Skip to content
Draft
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
14 changes: 14 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,27 @@ 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)
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
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
Expand Down
7 changes: 2 additions & 5 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
Loading