From 2c3c2cf724d8123eba3aa0aac9f9c8263fc8d289 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 10:32:07 -0600 Subject: [PATCH] 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 --- packages/adapter-utils/src/index.ts | 1 + packages/adapter-utils/src/server-utils.ts | 25 +- packages/adapter-utils/src/types.ts | 9 +- packages/adapters/claude-local/src/index.ts | 1 + .../claude-local/src/server/execute.ts | 11 +- .../claude-local/src/ui/build-config.ts | 1 + .../codex-local/src/cli/format-event.ts | 219 ++++++++++++++-- packages/adapters/codex-local/src/index.ts | 9 +- .../codex-local/src/server/execute.ts | 14 +- .../codex-local/src/ui/build-config.ts | 1 + .../codex-local/src/ui/parse-stdout.ts | 235 +++++++++++++++--- server/src/__tests__/adapter-models.test.ts | 58 +++++ .../src/__tests__/codex-local-adapter.test.ts | 221 +++++++++++++++- server/src/adapters/codex-models.ts | 104 ++++++++ server/src/adapters/http/execute.ts | 8 +- server/src/adapters/process/execute.ts | 2 +- server/src/adapters/registry.ts | 12 +- server/src/adapters/types.ts | 1 + server/src/routes/agents.ts | 34 ++- 19 files changed, 890 insertions(+), 76 deletions(-) create mode 100644 server/src/__tests__/adapter-models.test.ts create mode 100644 server/src/adapters/codex-models.ts 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;