Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
runtimeMock.state.autoPromptEcho = false;
runtimeMock.state.promptAsyncImplementation = () => {
promptStarted.resolve(undefined);
return new Promise<void>(() => {});
};
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<void>();
const abortStarted = promiseWithResolvers<void>();
const abortRelease = promiseWithResolvers<void>();
runtimeMock.state.autoPromptEcho = false;
runtimeMock.state.promptAsyncImplementation = () => {
promptStarted.resolve(undefined);
return new Promise<void>(() => {});
};
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);
}),
);
});
57 changes: 57 additions & 0 deletions apps/server/src/provider/opencodeRuntime.errorDetail.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
12 changes: 11 additions & 1 deletion apps/server/src/provider/opencodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +126 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High provider/opencodeRuntime.ts:126

This formatter throws instead of returning a diagnostic when an Error subclass changes or throws on its second message getter access. The typeof check and subsequent .trim() read cause.message separately; read it once into a local before checking and trimming.

Suggested change
if (cause instanceof Error && typeof cause.message === "string") {
const message = cause.message.trim();
if (message.length > 0) return message;
}
if (cause instanceof Error) {
const message = cause.message;
if (typeof message === "string") {
const trimmedMessage = message.trim();
if (trimmedMessage.length > 0) return trimmedMessage;
}
}
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/opencodeRuntime.ts around lines 126-129:

This formatter throws instead of returning a diagnostic when an `Error` subclass changes or throws on its second `message` getter access. The `typeof` check and subsequent `.trim()` read `cause.message` separately; read it once into a local before checking and trimming.

if (cause && typeof cause === "object") {
// SDK v2 throws { response, request, error? } shapes — extract what's useful
const anyCause = cause as Record<string, unknown>;
Expand Down
Loading