feat: adapter model discovery, reasoning effort, and improved codex formatting

Add dynamic OpenAI model list fetching for codex adapter with caching,
async listModels interface, reasoning effort support for both claude and
codex adapters, optional timeouts (default to unlimited), wakeCommentId
context propagation, and richer codex stdout event parsing/formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 10:32:07 -06:00
parent fdd2ea6157
commit 2c3c2cf724
19 changed files with 890 additions and 76 deletions

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { models as codexFallbackModels } from "@paperclip/adapter-codex-local";
import { listAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
describe("adapter model listing", () => {
beforeEach(() => {
delete process.env.OPENAI_API_KEY;
resetCodexModelsCacheForTests();
vi.restoreAllMocks();
});
it("returns an empty list for unknown adapters", async () => {
const models = await listAdapterModels("unknown_adapter");
expect(models).toEqual([]);
});
it("returns codex fallback models when no OpenAI key is available", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const models = await listAdapterModels("codex_local");
expect(models).toEqual(codexFallbackModels);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("loads codex models dynamically and merges fallback options", async () => {
process.env.OPENAI_API_KEY = "sk-test";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "gpt-5-pro" },
{ id: "gpt-5" },
],
}),
} as Response);
const first = await listAdapterModels("codex_local");
const second = await listAdapterModels("codex_local");
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first.some((model) => model.id === "gpt-5-pro")).toBe(true);
expect(first.some((model) => model.id === "codex-mini-latest")).toBe(true);
});
it("falls back to static codex models when OpenAI model discovery fails", async () => {
process.env.OPENAI_API_KEY = "sk-test";
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
} as Response);
const models = await listAdapterModels("codex_local");
expect(models).toEqual(codexFallbackModels);
});
});

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { isCodexUnknownSessionError, parseCodexJsonl } from "@paperclip/adapter-codex-local/server";
import { parseCodexStdoutLine } from "@paperclip/adapter-codex-local/ui";
import { printCodexStreamEvent } from "@paperclip/adapter-codex-local/cli";
describe("codex_local parser", () => {
it("extracts session, summary, usage, and terminal error message", () => {
@@ -30,3 +32,220 @@ describe("codex_local stale session detection", () => {
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",
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/genericuser/paperclip/ui/src/pages/AgentDetail.tsx", kind: "update" }],
status: "completed",
},
}),
ts,
),
).toEqual([
{
kind: "system",
ts,
text: "file changes: update /Users/genericuser/paperclip/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: "/Users/genericuser/paperclip/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 /Users/genericuser/paperclip/ui/src/pages/AgentDetail.tsx",
"turn failed: model access denied",
"tokens: in=10 out=4 cached=2",
"error: resume model mismatch",
]));
} finally {
spy.mockRestore();
}
});
});