| 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/paperclipuser/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(); |
| } |
| }); |
| }); |
|
|