From 7bb99e2e3a8c9374732e480015ce0f082afc6076 Mon Sep 17 00:00:00 2001 From: tachytelicdetonation Date: Fri, 18 Sep 2026 10:50:39 -0500 Subject: [PATCH 1/4] fix(server): keep OpenCode timeouts from crashing the error formatter `openCodeRuntimeErrorDetail` called `.trim()` on `cause.message` for every `Error`, but Effect's `TimeoutError` can be constructed without a message. When an unresponsive OpenCode timed out the prompt and its cleanup abort, formatting the abort cause threw a TypeError, which replaced the useful timeout warning with a defect and skipped prompt-admission recovery. Only trim `message` when it is a string, and fall through to the existing object and `String(cause)` fallbacks otherwise. Covered by unit tests for the formatter and an adapter test for the prompt-timeout cleanup path. Fixes #12456 Co-Authored-By: Claude Fable 5.1 --- .../provider/Layers/OpenCodeAdapter.test.ts | 67 +++++++++++++++++++ .../opencodeRuntime.errorDetail.test.ts | 57 ++++++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 7 +- 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/opencodeRuntime.errorDetail.test.ts diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 96d6b10d3839..8ce57c60bb50 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7962,4 +7962,71 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.deepEqual(closeCallsDuringRun, []); }), ); + + it.effect( + "still warns and schedules recovery when a timed-out prompt's cleanup abort fails without a message", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-prompt-timeout-abort-without-message"); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.promptAsyncImplementation = () => new Promise(() => {}); + runtimeMock.state.abortImplementation = async () => { + // Effect's TimeoutError can be constructed without a message; older + // builds of this adapter crashed while formatting such an error. + const error = new Error("placeholder"); + Object.defineProperty(error, "message", { value: undefined, writable: true }); + throw error; + }; + const warningFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "This prompt never gets a response", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.exit, Effect.forkChild); + while (runtimeMock.state.promptCalls.length === 0) { + yield* Effect.yieldNow; + } + + yield* advanceTestClock(10_000); + + const warning = Option.getOrThrow( + yield* Fiber.join(warningFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(warning.type, "runtime.warning"); + if (warning.type !== "runtime.warning") return; + NodeAssert.equal( + warning.payload.message, + "OpenCode prompt submission failed and its cleanup abort did not complete.", + ); + const detail = warning.payload.detail; + NodeAssert.equal(typeof detail, "string"); + if (typeof detail !== "string") return; + NodeAssert.notEqual(detail, ""); + NodeAssert.doesNotMatch(detail, /Cannot read properties|is not a function/); + NodeAssert.ok(runtimeMock.state.abortCalls.length >= 1); + + // Recovery polls OpenCode for the prompt's message before giving up. + yield* advanceTestClock(1_000); + NodeAssert.ok(runtimeMock.state.messageCalls.length >= 1); + + const sendResult = yield* Fiber.join(sendFiber); + NodeAssert.equal(Exit.isFailure(sendResult), true); + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/opencodeRuntime.errorDetail.test.ts b/apps/server/src/provider/opencodeRuntime.errorDetail.test.ts new file mode 100644 index 000000000000..8367da465826 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.errorDetail.test.ts @@ -0,0 +1,57 @@ +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("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/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index e87f758e0e3d..c4821528d2b0 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -116,7 +116,12 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { export function openCodeRuntimeErrorDetail(cause: unknown): string { if (OpenCodeRuntimeError.is(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 && typeof cause.message === "string") { + const message = cause.message.trim(); + if (message.length > 0) return message; + } if (cause && typeof cause === "object") { // SDK v2 throws { response, request, error? } shapes — extract what's useful const anyCause = cause as Record; From e777a3bae014da6fdf3611299f9daca906f15611 Mon Sep 17 00:00:00 2001 From: tachytelicdetonation Date: Fri, 18 Sep 2026 10:53:19 -0500 Subject: [PATCH 2/4] test(server): wait on the prompt signal instead of polling Co-Authored-By: Claude Fable 5.1 --- .../server/src/provider/Layers/OpenCodeAdapter.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 8ce57c60bb50..c90ce7201e0f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7969,8 +7969,12 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-prompt-timeout-abort-without-message"); + const promptStarted = promiseWithResolvers(); runtimeMock.state.autoPromptEcho = false; - runtimeMock.state.promptAsyncImplementation = () => new Promise(() => {}); + runtimeMock.state.promptAsyncImplementation = () => { + promptStarted.resolve(undefined); + return new Promise(() => {}); + }; runtimeMock.state.abortImplementation = async () => { // Effect's TimeoutError can be constructed without a message; older // builds of this adapter crashed while formatting such an error. @@ -7998,9 +8002,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ), }) .pipe(Effect.exit, Effect.forkChild); - while (runtimeMock.state.promptCalls.length === 0) { - yield* Effect.yieldNow; - } + yield* Effect.promise(() => promptStarted.promise); yield* advanceTestClock(10_000); From 3b7d255574dc070562e674c5ac44f65adb5542e1 Mon Sep 17 00:00:00 2001 From: tachytelicdetonation Date: Fri, 18 Sep 2026 11:15:41 -0500 Subject: [PATCH 3/4] docs(server): describe the contract of openCodeRuntimeErrorDetail Co-Authored-By: Claude Fable 5.1 --- apps/server/src/provider/opencodeRuntime.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index c4821528d2b0..c3094dbdb4fe 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -114,6 +114,11 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { return Exit.isSuccess(result) ? result.value : undefined; } +/** + * Formats any failure cause from the OpenCode SDK or runtime as a diagnostic + * string. It runs inside cleanup and recovery paths, so it must never throw, + * whatever shape the cause has. + */ export function openCodeRuntimeErrorDetail(cause: unknown): string { if (OpenCodeRuntimeError.is(cause)) return cause.detail; // Effect's TimeoutError can be constructed without a message, so `message` From 7c67a7d707b76e38a7be2926a0d918c48ecb3647 Mon Sep 17 00:00:00 2001 From: tachytelicdetonation Date: Fri, 18 Sep 2026 11:29:52 -0500 Subject: [PATCH 4/4] test(server): cover the prompt-timeout plus abort-timeout sequence The issue's repro is a prompt that times out whose cleanup abort then times out as well. The existing test reproduces the crashing error shape directly; this one drives the full sequence through the adapter and checks the warning carries the abort timeout and recovery still runs. The vendored Effect supplies a timeout message, so this sequence passes with or without the formatter fix; it pins the recovery path. Co-Authored-By: Claude Fable 5.1 --- .../provider/Layers/OpenCodeAdapter.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index c90ce7201e0f..c8deb14604e0 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -8031,4 +8031,76 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect( + "still warns and schedules recovery when a timed-out prompt's cleanup abort also times out", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-prompt-timeout-abort-timeout"); + const promptStarted = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.promptAsyncImplementation = () => { + promptStarted.resolve(undefined); + return new Promise(() => {}); + }; + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + const warningFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "This prompt never gets a response", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.promise(() => promptStarted.promise); + + // The prompt times out, its cleanup abort hangs, and the abort's own + // timeout fires. The warning must carry the timeout, not a crash. + yield* advanceTestClock(10_000); + yield* Effect.promise(() => abortStarted.promise); + yield* advanceTestClock(1_000); + + const warning = Option.getOrThrow( + yield* Fiber.join(warningFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(warning.type, "runtime.warning"); + if (warning.type !== "runtime.warning") return; + NodeAssert.equal( + warning.payload.message, + "OpenCode prompt submission failed and its cleanup abort did not complete.", + ); + const detail = warning.payload.detail; + NodeAssert.equal(typeof detail, "string"); + if (typeof detail !== "string") return; + NodeAssert.equal(detail, "Operation timed out after '1s'"); + + // Recovery polls OpenCode for the prompt's message before giving up. + yield* advanceTestClock(1_000); + NodeAssert.ok(runtimeMock.state.messageCalls.length >= 1); + + const sendResult = yield* Fiber.join(sendFiber); + NodeAssert.equal(Exit.isFailure(sendResult), true); + abortRelease.resolve(undefined); + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); });