From e107308f78f1b4cc01d51fff386c67304b71d0e6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:00:29 -0400 Subject: [PATCH 1/3] fix(sdk+cli): surface real errors instead of bare {} when server returns empty body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the opencode server responded with an error (4xx/5xx) and an empty body, the auto-generated SDK threw a literal `{}` (see `finalError = finalError || ({} as string)` in client.gen.ts). The TUI's exit handler ran that through `errorFormat` → `JSON.stringify({}, null, 2)` → `"{}"`, which printed a useless bare `{}` on stderr and dropped any clue about what had failed. Two-layer fix: 1. SDK wrapper (`packages/sdk/js/src/v2/client.ts`) — install an error interceptor that wraps any non-Error rejection in a real Error carrying method, url, status, and (when available) the response body. Now downstream catches see e.g. `Error: opencode server GET http://opencode.internal/config/providers → 500: (empty response body)` instead of `{}`. 2. Defensive fallback (`packages/opencode/src/util/error.ts`) — when `JSON.stringify(error, null, 2)` returns `"{}"`, fall back to the object's `toString` (if non-default), then to its constructor name + own property names. Any unknown error class still surfaces *something* useful instead of a bare `{}`. Reproducer: `mkdir /tmp/r && echo '{ "broken": true' > /tmp/r/opencode.json && bun run dev /tmp/r` --- packages/opencode/src/util/error.ts | 15 ++++++++++++++- packages/opencode/test/util/error.test.ts | 13 +++++++++++++ packages/sdk/js/src/v2/client.ts | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index fbda2dc50e02..42785035ae76 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -7,7 +7,20 @@ export function errorFormat(error: unknown): string { if (typeof error === "object" && error !== null) { try { - return JSON.stringify(error, null, 2) + const json = JSON.stringify(error, null, 2) + // Plain objects whose own properties are all non-enumerable (or empty) + // serialize to "{}", which prints as a useless bare `{}` on stderr. + // Fall back to a custom toString first, then to ctor name + own prop + // names — anything but a bare `{}`. + if (json === "{}") { + const str = String(error) + if (str && str !== "[object Object]") return str + const ctor = error.constructor?.name && error.constructor.name !== "Object" ? error.constructor.name : "" + const names = Object.getOwnPropertyNames(error).filter((n) => n !== "stack") + const prefix = ctor || "Error" + return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }` + } + return json } catch { return "Unexpected error (unserializable)" } diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index e536f3c4ea77..e7a02d6151e3 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -22,6 +22,19 @@ describe("util.error", () => { expect(data.code).toBe("E_BAD") }) + test("never returns bare {} for opaque object errors", () => { + // Plain empty object — what the SDK threw before we wrapped it. + expect(errorFormat({})).not.toBe("{}") + expect(errorFormat({})).toContain("no message") + + // Object with only non-enumerable own properties (JSON.stringify drops them). + class OpaqueError {} + const opaque = new OpaqueError() + Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false }) + expect(errorFormat(opaque)).not.toBe("{}") + expect(errorFormat(opaque)).toContain("OpaqueError") + }) + test("handles opaque throwables with custom toString", () => { const err = { toString() { diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 2d71d8446de6..fa18ab85b467 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -84,5 +84,23 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) + // The generated client falls back to throwing a literal `{}` when the server + // responds with an empty / unparseable error body, which then surfaces as a + // bare `{}` in TUI / CLI error output. Wrap that case in a real Error so + // downstream formatters get a useful message + status / url / method. + client.interceptors.error.use((error, response, request) => { + if (error instanceof Error) return error + const status = response?.status ?? 0 + const statusText = response?.statusText ?? "" + const method = request?.method ?? "?" + const url = request?.url ?? "?" + const detail = + typeof error === "string" && error + ? error + : error && typeof error === "object" && Object.keys(error).length > 0 + ? JSON.stringify(error) + : "(empty response body)" + return new Error(`opencode server ${method} ${url} → ${status}${statusText ? " " + statusText : ""}: ${detail}`) + }) return new OpencodeClient({ client }) } From 755c17fce266c32e8148ec53d5d671d21364b043 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:05:02 -0400 Subject: [PATCH 2/3] review: narrow SDK interceptor to only wrap empty-body case Previous version wrapped every non-Error rejection, which would have also transformed parsed-JSON error bodies (e.g. `{ error: { code: 'X' } }`) into Errors and broken any external SDK consumer that does `catch (e => e.error?.code)`. Now the interceptor only fires when the rejected value is empty (undefined, null, '', or {}), preserving every other case unchanged. --- packages/sdk/js/src/v2/client.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index fa18ab85b467..37564b1f9683 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -85,22 +85,24 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) // The generated client falls back to throwing a literal `{}` when the server - // responds with an empty / unparseable error body, which then surfaces as a - // bare `{}` in TUI / CLI error output. Wrap that case in a real Error so - // downstream formatters get a useful message + status / url / method. + // responds with an empty / unparseable error body, which surfaces as a bare + // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so + // downstream formatters get a useful message — but pass through any parsed + // JSON error body unchanged so existing consumers can still inspect fields. client.interceptors.error.use((error, response, request) => { - if (error instanceof Error) return error + const isEmpty = + error === undefined || + error === null || + error === "" || + (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) + if (!isEmpty) return error const status = response?.status ?? 0 const statusText = response?.statusText ?? "" const method = request?.method ?? "?" const url = request?.url ?? "?" - const detail = - typeof error === "string" && error - ? error - : error && typeof error === "object" && Object.keys(error).length > 0 - ? JSON.stringify(error) - : "(empty response body)" - return new Error(`opencode server ${method} ${url} → ${status}${statusText ? " " + statusText : ""}: ${detail}`) + return new Error( + `opencode server ${method} ${url} → ${status}${statusText ? " " + statusText : ""}: (empty response body)`, + ) }) return new OpencodeClient({ client }) } From 0ea661d5a22c2325aeff02f30af7eed0aecb1b5c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:08:00 -0400 Subject: [PATCH 3/3] review: simplify per agent feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete now-dead errorFormatted helper; inline-replace its 3 callers with errorFormat directly (errorFormat no longer returns '{}', so the wrapper's '{} → String(error)' fallback is unreachable). - Drop the redundant '!== "{}"' guard in errorMessage for the same reason. - Drop the pointless '!== "stack"' filter on Object.getOwnPropertyNames (we only print the names, not the values, so excluding 'stack' just hides a useful hint). - Flatten the ctor ternary in errorFormat for readability. - Branch the SDK interceptor on missing response so network errors say 'network error (no response)' instead of '→ 0: (empty response body)'. --- packages/opencode/src/util/error.ts | 22 ++++++++-------------- packages/sdk/js/src/v2/client.ts | 9 ++++----- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index 42785035ae76..32936e993568 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -10,14 +10,13 @@ export function errorFormat(error: unknown): string { const json = JSON.stringify(error, null, 2) // Plain objects whose own properties are all non-enumerable (or empty) // serialize to "{}", which prints as a useless bare `{}` on stderr. - // Fall back to a custom toString first, then to ctor name + own prop - // names — anything but a bare `{}`. + // Fall back to a custom toString first, then to ctor name + own prop names. if (json === "{}") { const str = String(error) if (str && str !== "[object Object]") return str - const ctor = error.constructor?.name && error.constructor.name !== "Object" ? error.constructor.name : "" - const names = Object.getOwnPropertyNames(error).filter((n) => n !== "stack") - const prefix = ctor || "Error" + const ctor = error.constructor?.name + const prefix = ctor && ctor !== "Object" ? ctor : "Error" + const names = Object.getOwnPropertyNames(error) return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }` } return json @@ -47,7 +46,7 @@ export function errorMessage(error: unknown): string { if (text && text !== "[object Object]") return text const formatted = errorFormat(error) - if (formatted && formatted !== "{}") return formatted + if (formatted) return formatted return "unknown error" } @@ -58,7 +57,7 @@ export function errorData(error: unknown) { message: errorMessage(error), stack: error.stack, cause: error.cause === undefined ? undefined : errorFormat(error.cause), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -66,7 +65,7 @@ export function errorData(error: unknown) { return { type: typeof error, message: errorMessage(error), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -84,12 +83,7 @@ export function errorData(error: unknown) { if (typeof data.message !== "string") data.message = errorMessage(error) if (typeof data.type !== "string") data.type = error.constructor?.name - data.formatted = errorFormatted(error) + data.formatted = errorFormat(error) return data } -function errorFormatted(error: unknown) { - const formatted = errorFormat(error) - if (formatted !== "{}") return formatted - return String(error) -} diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 37564b1f9683..8b49e7f101b5 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -96,13 +96,12 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp error === "" || (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) if (!isEmpty) return error - const status = response?.status ?? 0 - const statusText = response?.statusText ?? "" const method = request?.method ?? "?" const url = request?.url ?? "?" - return new Error( - `opencode server ${method} ${url} → ${status}${statusText ? " " + statusText : ""}: (empty response body)`, - ) + if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) + const status = response.status + const statusText = response.statusText ? " " + response.statusText : "" + return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) }) return new OpencodeClient({ client }) }