diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.errorDetail.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.errorDetail.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3ca63289c67e55f41399be99b4d7eccb6d237c6 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.errorDetail.test.ts @@ -0,0 +1,72 @@ +import * as NodeAssert from "node:assert/strict"; + +import * as Cause from "effect/Cause"; +import { describe, it } from "vite-plus/test"; + +import { OpenCodeRuntimeError, openCodeRuntimeErrorDetail } from "./OpenCodeRuntime.ts"; + +describe("openCodeRuntimeErrorDetail", () => { + it("formats an Effect TimeoutError constructed without a message", () => { + const detail = openCodeRuntimeErrorDetail(new Cause.TimeoutError()); + NodeAssert.equal(typeof detail, "string"); + NodeAssert.match(detail, /TimeoutError/); + }); + + it("returns the detail of an OpenCodeRuntimeError", () => { + const error = new OpenCodeRuntimeError({ operation: "session.abort", detail: "aborted" }); + NodeAssert.equal(openCodeRuntimeErrorDetail(error), "aborted"); + }); + + it("returns a trimmed Error message", () => { + NodeAssert.equal(openCodeRuntimeErrorDetail(new Error(" timed out ")), "timed out"); + }); + + it("reads an Error message getter only once", () => { + const error = new Error(); + let reads = 0; + Object.defineProperty(error, "message", { + get() { + reads += 1; + if (reads > 1) throw new Error("message was read twice"); + return " timed out "; + }, + }); + + NodeAssert.equal(openCodeRuntimeErrorDetail(error), "timed out"); + NodeAssert.equal(reads, 1); + }); + + it("does not throw for an Error whose message is undefined", () => { + // Effect's TimeoutError constructed without an argument arrives this way. + const error = new Error("placeholder"); + Object.defineProperty(error, "message", { value: undefined, writable: true }); + const detail = openCodeRuntimeErrorDetail(error); + NodeAssert.equal(typeof detail, "string"); + NodeAssert.ok(detail.length > 0); + }); + + it("does not throw for an Error whose message is not a string", () => { + const error = new Error("placeholder"); + Object.defineProperty(error, "message", { value: { code: 42 }, writable: true }); + const detail = openCodeRuntimeErrorDetail(error); + NodeAssert.equal(typeof detail, "string"); + NodeAssert.ok(detail.length > 0); + }); + + it("falls through to the object fallback for an Error with an empty message", () => { + const error = Object.assign(new Error(""), { response: { status: 503 }, error: "down" }); + NodeAssert.equal(openCodeRuntimeErrorDetail(error), 'status=503 body="down"'); + }); + + it("formats SDK response shapes", () => { + NodeAssert.equal( + openCodeRuntimeErrorDetail({ response: { status: 500 }, error: { message: "boom" } }), + 'status=500 body={"message":"boom"}', + ); + }); + + it("stringifies primitives", () => { + NodeAssert.equal(openCodeRuntimeErrorDetail("plain"), "plain"); + NodeAssert.equal(openCodeRuntimeErrorDetail(undefined), "undefined"); + }); +}); diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index 5e87afe5ed598c50c077574d8ba67c2cc486a18a..b6651d48a721b975db526ef34cdf8a6983a2a43f 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -117,9 +117,21 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { return Exit.isSuccess(result) ? result.value : undefined; } +/** + * Formats OpenCode SDK and runtime failures, including errors with missing or + * non-string messages. + */ export function openCodeRuntimeErrorDetail(cause: unknown): string { if (isOpenCodeRuntimeError(cause)) return cause.detail; - if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); + // Effect's TimeoutError can be constructed without a message, so `message` + // is not guaranteed to be a string on every Error that reaches here. + if (cause instanceof Error) { + const message = cause.message; + if (typeof message === "string") { + const trimmedMessage = message.trim(); + if (trimmedMessage.length > 0) return trimmedMessage; + } + } if (cause && typeof cause === "object") { // SDK v2 throws { response, request, error? } shapes — extract what's useful const anyCause = cause as Record;