Files
paperclip/server/src/__tests__/codex-local-adapter.test.ts
2026-03-11 20:56:47 -05:00

253 lines
7.3 KiB
TypeScript

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