diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 67e51dc6..0a3f4d06 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -6,6 +6,7 @@ export type { AdapterInvocationMeta, AdapterExecutionContext, AdapterSessionCodec, + AdapterModel, ServerAdapterModule, TranscriptEntry, StdoutLineParser, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 78938f4b..c744f4ce 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -201,15 +201,18 @@ export async function runChildProcess( let stderr = ""; let logChain: Promise = Promise.resolve(); - const timeout = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } - }, Math.max(1, opts.graceSec) * 1000); - }, Math.max(1, opts.timeoutSec) * 1000); + const timeout = + opts.timeoutSec > 0 + ? setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, Math.max(1, opts.graceSec) * 1000); + }, opts.timeoutSec * 1000) + : null; child.stdout?.on("data", (chunk) => { const text = String(chunk); @@ -228,7 +231,7 @@ export async function runChildProcess( }); child.on("error", (err) => { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); runningProcesses.delete(runId); const errno = (err as NodeJS.ErrnoException).code; const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; @@ -240,7 +243,7 @@ export async function runChildProcess( }); child.on("close", (code, signal) => { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); runningProcesses.delete(runId); void logChain.finally(() => { resolve({ diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ee60b5d8..1f767809 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -77,12 +77,18 @@ export interface AdapterExecutionContext { authToken?: string; } +export interface AdapterModel { + id: string; + label: string; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; sessionCodec?: AdapterSessionCodec; supportsLocalAgentJwt?: boolean; - models?: { id: string; label: string }[]; + models?: AdapterModel[]; + listModels?: () => Promise; agentConfigurationDoc?: string; } @@ -122,6 +128,7 @@ export interface CreateConfigValues { cwd: string; promptTemplate: string; model: string; + thinkingEffort: string; dangerouslySkipPermissions: boolean; search: boolean; dangerouslyBypassSandbox: boolean; diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 75a2c7c4..e359b384 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -14,6 +14,7 @@ Adapter: claude_local Core fields: - cwd (string, required): absolute working directory for the agent process - model (string, optional): Claude model id +- effort (string, optional): reasoning effort passed via --effort (low|medium|high) - promptTemplate (string, optional): run prompt template - bootstrapPromptTemplate (string, optional): first-run prompt template - maxTurnsPerRun (number, optional): max turns for one run diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 12310e20..3105a2cf 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -57,6 +57,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? context.wakeReason.trim() : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; const approvalId = typeof context.approvalId === "string" && context.approvalId.trim().length > 0 ? context.approvalId.trim() @@ -92,6 +97,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); @@ -148,6 +156,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push("--max-turns", String(maxTurns)); args.push("--add-dir", skillsDir); if (extraArgs.length > 0) args.push(...extraArgs); diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index 96604e59..0dcb0533 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -56,6 +56,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const msg = + (typeof rec.message === "string" && rec.message) || + (typeof rec.error === "string" && rec.error) || + (typeof rec.code === "string" && rec.code) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function printItemStarted(item: Record): boolean { + const itemType = asString(item.type); + if (itemType === "command_execution") { + const command = asString(item.command); + console.log(pc.yellow("tool_call: command_execution")); + if (command) console.log(pc.gray(command)); + return true; + } + + if (itemType === "tool_use") { + const name = asString(item.name, "unknown"); + console.log(pc.yellow(`tool_call: ${name}`)); + if (item.input !== undefined) { + try { + console.log(pc.gray(JSON.stringify(item.input, null, 2))); + } catch { + console.log(pc.gray(String(item.input))); + } + } + return true; + } + + return false; +} + +function printItemCompleted(item: Record): boolean { + const itemType = asString(item.type); + + if (itemType === "agent_message") { + const text = asString(item.text); + if (text) console.log(pc.green(`assistant: ${text}`)); + return true; + } + + if (itemType === "reasoning") { + const text = asString(item.text); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return true; + } + + if (itemType === "tool_use") { + const name = asString(item.name, "unknown"); + console.log(pc.yellow(`tool_call: ${name}`)); + if (item.input !== undefined) { + try { + console.log(pc.gray(JSON.stringify(item.input, null, 2))); + } catch { + console.log(pc.gray(String(item.input))); + } + } + return true; + } + + if (itemType === "command_execution") { + const command = asString(item.command); + const status = asString(item.status); + const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null; + const output = asString(item.aggregated_output).replace(/\s+$/, ""); + const isError = + (exitCode !== null && exitCode !== 0) || + status === "failed" || + status === "errored" || + status === "error" || + status === "cancelled"; + + const summaryParts = [ + "tool_result: command_execution", + command ? `command="${command}"` : "", + status ? `status=${status}` : "", + exitCode !== null ? `exit_code=${exitCode}` : "", + ].filter(Boolean); + console.log((isError ? pc.red : pc.cyan)(summaryParts.join(" "))); + if (output) console.log((isError ? pc.red : pc.gray)(output)); + return true; + } + + if (itemType === "file_change") { + const changes = Array.isArray(item.changes) ? item.changes : []; + const entries = changes + .map((changeRaw) => asRecord(changeRaw)) + .filter((change): change is Record => Boolean(change)) + .map((change) => { + const kind = asString(change.kind, "update"); + const path = asString(change.path, "unknown"); + return `${kind} ${path}`; + }); + const preview = entries.length > 0 ? entries.slice(0, 6).join(", ") : "none"; + const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : ""; + console.log(pc.cyan(`file_change: ${preview}${more}`)); + return true; + } + + if (itemType === "error") { + const message = errorText(item.message ?? item.error ?? item); + if (message) console.log(pc.red(`error: ${message}`)); + return true; + } + + if (itemType === "tool_result") { + const isError = item.is_error === true || asString(item.status) === "error"; + const text = asString(item.content) || asString(item.result) || asString(item.output); + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + if (text) console.log((isError ? pc.red : pc.gray)(text)); + return true; + } + + return false; +} + export function printCodexStreamEvent(raw: string, _debug: boolean): void { const line = raw.trim(); if (!line) return; @@ -12,43 +151,77 @@ export function printCodexStreamEvent(raw: string, _debug: boolean): void { return; } - const type = typeof parsed.type === "string" ? parsed.type : ""; + const type = asString(parsed.type); if (type === "thread.started") { - const threadId = typeof parsed.thread_id === "string" ? parsed.thread_id : ""; - console.log(pc.blue(`Codex thread started${threadId ? ` (session: ${threadId})` : ""}`)); + const threadId = asString(parsed.thread_id); + const model = asString(parsed.model); + const details = [threadId ? `session: ${threadId}` : "", model ? `model: ${model}` : ""].filter(Boolean).join(", "); + console.log(pc.blue(`Codex thread started${details ? ` (${details})` : ""}`)); return; } - if (type === "item.completed") { - const item = - typeof parsed.item === "object" && parsed.item !== null && !Array.isArray(parsed.item) - ? (parsed.item as Record) - : null; + if (type === "turn.started") { + console.log(pc.blue("turn started")); + return; + } + + if (type === "item.started" || type === "item.completed") { + const item = asRecord(parsed.item); if (item) { - const itemType = typeof item.type === "string" ? item.type : ""; - if (itemType === "agent_message") { - const text = typeof item.text === "string" ? item.text : ""; - if (text) console.log(pc.green(`assistant: ${text}`)); - } else if (itemType === "tool_use") { - const name = typeof item.name === "string" ? item.name : "unknown"; - console.log(pc.yellow(`tool_call: ${name}`)); + const handled = + type === "item.started" + ? printItemStarted(item) + : printItemCompleted(item); + if (!handled) { + const itemType = asString(item.type, "unknown"); + const id = asString(item.id); + const status = asString(item.status); + const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" "); + console.log(pc.gray(`${type}: ${itemType}${meta ? ` (${meta})` : ""}`)); } + } else { + console.log(pc.gray(type)); } return; } if (type === "turn.completed") { - const usage = - typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage) - ? (parsed.usage as Record) - : {}; - const input = Number(usage.input_tokens ?? 0); - const output = Number(usage.output_tokens ?? 0); - const cached = Number(usage.cached_input_tokens ?? 0); + const usage = asRecord(parsed.usage); + const input = asNumber(usage?.input_tokens); + const output = asNumber(usage?.output_tokens); + const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens)); + const cost = asNumber(parsed.total_cost_usd); + const isError = parsed.is_error === true; + const subtype = asString(parsed.subtype); + const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : []; + console.log( - pc.blue(`tokens: in=${input} out=${output} cached=${cached}`), + pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`), ); + if (subtype || isError || errors.length > 0) { + console.log( + pc.red(`result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`), + ); + if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`)); + } + return; + } + + if (type === "turn.failed") { + const usage = asRecord(parsed.usage); + const input = asNumber(usage?.input_tokens); + const output = asNumber(usage?.output_tokens); + const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens)); + const message = errorText(parsed.error ?? parsed.message); + console.log(pc.red(`turn failed${message ? `: ${message}` : ""}`)); + console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached}`)); + return; + } + + if (type === "error") { + const message = errorText(parsed.message ?? parsed.error ?? parsed); + if (message) console.log(pc.red(`error: ${message}`)); return; } diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 57c44c09..a01b1346 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -2,9 +2,14 @@ export const type = "codex_local"; export const label = "Codex (local)"; export const models = [ + { id: "gpt-5.3-codex", label: "gpt-5.3-codex" }, + { id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" }, { id: "gpt-5", label: "gpt-5" }, - { id: "o4-mini", label: "o4-mini" }, { id: "o3", label: "o3" }, + { id: "o4-mini", label: "o4-mini" }, + { id: "gpt-5-mini", label: "gpt-5-mini" }, + { id: "gpt-5-nano", label: "gpt-5-nano" }, + { id: "o3-mini", label: "o3-mini" }, { id: "codex-mini-latest", label: "Codex Mini" }, ]; @@ -15,6 +20,7 @@ Adapter: codex_local Core fields: - cwd (string, required): absolute working directory for the agent process - model (string, optional): Codex model id +- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=... - promptTemplate (string, optional): run prompt template - bootstrapPromptTemplate (string, optional): first-run prompt template - search (boolean, optional): run codex with --search @@ -30,4 +36,5 @@ Operational fields: Notes: - Prompts are piped via stdin (Codex receives "-" prompt argument). - Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills. +- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). `; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index dcf7d0b3..4fbf9135 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -98,6 +98,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? context.wakeReason.trim() : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; const approvalId = typeof context.approvalId === "string" && context.approvalId.trim().length > 0 ? context.approvalId.trim() @@ -134,6 +142,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); @@ -189,6 +200,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); if (resumeSessionId) args.push("resume", resumeSessionId, "-"); else args.push("-"); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index b2690eab..645a2f86 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -56,6 +56,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record | null { return value as Record; } +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const msg = + (typeof rec.message === "string" && rec.message) || + (typeof rec.error === "string" && rec.error) || + (typeof rec.code === "string" && rec.code) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function parseCommandExecutionItem( + item: Record, + ts: string, + phase: "started" | "completed", +): TranscriptEntry[] { + const id = asString(item.id); + const command = asString(item.command); + const status = asString(item.status); + const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null; + const output = asString(item.aggregated_output).replace(/\s+$/, ""); + + if (phase === "started") { + return [{ + kind: "tool_call", + ts, + name: "command_execution", + input: { + id, + command, + }, + }]; + } + + const lines: string[] = []; + if (command) lines.push(`command: ${command}`); + if (status) lines.push(`status: ${status}`); + if (exitCode !== null) lines.push(`exit_code: ${exitCode}`); + if (output) { + if (lines.length > 0) lines.push(""); + lines.push(output); + } + + const isError = + (exitCode !== null && exitCode !== 0) || + status === "failed" || + status === "errored" || + status === "error" || + status === "cancelled"; + + return [{ + kind: "tool_result", + ts, + toolUseId: id || command || "command_execution", + content: lines.join("\n").trim() || "command completed", + isError, + }]; +} + +function parseFileChangeItem(item: Record, ts: string): TranscriptEntry[] { + const changes = Array.isArray(item.changes) ? item.changes : []; + const entries = changes + .map((changeRaw) => asRecord(changeRaw)) + .filter((change): change is Record => Boolean(change)) + .map((change) => { + const kind = asString(change.kind, "update"); + const path = asString(change.path, "unknown"); + return `${kind} ${path}`; + }); + + if (entries.length === 0) { + return [{ kind: "system", ts, text: "file changes applied" }]; + } + + const preview = entries.slice(0, 6).join(", "); + const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : ""; + return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }]; +} + +function parseCodexItem( + item: Record, + ts: string, + phase: "started" | "completed", +): TranscriptEntry[] { + const itemType = asString(item.type); + + if (itemType === "agent_message") { + const text = asString(item.text); + if (text) return [{ kind: "assistant", ts, text }]; + return []; + } + + if (itemType === "reasoning") { + const text = asString(item.text); + if (text) return [{ kind: "thinking", ts, text }]; + return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }]; + } + + if (itemType === "command_execution") { + return parseCommandExecutionItem(item, ts, phase); + } + + if (itemType === "file_change" && phase === "completed") { + return parseFileChangeItem(item, ts); + } + + if (itemType === "tool_use") { + return [{ + kind: "tool_call", + ts, + name: asString(item.name, "unknown"), + input: item.input ?? {}, + }]; + } + + if (itemType === "tool_result" && phase === "completed") { + const toolUseId = asString(item.tool_use_id, asString(item.id)); + const content = + asString(item.content) || + asString(item.output) || + asString(item.result) || + stringifyUnknown(item.content ?? item.output ?? item.result); + const isError = item.is_error === true || asString(item.status) === "error"; + return [{ kind: "tool_result", ts, toolUseId, content, isError }]; + } + + if (itemType === "error" && phase === "completed") { + const text = errorText(item.message ?? item.error ?? item); + return [{ kind: "stderr", ts, text: text || "error" }]; + } + + const id = asString(item.id); + const status = asString(item.status); + const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" "); + return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }]; +} + export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] { const parsed = asRecord(safeJsonParse(line)); if (!parsed) { return [{ kind: "stdout", ts, text: line }]; } - const type = typeof parsed.type === "string" ? parsed.type : ""; + const type = asString(parsed.type); if (type === "thread.started") { - const threadId = typeof parsed.thread_id === "string" ? parsed.thread_id : ""; + const threadId = asString(parsed.thread_id); return [{ kind: "init", ts, - model: "codex", + model: asString(parsed.model, "codex"), sessionId: threadId, }]; } - if (type === "item.completed") { + if (type === "turn.started") { + return [{ kind: "system", ts, text: "turn started" }]; + } + + if (type === "item.started" || type === "item.completed") { const item = asRecord(parsed.item); - if (item) { - const itemType = typeof item.type === "string" ? item.type : ""; - if (itemType === "agent_message") { - const text = typeof item.text === "string" ? item.text : ""; - if (text) return [{ kind: "assistant", ts, text }]; - } - if (itemType === "tool_use") { - return [{ - kind: "tool_call", - ts, - name: typeof item.name === "string" ? item.name : "unknown", - input: item.input ?? {}, - }]; - } - } + if (!item) return [{ kind: "system", ts, text: type.replace(".", " ") }]; + return parseCodexItem(item, ts, type === "item.started" ? "started" : "completed"); } if (type === "turn.completed") { - const usage = asRecord(parsed.usage) ?? {}; - const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; - const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; - const cachedTokens = typeof usage.cached_input_tokens === "number" ? usage.cached_input_tokens : 0; + const usage = asRecord(parsed.usage); + const inputTokens = asNumber(usage?.input_tokens); + const outputTokens = asNumber(usage?.output_tokens); + const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens)); return [{ kind: "result", ts, - text: "", + text: asString(parsed.result), inputTokens, outputTokens, cachedTokens, - costUsd: 0, - subtype: "", - isError: false, - errors: [], + costUsd: asNumber(parsed.total_cost_usd), + subtype: asString(parsed.subtype), + isError: parsed.is_error === true, + errors: Array.isArray(parsed.errors) + ? parsed.errors.map(errorText).filter(Boolean) + : [], }]; } + if (type === "turn.failed") { + const usage = asRecord(parsed.usage); + const inputTokens = asNumber(usage?.input_tokens); + const outputTokens = asNumber(usage?.output_tokens); + const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens)); + const message = errorText(parsed.error ?? parsed.message); + return [{ + kind: "result", + ts, + text: asString(parsed.result), + inputTokens, + outputTokens, + cachedTokens, + costUsd: asNumber(parsed.total_cost_usd), + subtype: asString(parsed.subtype, "turn.failed"), + isError: true, + errors: message ? [message] : [], + }]; + } + + if (type === "error") { + const message = errorText(parsed.message ?? parsed.error ?? parsed); + return [{ kind: "stderr", ts, text: message || line }]; + } + return [{ kind: "stdout", ts, text: line }]; } diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts new file mode 100644 index 00000000..a4fa6217 --- /dev/null +++ b/server/src/__tests__/adapter-models.test.ts @@ -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); + }); +}); diff --git a/server/src/__tests__/codex-local-adapter.test.ts b/server/src/__tests__/codex-local-adapter.test.ts index 24644d9d..aad5f652 100644 --- a/server/src/__tests__/codex-local-adapter.test.ts +++ b/server/src/__tests__/codex-local-adapter.test.ts @@ -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(); + } + }); +}); diff --git a/server/src/adapters/codex-models.ts b/server/src/adapters/codex-models.ts new file mode 100644 index 00000000..abef28c4 --- /dev/null +++ b/server/src/adapters/codex-models.ts @@ -0,0 +1,104 @@ +import type { AdapterModel } from "./types.js"; +import { models as codexFallbackModels } from "@paperclip/adapter-codex-local"; +import { readConfigFile } from "../config-file.js"; + +const OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models"; +const OPENAI_MODELS_TIMEOUT_MS = 5000; +const OPENAI_MODELS_CACHE_TTL_MS = 60_000; + +let cached: { keyFingerprint: string; expiresAt: number; models: AdapterModel[] } | null = null; + +function fingerprint(apiKey: string): string { + return `${apiKey.length}:${apiKey.slice(-6)}`; +} + +function dedupeModels(models: AdapterModel[]): AdapterModel[] { + const seen = new Set(); + const deduped: AdapterModel[] = []; + for (const model of models) { + const id = model.id.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push({ id, label: model.label.trim() || id }); + } + return deduped; +} + +function mergedWithFallback(models: AdapterModel[]): AdapterModel[] { + return dedupeModels([ + ...models, + ...codexFallbackModels, + ]).sort((a, b) => a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" })); +} + +function resolveOpenAiApiKey(): string | null { + const envKey = process.env.OPENAI_API_KEY?.trim(); + if (envKey) return envKey; + + const config = readConfigFile(); + if (config?.llm?.provider !== "openai") return null; + const configKey = config.llm.apiKey?.trim(); + return configKey && configKey.length > 0 ? configKey : null; +} + +async function fetchOpenAiModels(apiKey: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OPENAI_MODELS_TIMEOUT_MS); + try { + const response = await fetch(OPENAI_MODELS_ENDPOINT, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + signal: controller.signal, + }); + if (!response.ok) return []; + + const payload = (await response.json()) as { data?: unknown }; + const data = Array.isArray(payload.data) ? payload.data : []; + const models: AdapterModel[] = []; + for (const item of data) { + if (typeof item !== "object" || item === null) continue; + const id = (item as { id?: unknown }).id; + if (typeof id !== "string" || id.trim().length === 0) continue; + models.push({ id, label: id }); + } + return dedupeModels(models); + } catch { + return []; + } finally { + clearTimeout(timeout); + } +} + +export async function listCodexModels(): Promise { + const apiKey = resolveOpenAiApiKey(); + const fallback = dedupeModels(codexFallbackModels); + if (!apiKey) return fallback; + + const now = Date.now(); + const keyFingerprint = fingerprint(apiKey); + if (cached && cached.keyFingerprint === keyFingerprint && cached.expiresAt > now) { + return cached.models; + } + + const fetched = await fetchOpenAiModels(apiKey); + if (fetched.length > 0) { + const merged = mergedWithFallback(fetched); + cached = { + keyFingerprint, + expiresAt: now + OPENAI_MODELS_CACHE_TTL_MS, + models: merged, + }; + return merged; + } + + if (cached && cached.keyFingerprint === keyFingerprint && cached.models.length > 0) { + return cached.models; + } + + return fallback; +} + +export function resetCodexModelsCacheForTests() { + cached = null; +} diff --git a/server/src/adapters/http/execute.ts b/server/src/adapters/http/execute.ts index e4044570..eff14023 100644 --- a/server/src/adapters/http/execute.ts +++ b/server/src/adapters/http/execute.ts @@ -7,13 +7,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise; const payloadTemplate = parseObject(config.payloadTemplate); const body = { ...payloadTemplate, agentId: agent.id, runId, context }; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null; try { const res = await fetch(url, { @@ -23,7 +23,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const adapter = adaptersByType.get(type); + if (!adapter) return []; + if (adapter.listModels) { + const discovered = await adapter.listModels(); + if (discovered.length > 0) return discovered; + } + return adapter.models ?? []; } export function listServerAdapters(): ServerAdapterModule[] { diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 76bd13bd..629afc87 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -9,5 +9,6 @@ export type { AdapterInvocationMeta, AdapterExecutionContext, AdapterSessionCodec, + AdapterModel, ServerAdapterModule, } from "@paperclip/adapter-utils"; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 2f083290..d14e08a9 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -189,9 +189,9 @@ export function agentRoutes(db: Db) { }; } - router.get("/adapters/:type/models", (req, res) => { + router.get("/adapters/:type/models", async (req, res) => { const type = req.params.type as string; - const models = listAdapterModels(type); + const models = await listAdapterModels(type); res.json(models); }); @@ -890,6 +890,36 @@ export function agentRoutes(db: Db) { res.json(runs); }); + router.get("/companies/:companyId/live-runs", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const liveRuns = await db + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + agentId: heartbeatRuns.agentId, + agentName: agentsTable.name, + adapterType: agentsTable.adapterType, + }) + .from(heartbeatRuns) + .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.status, ["queued", "running"]), + ), + ) + .orderBy(desc(heartbeatRuns.createdAt)); + + res.json(liveRuns); + }); + router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string;