diff --git a/packages/core/src/util/error.ts b/packages/core/src/util/error.ts index 5fe41e1cff99..a1caa323e5ec 100644 --- a/packages/core/src/util/error.ts +++ b/packages/core/src/util/error.ts @@ -40,7 +40,13 @@ export abstract class NamedError extends Error { public readonly data: Data, options?: ErrorOptions, ) { - super(name, options) + // Error.message is the human detail only; the tag lives on `.name`, and renderers + // (Cause.pretty, Error.toString) compose "${name}: ${message}". + const message = + typeof data === "object" && data !== null && "message" in data && typeof data.message === "string" + ? data.message + : "" + super(message, options) this.name = name } diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index c395ac017552..43d6cc4a4375 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -106,7 +106,9 @@ async function cleanup(dir: string) { } function formatError(error: Error, depth = 0): string { - const result = error.message + // toString() composes "${name}: ${message}" (and drops the colon when message is empty), + // so the error's tag is never lost the way a bare error.message would lose it. + const result = error.toString() return error.cause instanceof Error && depth < 10 ? result + " Caused by: " + formatError(error.cause, depth + 1) : result diff --git a/packages/core/test/util-error.test.ts b/packages/core/test/util-error.test.ts new file mode 100644 index 000000000000..73fff259d1c1 --- /dev/null +++ b/packages/core/test/util-error.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test" +import { Cause, Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" + +const WithMessage = NamedError.create("FooError", { message: Schema.String }) +const NoMessage = NamedError.create("BarError", { providerID: Schema.String }) + +const prettyHead = (error: Error) => Cause.pretty(Cause.fail(error)).split("\n")[0] + +describe("NamedError rendering", () => { + it("puts the human detail on .message and the tag on .name", () => { + const error = new WithMessage({ message: "boom" }) + expect(error.name).toBe("FooError") + expect(error.message).toBe("boom") + }) + + it("composes 'name: message' exactly once, with no doubled tag", () => { + const error = new WithMessage({ message: "boom" }) + expect(error.toString()).toBe("FooError: boom") + expect(prettyHead(error)).toBe("FooError: boom") + }) + + it("renders only the tag when data has no message field", () => { + const error = new NoMessage({ providerID: "anthropic" }) + expect(error.message).toBe("") + expect(error.toString()).toBe("BarError") + // Cause.pretty composes "${name}: ${message}" unconditionally, so an empty message + // leaves a trailing ": " here. Acceptable; the log path uses toString(), which omits it. + expect(prettyHead(error)).toBe("BarError: ") + }) + + it("ignores a non-string message field", () => { + const error = new WithMessage({ message: undefined as unknown as string }) + expect(error.message).toBe("") + expect(error.toString()).toBe("FooError") + }) +})