diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 96d6b10d3839..c8deb14604e0 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7962,4 +7962,145 @@ 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"); + const promptStarted = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + 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. + 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); + yield* Effect.promise(() => promptStarted.promise); + + 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); + }), + ); + + 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); + }), + ); }); 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..c3094dbdb4fe 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -114,9 +114,19 @@ 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; - 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;