Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/core/src/util/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/util/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions packages/core/test/util-error.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading