import assert from "node:assert/strict"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { describe, it } from "vitest"; import { ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; import { buildTurnStartParams, isRecoverableThreadResumeError, openCodexThread, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); function makeThreadOpenResponse( threadId: string, ): CodexRpc.ClientRequestResponsesByMethod["thread/start"] { return { cwd: "/tmp/project", model: "gpt-5.3-codex", modelProvider: "openai", approvalPolicy: "never", approvalsReviewer: "user", sandbox: { type: "danger-full-access" }, thread: { id: threadId, createdAt: "2026-04-18T00:00:00.000Z", source: { session: "cli" }, turns: [], status: { state: "idle", activeFlags: [], }, }, } as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"]; } describe("buildTurnStartParams", () => { it("includes plan collaboration mode when requested", () => { const params = Effect.runSync( buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "full-access", prompt: "Make a plan", model: "gpt-5.3-codex", effort: "medium", interactionMode: "plan", }), ); assert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess", }, input: [ { type: "text", text: "Make a plan", }, ], model: "gpt-5.3-codex", effort: "medium", collaborationMode: { mode: "plan", settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, }, }, }); }); it("includes default collaboration mode and image attachments", () => { const params = Effect.runSync( buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "auto-accept-edits", prompt: "Implement it", model: "gpt-5.3-codex", interactionMode: "default", attachments: [ { type: "image", url: "data:image/png;base64,abc", }, ], }), ); assert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "on-request", sandboxPolicy: { type: "workspaceWrite", }, input: [ { type: "text", text: "Implement it", }, { type: "image", url: "data:image/png;base64,abc", }, ], model: "gpt-5.3-codex", collaborationMode: { mode: "default", settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", developer_instructions: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, }, }, }); }); it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "approval-required", prompt: "Review", }), ); assert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "untrusted", sandboxPolicy: { type: "readOnly", }, input: [ { type: "text", text: "Review", }, ], }); }); }); describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { assert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, errorMessage: "Thread does not exist", }), ), true, ); }); it("ignores non-recoverable resume errors", () => { assert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, errorMessage: "Permission denied", }), ), false, ); }); it("ignores unrelated missing-resource errors that do not mention threads", () => { assert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, errorMessage: "Config file not found", }), ), false, ); assert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, errorMessage: "Model does not exist", }), ), false, ); }); }); describe("openCodexThread", () => { it("falls back to thread/start when resume fails recoverably", async () => { const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; const started = makeThreadOpenResponse("fresh-thread"); const client = { request: ( method: M, payload: CodexRpc.ClientRequestParamsByMethod[M], ) => { calls.push({ method, payload }); if (method === "thread/resume") { return Effect.fail( new CodexErrors.CodexAppServerRequestError({ code: -32603, errorMessage: "thread not found", }), ); } return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); }, }; const opened = await Effect.runPromise( openCodexThread({ client, threadId: ThreadId.make("thread-1"), runtimeMode: "full-access", cwd: "/tmp/project", requestedModel: "gpt-5.3-codex", serviceTier: undefined, resumeThreadId: "stale-thread", }), ); assert.equal(opened.thread.id, "fresh-thread"); assert.deepStrictEqual( calls.map((call) => call.method), ["thread/resume", "thread/start"], ); }); it("propagates non-recoverable resume failures", async () => { const client = { request: ( method: M, _payload: CodexRpc.ClientRequestParamsByMethod[M], ) => { if (method === "thread/resume") { return Effect.fail( new CodexErrors.CodexAppServerRequestError({ code: -32603, errorMessage: "timed out waiting for server", }), ); } return Effect.succeed( makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], ); }, }; await assert.rejects( Effect.runPromise( openCodexThread({ client, threadId: ThreadId.make("thread-1"), runtimeMode: "full-access", cwd: "/tmp/project", requestedModel: "gpt-5.3-codex", serviceTier: undefined, resumeThreadId: "stale-thread", }), ), (error: unknown) => isCodexAppServerRequestError(error) && error.errorMessage === "timed out waiting for server", ); }); });