import { describe, expect, it, vi } from "vitest"; import { CodexAppServerManager, classifyCodexStderrLine, isRecoverableThreadResumeError, normalizeCodexModelSlug, } from "./codexAppServerManager"; function createSendTurnHarness() { const manager = new CodexAppServerManager(); const context = { session: { sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, }; const requireSession = vi .spyOn( manager as unknown as { requireSession: (sessionId: string) => unknown }, "requireSession", ) .mockReturnValue(context); const sendRequest = vi .spyOn( manager as unknown as { sendRequest: (...args: unknown[]) => Promise }, "sendRequest", ) .mockResolvedValue({ turn: { id: "turn_1", }, }); const updateSession = vi .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") .mockImplementation(() => {}); return { manager, context, requireSession, sendRequest, updateSession }; } describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { expect(classifyCodexStderrLine(" ")).toBeNull(); }); it("ignores non-error structured codex logs", () => { const line = "2026-02-08T04:24:19.241256Z WARN codex_core::features: unknown feature key in config: skills"; expect(classifyCodexStderrLine(line)).toBeNull(); }); it("ignores known benign rollout path errors", () => { const line = "\u001b[2m2026-02-08T04:24:20.085687Z\u001b[0m \u001b[31mERROR\u001b[0m \u001b[2mcodex_core::rollout::list\u001b[0m: state db missing rollout path for thread 019c3b6c-46b8-7b70-ad23-82f824d161fb"; expect(classifyCodexStderrLine(line)).toBeNull(); }); it("keeps unknown structured errors", () => { const line = "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure"; expect(classifyCodexStderrLine(line)).toEqual({ message: line, }); }); it("keeps plain stderr messages", () => { const line = "fatal: permission denied"; expect(classifyCodexStderrLine(line)).toEqual({ message: line, }); }); }); describe("normalizeCodexModelSlug", () => { it("maps 5.3 aliases to gpt-5.3-codex", () => { expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); }); it("prefers codex id when model differs", () => { expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe("gpt-5.3-codex"); }); it("keeps non-aliased models as-is", () => { expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); }); }); describe("isRecoverableThreadResumeError", () => { it("matches not-found resume errors", () => { expect( isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")), ).toBe(true); }); it("ignores non-resume errors", () => { expect( isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")), ).toBe(false); }); it("ignores non-recoverable resume errors", () => { expect( isRecoverableThreadResumeError( new Error("thread/resume failed: timed out waiting for server"), ), ).toBe(false); }); }); describe("sendTurn", () => { it("sends text and image user input items to turn/start", async () => { const { manager, context, requireSession, sendRequest, updateSession } = createSendTurnHarness(); const result = await manager.sendTurn({ sessionId: "sess_1", input: "Inspect this image", attachments: [ { type: "image", name: "error.png", mimeType: "image/png", sizeBytes: 1_024, dataUrl: "data:image/png;base64,AAAA", }, ], model: "gpt-5.3", effort: "high", }); expect(result).toEqual({ threadId: "thread_1", turnId: "turn_1", }); expect(requireSession).toHaveBeenCalledWith("sess_1"); expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { threadId: "thread_1", input: [ { type: "text", text: "Inspect this image", text_elements: [], }, { type: "image", url: "data:image/png;base64,AAAA", }, ], model: "gpt-5.3-codex", effort: "high", }); expect(updateSession).toHaveBeenCalledWith(context, { status: "running", activeTurnId: "turn_1", }); }); it("supports image-only turns", async () => { const { manager, context, sendRequest } = createSendTurnHarness(); await manager.sendTurn({ sessionId: "sess_1", attachments: [ { type: "image", name: "diagram.png", mimeType: "image/png", sizeBytes: 256, dataUrl: "data:image/png;base64,BBBB", }, ], }); expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { threadId: "thread_1", input: [ { type: "image", url: "data:image/png;base64,BBBB", }, ], }); }); it("rejects empty turn input", async () => { const { manager } = createSendTurnHarness(); await expect( manager.sendTurn({ sessionId: "sess_1", }), ).rejects.toThrow("Turn input must include text or attachments."); }); });