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:
58
server/src/__tests__/adapter-models.test.ts
Normal file
58
server/src/__tests__/adapter-models.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user