diff --git a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts index 63b3fb448769..6786fb1dc3ec 100644 --- a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts +++ b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts @@ -15,6 +15,12 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null { ) if (failed.length === 0) return null + const named = failed.map((f) => namedError(f.result.reason)) + const name = named[0]?.name + if (typeof name === "string" && named.every((item) => item?.name === name)) { + return Object.assign(new Error(name), named[0]) + } + const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` const err = new Error(summary) @@ -22,6 +28,14 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null { return err } +function namedError(reason: unknown): { name: unknown } | undefined { + const value = reason instanceof Error && reason.cause && typeof reason.cause === "object" && "body" in reason.cause + ? reason.cause.body + : reason + if (!value || typeof value !== "object" || !("name" in value)) return undefined + return value +} + function reasonMessage(reason: unknown): string { if (reason instanceof Error) return reason.message if (typeof reason === "string") return reason diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 9f8a384f777f..730f467e7b3a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -33,6 +33,7 @@ import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" import { aggregateFailures } from "./aggregate-failures" +import { errorData } from "@/util/error" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -465,11 +466,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }) .catch(async (e) => { - Log.Default.error("tui bootstrap failed", { - error: e instanceof Error ? e.message : String(e), - name: e instanceof Error ? e.name : undefined, - stack: e instanceof Error ? e.stack : undefined, - }) + Log.Default.error("tui bootstrap failed", errorData(e)) if (fatal) { await exit(e) } else { diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts index c9b3551d9a23..b0ec4dfdf0c3 100644 --- a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -52,4 +52,47 @@ describe("aggregateFailures", () => { const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }]) expect(err!.message).toContain("x: 42") }) + + test("surfaces a shared named error as an Error with structured fields", () => { + const reason = { name: "ConfigInvalidError", data: { message: "bad config" } } + const result = aggregateFailures([ + { name: "config", result: { status: "rejected", reason } }, + { name: "providers", result: { status: "rejected", reason: { name: "ConfigInvalidError", data: { message: "bad config" } } } }, + ]) + expect(result).toBeInstanceOf(Error) + expect(result!.name).toBe("ConfigInvalidError") + expect((result as Error & { data: unknown }).data).toEqual(reason.data) + }) + + test("surfaces a wrapped named error from SDK throwOnError", () => { + const body = { name: "ConfigInvalidError", data: { issues: [{ message: "expected string", path: ["model"] }] } } + const reason = new Error("ConfigInvalidError", { cause: { body, status: 400 } }) + const result = aggregateFailures([ + { name: "config", result: { status: "rejected", reason } }, + { name: "providers", result: { status: "rejected", reason } }, + ]) + expect(result).toBeInstanceOf(Error) + expect(result!.name).toBe("ConfigInvalidError") + expect((result as Error & { data: unknown }).data).toEqual(body.data) + }) + + test("aggregates when failures have different named error names", () => { + const result = aggregateFailures([ + { name: "config", result: { status: "rejected", reason: { name: "ConfigInvalidError", data: {} } } }, + { name: "providers", result: { status: "rejected", reason: { name: "ProviderInitError", data: {} } } }, + ]) + expect(result).toBeInstanceOf(Error) + expect(result!.message).toContain("ConfigInvalidError") + expect(result!.message).toContain("ProviderInitError") + }) + + test("aggregates generic Errors even when they all share the same name", () => { + const result = aggregateFailures([ + { name: "a", result: { status: "rejected", reason: new Error("foo") } }, + { name: "b", result: { status: "rejected", reason: new Error("bar") } }, + ]) + expect(result).toBeInstanceOf(Error) + expect(result!.message).toContain("foo") + expect(result!.message).toContain("bar") + }) })