authorgravatar for 47115683+tachytelicdetonation@users.noreply.github.comtachytelicdetonation <47115683+tachytelicdetonation@users.noreply.github.com> 2026-10-13 18:51:09-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 18:51:09-05:00
log3feae3a94077317e78b7fd4591454ed68f9d4fd2
tree7e5aa0d6b0924c604d35688d24f5635917c19669
parent006c5086641dc05a8cbeb802929abe7052ada4a0
signature Signed by PGP key B5690EEEBB952194

fix(server): keep OpenCode timeouts from crashing the error formatter (#12469)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com>

2 files changed, 85 insertions(+), 1 deletions(-)

packages/provider-opencode/src/server/OpenCodeRuntime.errorDetail.test.ts created+72
......@@ -0,0 +1,72 @@
1import * as NodeAssert from "node:assert/strict";
2
3import * as Cause from "effect/Cause";
4import { describe, it } from "vite-plus/test";
5
6import { OpenCodeRuntimeError, openCodeRuntimeErrorDetail } from "./OpenCodeRuntime.ts";
7
8describe("openCodeRuntimeErrorDetail", () => {
9 it("formats an Effect TimeoutError constructed without a message", () => {
10 const detail = openCodeRuntimeErrorDetail(new Cause.TimeoutError());
11 NodeAssert.equal(typeof detail, "string");
12 NodeAssert.match(detail, /TimeoutError/);
13 });
14
15 it("returns the detail of an OpenCodeRuntimeError", () => {
16 const error = new OpenCodeRuntimeError({ operation: "session.abort", detail: "aborted" });
17 NodeAssert.equal(openCodeRuntimeErrorDetail(error), "aborted");
18 });
19
20 it("returns a trimmed Error message", () => {
21 NodeAssert.equal(openCodeRuntimeErrorDetail(new Error(" timed out ")), "timed out");
22 });
23
24 it("reads an Error message getter only once", () => {
25 const error = new Error();
26 let reads = 0;
27 Object.defineProperty(error, "message", {
28 get() {
29 reads += 1;
30 if (reads > 1) throw new Error("message was read twice");
31 return " timed out ";
32 },
33 });
34
35 NodeAssert.equal(openCodeRuntimeErrorDetail(error), "timed out");
36 NodeAssert.equal(reads, 1);
37 });
38
39 it("does not throw for an Error whose message is undefined", () => {
40 // Effect's TimeoutError constructed without an argument arrives this way.
41 const error = new Error("placeholder");
42 Object.defineProperty(error, "message", { value: undefined, writable: true });
43 const detail = openCodeRuntimeErrorDetail(error);
44 NodeAssert.equal(typeof detail, "string");
45 NodeAssert.ok(detail.length > 0);
46 });
47
48 it("does not throw for an Error whose message is not a string", () => {
49 const error = new Error("placeholder");
50 Object.defineProperty(error, "message", { value: { code: 42 }, writable: true });
51 const detail = openCodeRuntimeErrorDetail(error);
52 NodeAssert.equal(typeof detail, "string");
53 NodeAssert.ok(detail.length > 0);
54 });
55
56 it("falls through to the object fallback for an Error with an empty message", () => {
57 const error = Object.assign(new Error(""), { response: { status: 503 }, error: "down" });
58 NodeAssert.equal(openCodeRuntimeErrorDetail(error), 'status=503 body="down"');
59 });
60
61 it("formats SDK response shapes", () => {
62 NodeAssert.equal(
63 openCodeRuntimeErrorDetail({ response: { status: 500 }, error: { message: "boom" } }),
64 'status=500 body={"message":"boom"}',
65 );
66 });
67
68 it("stringifies primitives", () => {
69 NodeAssert.equal(openCodeRuntimeErrorDetail("plain"), "plain");
70 NodeAssert.equal(openCodeRuntimeErrorDetail(undefined), "undefined");
71 });
72});
packages/provider-opencode/src/server/OpenCodeRuntime.ts+13-1
......@@ -117,9 +117,21 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
117117 return Exit.isSuccess(result) ? result.value : undefined;
118118}
119119
120/**
121 * Formats OpenCode SDK and runtime failures, including errors with missing or
122 * non-string messages.
123 */
120124export function openCodeRuntimeErrorDetail(cause: unknown): string {
121125 if (isOpenCodeRuntimeError(cause)) return cause.detail;
122 if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim();
126 // Effect's TimeoutError can be constructed without a message, so `message`
127 // is not guaranteed to be a string on every Error that reaches here.
128 if (cause instanceof Error) {
129 const message = cause.message;
130 if (typeof message === "string") {
131 const trimmedMessage = message.trim();
132 if (trimmedMessage.length > 0) return trimmedMessage;
133 }
134 }
123135 if (cause && typeof cause === "object") {
124136 // SDK v2 throws { response, request, error? } shapes — extract what's useful
125137 const anyCause = cause as Record<string, unknown>;