diff --git a/src/__tests__/backend-registry-parity.test.ts b/src/__tests__/backend-registry-parity.test.ts new file mode 100644 index 00000000..0c570c29 --- /dev/null +++ b/src/__tests__/backend-registry-parity.test.ts @@ -0,0 +1,88 @@ +/** + * Backend registry parity tests. + * + * Verifies that ALL four backends (Claude SDK, Kilo, OpenCode, Codex) + * register themselves into the registry with the same QueryBackend + * surface — so the dispatcher can swap backends without leaking + * backend-specific behaviour upstream. + * + * Each backend factory's `init(config, ctx)` returns a `QueryBackend` + * whose required + optional methods Talon's core relies on. This file + * doesn't actually CALL `init` (it would spawn real subprocesses); + * instead it verifies registry presence + factory shape. + */ + +import { describe, it, expect, beforeAll } from "vitest"; + +import { + clearBackends, + getBackend, + listBackends, + hasBackend, +} from "../backend/registry.js"; + +const ALL_BACKENDS = ["claude", "kilo", "opencode", "codex"] as const; + +beforeAll(async () => { + // Reset registry for a clean import. Each factory module's + // side-effect import re-registers it. + clearBackends(); + await import("../backend/claude-sdk/factory.js"); + await import("../backend/kilo/factory.js"); + await import("../backend/opencode/factory.js"); + await import("../backend/codex/factory.js"); +}); + +describe("backend registry parity — all four backends present", () => { + it("registers Claude, Kilo, OpenCode, and Codex", () => { + for (const id of ALL_BACKENDS) { + expect(hasBackend(id), `expected backend "${id}" registered`).toBe(true); + } + }); + + it("listBackends returns them sorted by id", () => { + const ids = listBackends().map((b) => b.id); + expect(ids).toContain("claude"); + expect(ids).toContain("codex"); + expect(ids).toContain("kilo"); + expect(ids).toContain("opencode"); + // Sorted property: ids should equal their sorted-copy + const sorted = [...ids].sort(); + expect(ids).toEqual(sorted); + }); + + it("every backend has a non-empty label", () => { + for (const id of ALL_BACKENDS) { + const factory = getBackend(id); + expect(factory, `factory for ${id}`).toBeDefined(); + expect(factory!.label.length).toBeGreaterThan(0); + } + }); + + it("every backend factory has an init function", () => { + for (const id of ALL_BACKENDS) { + const factory = getBackend(id); + expect(typeof factory!.init).toBe("function"); + } + }); + + it("expected labels", () => { + expect(getBackend("claude")?.label).toBe("Anthropic"); + expect(getBackend("kilo")?.label).toBe("Kilo"); + expect(getBackend("opencode")?.label).toBe("OpenCode"); + expect(getBackend("codex")?.label).toBe("Codex"); + }); +}); + +describe("backend registry parity — duplicate registration is rejected", () => { + it("re-registering an existing id throws", async () => { + const { registerBackend } = await import("../backend/registry.js"); + expect(() => + registerBackend({ + id: "claude", + label: "Duplicate", + init: async () => ({ backend: {} as never }), + }), + ).toThrow(/already registered/); + }); +}); diff --git a/src/__tests__/codex-models.test.ts b/src/__tests__/codex-models.test.ts new file mode 100644 index 00000000..9b6e2b4a --- /dev/null +++ b/src/__tests__/codex-models.test.ts @@ -0,0 +1,158 @@ +/** + * Codex model catalog tests. + */ + +import { describe, it, expect } from "vitest"; + +import { + CODEX_MODELS, + resolveModel, + getModelInfo, + getSettingsPresentation, + getProviders, + getProviderModels, + formatModelError, + listModels, +} from "../backend/codex/models.js"; + +describe("codex / model catalog", () => { + it("exposes at least the gpt-5-codex flagship", () => { + expect(CODEX_MODELS.some((m) => m.id === "gpt-5-codex")).toBe(true); + }); + + it("every model carries the openai provider", () => { + for (const m of CODEX_MODELS) { + expect(m.provider).toBe("openai"); + expect(m.providerName).toBe("OpenAI"); + expect(m.selectable).toBe(true); + } + }); +}); + +describe("codex / resolveModel", () => { + it("returns exact match for a known model id", () => { + const result = resolveModel("gpt-5-codex"); + expect(result.kind).toBe("exact"); + if (result.kind === "exact") { + expect(result.model.id).toBe("gpt-5-codex"); + expect(result.storedValue).toBe("gpt-5-codex"); + } + }); + + it("returns missing for an empty query", () => { + expect(resolveModel("").kind).toBe("missing"); + expect(resolveModel(" ").kind).toBe("missing"); + }); + + it("returns missing for an unrecognised query", () => { + expect(resolveModel("nonsense-model-1.0").kind).toBe("missing"); + }); + + it("returns ambiguous for a prefix that matches multiple", () => { + // `gpt-5` matches gpt-5, gpt-5-codex, gpt-5-mini → ambiguous + const result = resolveModel("gpt-5"); + // Exact match on "gpt-5" wins via the first-pass exact filter + expect(result.kind).toBe("exact"); + }); + + it("returns ambiguous when only prefix matches multiple", () => { + // `gpt` (no exact match) matches all gpt-5* models → ambiguous + const result = resolveModel("gpt"); + expect(result.kind).toBe("ambiguous"); + if (result.kind === "ambiguous") { + expect(result.matches.length).toBeGreaterThan(1); + } + }); +}); + +describe("codex / getModelInfo", () => { + it("returns the model for a known id", () => { + expect(getModelInfo("gpt-5-codex")?.id).toBe("gpt-5-codex"); + }); + + it("returns undefined for unknown ids", () => { + expect(getModelInfo("not-real")).toBeUndefined(); + }); +}); + +describe("codex / getSettingsPresentation", () => { + it("returns one button per model with active marker on the current one", () => { + const { modelButtons, modelDetails } = getSettingsPresentation("gpt-5"); + expect(modelButtons).toHaveLength(CODEX_MODELS.length); + expect(modelDetails).toHaveLength(CODEX_MODELS.length); + + const active = modelButtons.find((b) => b.callback_data.endsWith("gpt-5")); + const others = modelButtons.filter( + (b) => !b.callback_data.endsWith("gpt-5"), + ); + expect(active?.text).toMatch(/^●/); + for (const b of others) { + expect(b.text).not.toMatch(/^●/); + } + }); + + it("uses the supplied callbackPrefix", () => { + const { modelButtons } = getSettingsPresentation("gpt-5", "custom:prefix:"); + for (const b of modelButtons) { + expect(b.callback_data.startsWith("custom:prefix:")).toBe(true); + } + }); +}); + +describe("codex / getProviders + getProviderModels", () => { + it("returns OpenAI as the sole provider", () => { + const providers = getProviders(); + expect(providers).toHaveLength(1); + expect(providers[0].id).toBe("openai"); + expect(providers[0].modelCount).toBe(CODEX_MODELS.length); + }); + + it("returns paginated models for openai provider", () => { + const result = getProviderModels("openai", 1, 2); + expect(result.models).toHaveLength(2); + expect(result.total).toBe(CODEX_MODELS.length); + }); + + it("returns empty for unknown provider", () => { + expect(getProviderModels("anthropic", 1, 50)).toEqual({ + models: [], + total: 0, + }); + }); +}); + +describe("codex / formatModelError", () => { + it("describes ambiguous matches with backtick-quoted ids", () => { + const msg = formatModelError("gpt", { + kind: "ambiguous", + matches: CODEX_MODELS.filter((m) => m.id.startsWith("gpt-5")), + }); + expect(msg).toContain("Multiple Codex models match"); + expect(msg).toContain("`gpt-5-codex`"); + }); + + it("describes a missing query with the full catalog", () => { + const msg = formatModelError("xyz", { kind: "missing" }); + expect(msg).toContain("No Codex model matches"); + expect(msg).toContain("gpt-5-codex"); + }); +}); + +describe("codex / listModels", () => { + it("returns all by default", () => { + const { models, total } = listModels(); + expect(total).toBe(CODEX_MODELS.length); + expect(models).toEqual(CODEX_MODELS); + }); + + it("returns nothing for `free` filter — no free Codex models", () => { + const { models, total } = listModels("free"); + expect(models).toEqual([]); + expect(total).toBe(0); + }); + + it("returns all for the `all` filter", () => { + const { total } = listModels("all"); + expect(total).toBe(CODEX_MODELS.length); + }); +}); diff --git a/src/__tests__/codex-one-shot.test.ts b/src/__tests__/codex-one-shot.test.ts new file mode 100644 index 00000000..51e1b033 --- /dev/null +++ b/src/__tests__/codex-one-shot.test.ts @@ -0,0 +1,158 @@ +/** + * Codex one-shot agent runner tests. + * + * Exercises the event-translation layer that turns Codex's + * `runStreamed` `ThreadEvent` stream into run-log lines used by + * heartbeat + dream. The full path (spawning `codex` CLI) isn't + * exercisable without the binary on PATH, but the per-event/per-item + * formatting logic is pure and we can hand-build representative + * events. + * + * We don't import `runOneShotAgent` directly because it depends on + * `ensureCodex` which spawns the CLI. Instead this file imports the + * source for the test surface area via a thin helper export. + */ + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../core/plugin.js", () => ({ + getPluginMcpServers: vi.fn(() => ({})), +})); + +vi.mock("@openai/codex-sdk", () => { + // Light mock — `runOneShotAgent` only needs `startThread` and a + // working `runStreamed` AsyncGenerator. + class MockThread { + async runStreamed(_input: string, options?: { signal?: AbortSignal }) { + const events = (async function* () { + yield { type: "thread.started", thread_id: "thr_test" }; + yield { type: "turn.started" }; + yield { + type: "item.completed", + item: { + id: "i1", + type: "agent_message", + text: "Hello from Codex.", + }, + }; + yield { + type: "item.completed", + item: { + id: "i2", + type: "mcp_tool_call", + server: "telegram-tools", + tool: "send", + arguments: { type: "text", text: "ok" }, + status: "completed", + }, + }; + yield { + type: "turn.completed", + usage: { + input_tokens: 100, + output_tokens: 50, + cached_input_tokens: 10, + reasoning_output_tokens: 5, + }, + }; + })(); + void options; + return { events }; + } + } + return { + Codex: class { + // The init module accesses `__talonChatId` as a stash slot. + // Allow arbitrary property writes via `as any` in init.ts. + startThread() { + return new MockThread(); + } + resumeThread() { + return new MockThread(); + } + }, + }; +}); + +// Now import after the mocks are wired +const { runOneShotAgent } = await import("../backend/codex/one-shot.js"); +const { initCodexAgent } = await import("../backend/codex/init.js"); + +describe("codex / runOneShotAgent — event → log translation", () => { + it("appends thread-started, turn lifecycle, agent message, and tool call", async () => { + initCodexAgent( + { + model: "gpt-5-codex", + workspace: "/tmp", + systemPrompt: "test", + frontend: "terminal", + openaiApiKey: "test-key", + } as never, + () => 19876, + "terminal", + ); + + const lines: string[] = []; + const appendLog = async (text: string) => { + lines.push(text); + }; + const abortController = new AbortController(); + + await runOneShotAgent({ + prompt: "Hello", + systemPrompt: "You are an assistant.", + workspace: "/tmp", + model: "gpt-5-codex", + contextLabel: "heartbeat", + abortController, + appendLog, + }); + + const log = lines.join(""); + expect(log).toContain("Thread started"); + expect(log).toContain("thr_test"); + expect(log).toContain("Turn started"); + expect(log).toContain("Turn completed"); + expect(log).toContain("input=100"); + expect(log).toContain("output=50"); + expect(log).toContain("Assistant"); + expect(log).toContain("Hello from Codex."); + expect(log).toContain("MCP tool call"); + expect(log).toContain("telegram-tools.send"); + }); + + it("stops appending once the abort signal fires", async () => { + initCodexAgent( + { + model: "gpt-5-codex", + workspace: "/tmp", + systemPrompt: "test", + frontend: "terminal", + openaiApiKey: "test-key", + } as never, + () => 19876, + "terminal", + ); + + const lines: string[] = []; + const appendLog = async (text: string) => { + lines.push(text); + }; + const abortController = new AbortController(); + // Fire abort immediately + abortController.abort(); + + await runOneShotAgent({ + prompt: "Hello", + systemPrompt: "You are an assistant.", + workspace: "/tmp", + model: "gpt-5-codex", + contextLabel: "heartbeat", + abortController, + appendLog, + }); + + const log = lines.join(""); + expect(log).toContain("Aborted"); + }); +}); diff --git a/src/backend/codex/factory.ts b/src/backend/codex/factory.ts index 500d277e..d75b4edb 100644 --- a/src/backend/codex/factory.ts +++ b/src/backend/codex/factory.ts @@ -14,6 +14,16 @@ import { log } from "../../util/log.js"; import { initCodexAgent } from "./init.js"; import { handleMessage as codexHandleMessage } from "./handler.js"; +import { runOneShotAgent as codexRunOneShotAgent } from "./one-shot.js"; +import { + resolveModel, + getModelInfo, + getSettingsPresentation, + getProviders, + getProviderModels, + formatModelError, + listModels, +} from "./models.js"; const codexFactory: BackendFactory = { id: "codex", @@ -25,6 +35,16 @@ const codexFactory: BackendFactory = { const backend: QueryBackend = { query: (params) => codexHandleMessage(params), + resolveModel: (q) => Promise.resolve(resolveModel(q)), + getModelInfo: (id) => Promise.resolve(getModelInfo(id)), + getSettingsPresentation: (m, prefix) => + Promise.resolve(getSettingsPresentation(m, prefix)), + getProviders: () => Promise.resolve(getProviders()), + getProviderModels: (p, pg, ps) => + Promise.resolve(getProviderModels(p, pg, ps)), + formatModelError: (q, r) => formatModelError(q, r), + listModels: (f) => Promise.resolve(listModels(f)), + runOneShotAgent: (p) => codexRunOneShotAgent(p), backendLabel: "Codex", }; diff --git a/src/backend/codex/index.ts b/src/backend/codex/index.ts index 6c53940d..ca82d9f6 100644 --- a/src/backend/codex/index.ts +++ b/src/backend/codex/index.ts @@ -28,4 +28,6 @@ export { initCodexAgent, ensureCodex } from "./init.js"; export { handleMessage, getActiveAbort } from "./handler.js"; +export { runOneShotAgent } from "./one-shot.js"; + export { buildCodexMcpServers, type CodexMcpServer } from "./mcp-config.js"; diff --git a/src/backend/codex/models.ts b/src/backend/codex/models.ts new file mode 100644 index 00000000..fe80b661 --- /dev/null +++ b/src/backend/codex/models.ts @@ -0,0 +1,169 @@ +/** + * Codex model catalog. + * + * Unlike Kilo / OpenCode (which fetch a live provider catalog from a + * running server) and Claude SDK (which queries the SDK's model + * registry), Codex ships with a fixed-ish set of models hardcoded in + * the CLI. The set we expose here mirrors what `codex --help` lists + * and what OpenAI's docs document as Codex-supported. + * + * Reasoning-effort suffixes (`gpt-5-codex-high`, etc.) are pushed + * through Codex's `modelReasoningEffort` thread option rather than + * baked into the model id, so we keep this list short. + */ + +import type { + UnifiedModelInfo, + UnifiedModelResolution, + UnifiedProviderInfo, + ModelButton, +} from "../../core/types.js"; + +/** Models available through the Codex CLI. */ +export const CODEX_MODELS: UnifiedModelInfo[] = [ + { + id: "gpt-5-codex", + displayName: "GPT-5 Codex", + provider: "openai", + providerName: "OpenAI", + selectable: true, + reasoning: true, + contextWindow: 200_000, + }, + { + id: "gpt-5", + displayName: "GPT-5", + provider: "openai", + providerName: "OpenAI", + selectable: true, + reasoning: true, + contextWindow: 200_000, + }, + { + id: "gpt-5-mini", + displayName: "GPT-5 Mini", + provider: "openai", + providerName: "OpenAI", + selectable: true, + reasoning: true, + contextWindow: 200_000, + }, + { + id: "o4-mini", + displayName: "o4-mini", + provider: "openai", + providerName: "OpenAI", + selectable: true, + reasoning: true, + contextWindow: 128_000, + }, +]; + +/** + * Resolve a user query string against the Codex model catalog. + * + * Matches by exact id first, then case-insensitive prefix on id or + * display name. Returns ambiguous when multiple models match. + */ +export function resolveModel(query: string): UnifiedModelResolution { + const q = query.trim(); + if (!q) return { kind: "missing" }; + + // Exact-id match + const exact = CODEX_MODELS.find((m) => m.id === q); + if (exact) return { kind: "exact", model: exact, storedValue: exact.id }; + + // Case-insensitive prefix match on id or displayName + const qLower = q.toLowerCase(); + const matches = CODEX_MODELS.filter( + (m) => + m.id.toLowerCase().startsWith(qLower) || + m.displayName.toLowerCase().startsWith(qLower), + ); + + if (matches.length === 0) return { kind: "missing" }; + if (matches.length === 1) { + return { kind: "exact", model: matches[0], storedValue: matches[0].id }; + } + return { kind: "ambiguous", matches }; +} + +/** Look up a model by stored id. */ +export function getModelInfo(id: string): UnifiedModelInfo | undefined { + return CODEX_MODELS.find((m) => m.id === id); +} + +/** Quick-pick buttons for the `/settings` model picker. */ +export function getSettingsPresentation( + activeModel: string, + callbackPrefix = "settings:model:", +): { modelButtons: ModelButton[]; modelDetails: string[] } { + const modelButtons: ModelButton[] = CODEX_MODELS.map((m) => ({ + text: `${m.id === activeModel ? "● " : ""}${m.displayName}`, + callback_data: `${callbackPrefix}${m.id}`, + })); + + const modelDetails = CODEX_MODELS.map((m) => { + const flags: string[] = []; + if (m.reasoning) flags.push("reasoning"); + if (m.contextWindow) flags.push(`${m.contextWindow / 1000}k ctx`); + return `**${m.displayName}** (${m.id}) — ${flags.join(" · ")}`; + }); + + return { modelButtons, modelDetails }; +} + +/** List Codex's providers (one — OpenAI). */ +export function getProviders(): UnifiedProviderInfo[] { + return [ + { + id: "openai", + name: "OpenAI", + connected: true, + modelCount: CODEX_MODELS.length, + }, + ]; +} + +/** List models for a provider (paginated). */ +export function getProviderModels( + providerId: string, + page = 1, + pageSize = 50, +): { models: UnifiedModelInfo[]; total: number } { + if (providerId !== "openai") return { models: [], total: 0 }; + const start = (page - 1) * pageSize; + return { + models: CODEX_MODELS.slice(start, start + pageSize), + total: CODEX_MODELS.length, + }; +} + +/** Format a human-readable error for an unresolvable model query. */ +export function formatModelError( + query: string, + resolution: UnifiedModelResolution, +): string { + if (resolution.kind === "ambiguous") { + const list = resolution.matches.map((m) => `\`${m.id}\``).join(", "); + return `Multiple Codex models match \`${query}\`: ${list}. Pick one.`; + } + return ( + `No Codex model matches \`${query}\`. ` + + `Available: ${CODEX_MODELS.map((m) => m.id).join(", ")}.` + ); +} + +/** Filter the catalog by a coarse-grained tag. */ +export function listModels(filter?: "free" | "all"): { + models: UnifiedModelInfo[]; + total: number; +} { + // None of Codex's official models are free; the `free` filter + // returns an empty list so the `/model free` slash-command produces + // an honest "(no free models)" message. + if (filter === "free") { + return { models: [], total: 0 }; + } + return { models: CODEX_MODELS, total: CODEX_MODELS.length }; +} diff --git a/src/backend/codex/one-shot.ts b/src/backend/codex/one-shot.ts new file mode 100644 index 00000000..06e22ec8 --- /dev/null +++ b/src/backend/codex/one-shot.ts @@ -0,0 +1,236 @@ +/** + * Codex one-shot agent runner — used by heartbeat & dream. + * + * The heartbeat/dream modules own timing, locking, and the run log + * file. This module owns everything Codex-specific: + * + * - Ensuring the per-context Codex instance is built (with the + * contextLabel's MCP servers wired in). + * - Starting an ephemeral thread (heartbeat / dream don't resume — + * each run is fresh). + * - Streaming `runStreamed` events into the run log. + * - Honouring the heartbeat module's abort controller so timeouts + * stop the model promptly. + * + * Codex spawns the `codex` CLI as a subprocess per `runStreamed` call. + * The SDK's AbortSignal cuts the subprocess cleanly when the abort + * fires — no orphan process handling needed. + */ + +import type { OneShotAgentParams } from "../../core/types.js"; +import { logWarn } from "../../util/log.js"; +import { appendBackendSuffix } from "../shared/index.js"; +import { ensureCodex } from "./init.js"; +import { CODEX_SYSTEM_PROMPT_SUFFIX } from "./constants.js"; + +export async function runOneShotAgent( + params: OneShotAgentParams, +): Promise { + const { + prompt, + systemPrompt, + model, + contextLabel, + abortController, + appendLog, + } = params; + + const codex = ensureCodex(contextLabel); + + const finalSystemPrompt = appendBackendSuffix( + systemPrompt, + CODEX_SYSTEM_PROMPT_SUFFIX, + ); + + // Codex SDK doesn't expose `system` on runStreamed — the system + // prompt gets prepended to the user prompt for a one-shot, since + // there's no thread continuity to worry about. + const inputText = `${finalSystemPrompt}\n\n---\n\n${prompt}`; + + const thread = codex.startThread({ + model, + skipGitRepoCheck: true, + }); + + try { + if (abortController.signal.aborted) { + throw new Error("Aborted before prompt was sent"); + } + + const { events } = await thread.runStreamed(inputText, { + signal: abortController.signal, + }); + + for await (const event of events) { + if (abortController.signal.aborted) break; + await appendCodexEvent(appendLog, event); + } + } catch (err) { + if ( + abortController.signal.aborted || + /abort/i.test(err instanceof Error ? err.message : String(err)) + ) { + const ts = new Date().toISOString().slice(11, 19); + await appendLog(`\n### [${ts}] Aborted\nRun aborted by timeout.\n`); + return; + } + const msg = err instanceof Error ? err.message : String(err); + logWarn("agent", `Codex one-shot run failed: ${msg}`); + const ts = new Date().toISOString().slice(11, 19); + await appendLog(`\n### [${ts}] Error\n${msg}\n`); + } +} + +/** + * Append one Codex `ThreadEvent` to the run log. We surface: + * + * - `thread.started` — record the thread id for diagnostic purposes. + * - `turn.started` / `turn.completed` — markers around the model's work. + * - `item.completed` — the meat: agent messages, tool calls, reasoning, + * command execution, file changes, web searches, todo lists, errors. + * - `turn.failed` / `error` — surface upstream failures into the log. + * + * `item.started` / `item.updated` are skipped to keep the log readable — + * the completed snapshot of each item is sufficient. + */ +async function appendCodexEvent( + appendLog: (text: string) => Promise, + event: { type: string } & Record, +): Promise { + const ts = new Date().toISOString().slice(11, 19); + + switch (event.type) { + case "thread.started": { + const id = + typeof event.thread_id === "string" ? event.thread_id : "(unknown)"; + await appendLog(`\n### [${ts}] Thread started\n\`${id}\`\n`); + return; + } + case "turn.started": + await appendLog(`\n### [${ts}] Turn started\n`); + return; + case "turn.completed": { + const usage = (event as { usage?: Record }).usage; + if (usage) { + await appendLog( + `\n### [${ts}] Turn completed\ninput=${usage.input_tokens ?? 0} ` + + `cached=${usage.cached_input_tokens ?? 0} ` + + `output=${usage.output_tokens ?? 0} ` + + `reasoning=${usage.reasoning_output_tokens ?? 0}\n`, + ); + } else { + await appendLog(`\n### [${ts}] Turn completed\n`); + } + return; + } + case "turn.failed": { + const err = (event as { error?: { message?: string } }).error; + await appendLog( + `\n### [${ts}] Turn FAILED\n${err?.message ?? "(no message)"}\n`, + ); + return; + } + case "error": { + const msg = + typeof event.message === "string" ? event.message : "(no message)"; + await appendLog(`\n### [${ts}] ERROR\n${msg}\n`); + return; + } + case "item.completed": { + const item = (event as unknown as { item?: Record }) + .item; + if (item) await appendCodexItem(appendLog, item, ts); + return; + } + default: + return; + } +} + +/** Append one `ThreadItem` to the run log. */ +async function appendCodexItem( + appendLog: (text: string) => Promise, + item: Record, + ts: string, +): Promise { + const type = typeof item.type === "string" ? item.type : "unknown"; + + if (type === "agent_message") { + const text = typeof item.text === "string" ? item.text : ""; + if (text) await appendLog(`\n## [${ts}] Assistant\n${text}\n`); + return; + } + + if (type === "reasoning") { + const text = typeof item.text === "string" ? item.text : ""; + if (text) await appendLog(`\n### [${ts}] Reasoning\n${text}\n`); + return; + } + + if (type === "mcp_tool_call") { + const server = typeof item.server === "string" ? item.server : "(unknown)"; + const tool = typeof item.tool === "string" ? item.tool : "(unknown)"; + const input = item.arguments ?? null; + await appendLog( + `\n**MCP tool call:** \`${server}.${tool}\`\n\`\`\`json\n${JSON.stringify( + input, + null, + 2, + ).slice(0, 2000)}\n\`\`\`\n`, + ); + return; + } + + if (type === "command_execution") { + const cmd = typeof item.command === "string" ? item.command : "(unknown)"; + const status = typeof item.status === "string" ? item.status : "(unknown)"; + const exitCode = item.exit_code; + const exitTail = typeof exitCode === "number" ? ` exit=${exitCode}` : ""; + await appendLog(`\n**Command:** \`${cmd}\` (${status}${exitTail})\n`); + return; + } + + if (type === "file_change") { + const changes = Array.isArray(item.changes) ? item.changes : []; + const status = typeof item.status === "string" ? item.status : "(unknown)"; + const list = changes + .map((c) => { + const change = c as { kind?: string; path?: string }; + return ` - ${change.kind ?? "?"} ${change.path ?? "?"}`; + }) + .join("\n"); + await appendLog(`\n**File changes:** (${status})\n${list}\n`); + return; + } + + if (type === "web_search") { + const query = typeof item.query === "string" ? item.query : "(unknown)"; + await appendLog(`\n**Web search:** \`${query}\`\n`); + return; + } + + if (type === "todo_list") { + const items = Array.isArray(item.items) ? item.items : []; + const list = items + .map((todo) => { + const t = todo as { text?: string; completed?: boolean }; + return ` - [${t.completed ? "x" : " "}] ${t.text ?? "?"}`; + }) + .join("\n"); + await appendLog(`\n**Todo list:**\n${list}\n`); + return; + } + + if (type === "error") { + const msg = + typeof item.message === "string" ? item.message : "(no message)"; + await appendLog(`\n### [${ts}] Error item\n${msg}\n`); + return; + } + + // Fallback: dump unknown item types. + const truncated = JSON.stringify(item, null, 2).slice(0, 2000); + await appendLog( + `\n### [${ts}] Item (${type})\n\`\`\`json\n${truncated}\n\`\`\`\n`, + ); +}