import { describe, expect, it, vi } from "vitest"; import { isCodexUnknownSessionError, parseCodexJsonl } from "@paperclipai/adapter-codex-local/server"; import { parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; describe("codex_local parser", () => { it("extracts session, summary, usage, and terminal error message", () => { const stdout = [ JSON.stringify({ type: "thread.started", thread_id: "thread-123" }), JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } }), JSON.stringify({ type: "turn.completed", usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 } }), JSON.stringify({ type: "turn.failed", error: { message: "model access denied" } }), ].join("\n"); const parsed = parseCodexJsonl(stdout); expect(parsed.sessionId).toBe("thread-123"); expect(parsed.summary).toBe("hello"); expect(parsed.usage).toEqual({ inputTokens: 10, cachedInputTokens: 2, outputTokens: 4, }); expect(parsed.errorMessage).toBe("model access denied"); }); }); describe("codex_local stale session detection", () => { it("treats missing rollout path as an unknown session error", () => { const stderr = "2026-02-19T19:58:53.281939Z ERROR codex_core::rollout::list: state db missing rollout path for thread 019c775d-967c-7ef1-acc7-e396dc2c87cc"; expect(isCodexUnknownSessionError("", stderr)).toBe(true); }); }); describe("codex_local ui stdout parser", () => { it("parses turn and reasoning lifecycle events", () => { const ts = "2026-02-20T00:00:00.000Z"; expect(parseCodexStdoutLine(JSON.stringify({ type: "turn.started" }), ts)).toEqual([ { kind: "system", ts, text: "turn started" }, ]); expect( parseCodexStdoutLine( JSON.stringify({ type: "item.completed", item: { id: "item_1", type: "reasoning", text: "**Preparing to use paperclip skill**" }, }), ts, ), ).toEqual([ { kind: "thinking", ts, text: "**Preparing to use paperclip skill**" }, ]); }); it("parses command execution and file changes", () => { const ts = "2026-02-20T00:00:00.000Z"; expect( parseCodexStdoutLine( JSON.stringify({ type: "item.started", item: { id: "item_2", type: "command_execution", command: "/bin/zsh -lc ls", status: "in_progress" }, }), ts, ), ).toEqual([ { kind: "tool_call", ts, name: "command_execution", toolUseId: "item_2", input: { id: "item_2", command: "/bin/zsh -lc ls" }, }, ]); expect( parseCodexStdoutLine( JSON.stringify({ type: "item.completed", item: { id: "item_2", type: "command_execution", command: "/bin/zsh -lc ls", aggregated_output: "agents\n", exit_code: 0, status: "completed", }, }), ts, ), ).toEqual([ { kind: "tool_result", ts, toolUseId: "item_2", content: "command: /bin/zsh -lc ls\nstatus: completed\nexit_code: 0\n\nagents", isError: false, }, ]); expect( parseCodexStdoutLine( JSON.stringify({ type: "item.completed", item: { id: "item_52", type: "file_change", changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }], status: "completed", }, }), ts, ), ).toEqual([ { kind: "system", ts, text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx", }, ]); }); it("parses error items and failed turns", () => { const ts = "2026-02-20T00:00:00.000Z"; expect( parseCodexStdoutLine( JSON.stringify({ type: "item.completed", item: { id: "item_0", type: "error", message: "This session was recorded with model `gpt-5.2-pro` but is resuming with `gpt-5.2-codex`.", }, }), ts, ), ).toEqual([ { kind: "stderr", ts, text: "This session was recorded with model `gpt-5.2-pro` but is resuming with `gpt-5.2-codex`.", }, ]); expect( parseCodexStdoutLine( JSON.stringify({ type: "turn.failed", error: { message: "model access denied" }, usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 }, }), ts, ), ).toEqual([ { kind: "result", ts, text: "", inputTokens: 10, outputTokens: 4, cachedTokens: 2, costUsd: 0, subtype: "turn.failed", isError: true, errors: ["model access denied"], }, ]); }); }); function stripAnsi(value: string): string { return value.replace(/\x1b\[[0-9;]*m/g, ""); } describe("codex_local cli formatter", () => { it("prints lifecycle, command execution, file change, and error events", () => { const spy = vi.spyOn(console, "log").mockImplementation(() => {}); try { printCodexStreamEvent(JSON.stringify({ type: "turn.started" }), false); printCodexStreamEvent( JSON.stringify({ type: "item.started", item: { id: "item_2", type: "command_execution", command: "/bin/zsh -lc ls", status: "in_progress" }, }), false, ); printCodexStreamEvent( JSON.stringify({ type: "item.completed", item: { id: "item_2", type: "command_execution", command: "/bin/zsh -lc ls", aggregated_output: "agents\n", exit_code: 0, status: "completed", }, }), false, ); printCodexStreamEvent( JSON.stringify({ type: "item.completed", item: { id: "item_52", type: "file_change", changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }], status: "completed", }, }), false, ); printCodexStreamEvent( JSON.stringify({ type: "turn.failed", error: { message: "model access denied" }, usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 }, }), false, ); printCodexStreamEvent( JSON.stringify({ type: "item.completed", item: { type: "error", message: "resume model mismatch" }, }), false, ); const lines = spy.mock.calls .map((call) => call.map((v) => String(v)).join(" ")) .map(stripAnsi); expect(lines).toEqual(expect.arrayContaining([ "turn started", "tool_call: command_execution", "/bin/zsh -lc ls", "tool_result: command_execution command=\"/bin/zsh -lc ls\" status=completed exit_code=0", "agents", "file_change: update /home/user/project/ui/src/pages/AgentDetail.tsx", "turn failed: model access denied", "tokens: in=10 out=4 cached=2", "error: resume model mismatch", ])); } finally { spy.mockRestore(); } }); });