Kaynağa Gözat

fix(tui): aggregate bootstrap request failures

TUI bootstrap now reports all parallel fetch failures together instead of losing sibling failures after the first rejection.
Kit Langton 4 ay önce
ebeveyn
işleme
29b5b24787

+ 34 - 0
packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts

@@ -0,0 +1,34 @@
+/**
+ * Aggregate Promise.allSettled results into a single Error that names every
+ * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
+ * boundaries so a single 4xx doesn't drown its parallel siblings as
+ * unhandled rejections — every failure surfaces in one labeled message.
+ */
+export type LabeledSettled = {
+  name: string
+  result: PromiseSettledResult<unknown>
+}
+
+export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
+  const failed = labeled.filter(
+    (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
+  )
+  if (failed.length === 0) return null
+
+  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 reasonMessage(reason: unknown): string {
+  if (reason instanceof Error) return reason.message
+  if (typeof reason === "string") return reason
+  if (reason && typeof reason === "object") {
+    const obj = reason as { message?: unknown; name?: unknown }
+    if (typeof obj.message === "string") return obj.message
+    if (typeof obj.name === "string") return obj.name
+  }
+  return String(reason)
+}

+ 18 - 8
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log"
 import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
 import path from "path"
 import { useKV } from "./kv"
+import { aggregateFailures } from "./aggregate-failures"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
@@ -391,16 +392,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         .catch(() => emptyConsoleState)
       const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
       const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
-      const blockingRequests: Promise<unknown>[] = [
-        providersPromise,
-        providerListPromise,
-        agentsPromise,
-        configPromise,
-        projectPromise,
-        ...(args.continue ? [sessionListPromise] : []),
+      const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
+        { name: "config.providers", promise: providersPromise },
+        { name: "provider.list", promise: providerListPromise },
+        { name: "app.agents", promise: agentsPromise },
+        { name: "config.get", promise: configPromise },
+        { name: "project.sync", promise: projectPromise },
+        ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
       ]
 
-      await Promise.all(blockingRequests)
+      await Promise.allSettled(blockingRequests.map((r) => r.promise))
+        .then((settled) => {
+          // Surface every failed endpoint in one labeled message instead of
+          // letting the first rejection drown its siblings as unhandled
+          // rejections.
+          const failure = aggregateFailures(
+            blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })),
+          )
+          if (failure) throw failure
+        })
         .then(async () => {
           const providersResponse = providersPromise.then((x) => x.data!)
           const providerListResponse = providerListPromise.then((x) => x.data!)

+ 59 - 0
packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts

@@ -0,0 +1,59 @@
+/**
+ * Regression test for the TUI bootstrap aggregation helper. Replaces the
+ * pre-fix Promise.all behavior where the first rejection drowned every
+ * sibling endpoint's failure as an unhandled rejection.
+ */
+import { describe, expect, test } from "bun:test"
+import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures"
+
+describe("aggregateFailures", () => {
+  test("returns null when every result is fulfilled", () => {
+    expect(
+      aggregateFailures([
+        { name: "config", result: { status: "fulfilled", value: 1 } },
+        { name: "providers", result: { status: "fulfilled", value: 2 } },
+      ]),
+    ).toBeNull()
+  })
+
+  test("names the failed endpoint when one rejects", () => {
+    const err = aggregateFailures([
+      { name: "config", result: { status: "fulfilled", value: 1 } },
+      {
+        name: "providers",
+        result: { status: "rejected", reason: new Error("Service unavailable") },
+      },
+    ])
+    expect(err).toBeInstanceOf(Error)
+    expect(err!.message).toContain("1 of 2")
+    expect(err!.message).toContain("providers: Service unavailable")
+  })
+
+  test("names every failed endpoint when multiple reject", () => {
+    const err = aggregateFailures([
+      { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } },
+      { name: "providers", result: { status: "fulfilled", value: 1 } },
+      { name: "agents", result: { status: "rejected", reason: { message: "boom" } } },
+    ])
+    expect(err).toBeInstanceOf(Error)
+    expect(err!.message).toContain("2 of 3")
+    expect(err!.message).toContain("config: 400 Bad Request")
+    expect(err!.message).toContain("agents: boom")
+  })
+
+  test("attaches structured failure list under .cause", () => {
+    const reason = new Error("nope")
+    const err = aggregateFailures([
+      { name: "providers", result: { status: "rejected", reason } },
+    ])
+    const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> }
+    expect(cause.failures).toEqual([{ name: "providers", reason }])
+  })
+
+  test("falls back to String() for opaque reasons", () => {
+    const err = aggregateFailures([
+      { name: "x", result: { status: "rejected", reason: 42 } },
+    ])
+    expect(err!.message).toContain("x: 42")
+  })
+})