// Message Adapter — parses provider-specific NDJSON events into common AgentMessage format // Standalone for Bun process (no Svelte/Tauri deps). Mirrors the Tauri adapter layer. // ── Type guards ────────────────────────────────────────────────────────────── function str(v: unknown, fallback = ""): string { return typeof v === "string" ? v : fallback; } function num(v: unknown, fallback = 0): number { return typeof v === "number" ? v : fallback; } // ── Types ──────────────────────────────────────────────────────────────────── export type AgentMessageType = | "init" | "text" | "thinking" | "tool_call" | "tool_result" | "status" | "compaction" | "cost" | "error" | "unknown"; export interface AgentMessage { id: string; type: AgentMessageType; parentId?: string; content: unknown; timestamp: number; } export type ProviderId = "claude" | "codex" | "ollama"; // ── Public API ─────────────────────────────────────────────────────────────── /** Parse a raw NDJSON event from a sidecar runner into AgentMessage[] */ export function parseMessage( provider: ProviderId, raw: Record, ): AgentMessage[] { switch (provider) { case "claude": return adaptClaudeMessage(raw); case "codex": return adaptCodexMessage(raw); case "ollama": return adaptOllamaMessage(raw); default: return adaptClaudeMessage(raw); } } // ── Claude adapter ─────────────────────────────────────────────────────────── function adaptClaudeMessage(raw: Record): AgentMessage[] { const uuid = str(raw.uuid) || crypto.randomUUID(); const ts = Date.now(); const parentId = typeof raw.parent_tool_use_id === "string" ? raw.parent_tool_use_id : undefined; switch (raw.type) { case "system": return adaptClaudeSystem(raw, uuid, ts); case "assistant": return adaptClaudeAssistant(raw, uuid, ts, parentId); case "user": return adaptClaudeUser(raw, uuid, ts, parentId); case "result": return adaptClaudeResult(raw, uuid, ts); default: return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }]; } } function adaptClaudeSystem( raw: Record, uuid: string, ts: number, ): AgentMessage[] { const subtype = str(raw.subtype); if (subtype === "init") { return [ { id: uuid, type: "init", content: { sessionId: str(raw.session_id), model: str(raw.model), cwd: str(raw.cwd), tools: Array.isArray(raw.tools) ? raw.tools.filter((t): t is string => typeof t === "string") : [], }, timestamp: ts, }, ]; } if (subtype === "compact_boundary") { const meta = typeof raw.compact_metadata === "object" && raw.compact_metadata !== null ? (raw.compact_metadata as Record) : {}; return [ { id: uuid, type: "compaction", content: { trigger: str(meta.trigger, "auto"), preTokens: num(meta.pre_tokens), }, timestamp: ts, }, ]; } return [ { id: uuid, type: "status", content: { subtype, message: typeof raw.status === "string" ? raw.status : undefined, }, timestamp: ts, }, ]; } function adaptClaudeAssistant( raw: Record, uuid: string, ts: number, parentId?: string, ): AgentMessage[] { const msg = typeof raw.message === "object" && raw.message !== null ? (raw.message as Record) : undefined; if (!msg) return []; const content = Array.isArray(msg.content) ? (msg.content as Array>) : undefined; if (!content) return []; const messages: AgentMessage[] = []; for (const block of content) { switch (block.type) { case "text": messages.push({ id: `${uuid}-text-${messages.length}`, type: "text", parentId, content: { text: str(block.text) }, timestamp: ts, }); break; case "thinking": messages.push({ id: `${uuid}-think-${messages.length}`, type: "thinking", parentId, content: { text: str(block.thinking ?? block.text) }, timestamp: ts, }); break; case "tool_use": messages.push({ id: `${uuid}-tool-${messages.length}`, type: "tool_call", parentId, content: { toolUseId: str(block.id), name: str(block.name), input: block.input, }, timestamp: ts, }); break; } } return messages; } function adaptClaudeUser( raw: Record, uuid: string, ts: number, parentId?: string, ): AgentMessage[] { const msg = typeof raw.message === "object" && raw.message !== null ? (raw.message as Record) : undefined; if (!msg) return []; const content = Array.isArray(msg.content) ? (msg.content as Array>) : undefined; if (!content) return []; const messages: AgentMessage[] = []; for (const block of content) { if (block.type === "tool_result") { messages.push({ id: `${uuid}-result-${messages.length}`, type: "tool_result", parentId, content: { toolUseId: str(block.tool_use_id), output: block.content ?? raw.tool_use_result, }, timestamp: ts, }); } } return messages; } function adaptClaudeResult( raw: Record, uuid: string, ts: number, ): AgentMessage[] { const usage = typeof raw.usage === "object" && raw.usage !== null ? (raw.usage as Record) : undefined; return [ { id: uuid, type: "cost", content: { totalCostUsd: num(raw.total_cost_usd), durationMs: num(raw.duration_ms), inputTokens: num(usage?.input_tokens), outputTokens: num(usage?.output_tokens), numTurns: num(raw.num_turns), isError: raw.is_error === true, result: typeof raw.result === "string" ? raw.result : undefined, }, timestamp: ts, }, ]; } // ── Codex adapter ──────────────────────────────────────────────────────────── function adaptCodexMessage(raw: Record): AgentMessage[] { const ts = Date.now(); const uuid = crypto.randomUUID(); switch (raw.type) { case "thread.started": return [ { id: uuid, type: "init", content: { sessionId: str(raw.thread_id), model: "", cwd: "", tools: [], }, timestamp: ts, }, ]; case "turn.started": return [ { id: uuid, type: "status", content: { subtype: "turn_started" }, timestamp: ts, }, ]; case "turn.completed": { const usage = typeof raw.usage === "object" && raw.usage !== null ? (raw.usage as Record) : {}; return [ { id: uuid, type: "cost", content: { totalCostUsd: 0, durationMs: 0, inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens), numTurns: 1, isError: false, }, timestamp: ts, }, ]; } case "turn.failed": return [ { id: uuid, type: "error", content: { message: str( (raw.error as Record)?.message, "Turn failed", ), }, timestamp: ts, }, ]; case "item.started": case "item.updated": case "item.completed": return adaptCodexItem(raw, uuid, ts); case "error": return [ { id: uuid, type: "error", content: { message: str(raw.message, "Unknown error") }, timestamp: ts, }, ]; default: return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }]; } } function adaptCodexItem( raw: Record, uuid: string, ts: number, ): AgentMessage[] { const item = typeof raw.item === "object" && raw.item !== null ? (raw.item as Record) : {}; const itemType = str(item.type); const eventType = str(raw.type); switch (itemType) { case "agent_message": if (eventType !== "item.completed") return []; return [ { id: uuid, type: "text", content: { text: str(item.text) }, timestamp: ts, }, ]; case "reasoning": if (eventType !== "item.completed") return []; return [ { id: uuid, type: "thinking", content: { text: str(item.text) }, timestamp: ts, }, ]; case "command_execution": { // Fix #13: Only emit tool_call on item.started, tool_result on item.completed // Prevents duplicate tool_call messages. const messages: AgentMessage[] = []; const toolUseId = str(item.id, uuid); if (eventType === "item.started") { messages.push({ id: `${uuid}-call`, type: "tool_call", content: { toolUseId, name: "Bash", input: { command: str(item.command) }, }, timestamp: ts, }); } if (eventType === "item.completed") { messages.push({ id: `${uuid}-result`, type: "tool_result", content: { toolUseId, output: str(item.aggregated_output), }, timestamp: ts, }); } return messages; } default: return []; } } // ── Ollama adapter ─────────────────────────────────────────────────────────── function adaptOllamaMessage(raw: Record): AgentMessage[] { const ts = Date.now(); const uuid = crypto.randomUUID(); switch (raw.type) { case "system": { const subtype = str(raw.subtype); if (subtype === "init") { return [ { id: uuid, type: "init", content: { sessionId: str(raw.session_id), model: str(raw.model), cwd: str(raw.cwd), tools: [], }, timestamp: ts, }, ]; } return [ { id: uuid, type: "status", content: { subtype, message: typeof raw.status === "string" ? raw.status : undefined, }, timestamp: ts, }, ]; } case "chunk": { const messages: AgentMessage[] = []; const msg = typeof raw.message === "object" && raw.message !== null ? (raw.message as Record) : {}; const done = raw.done === true; const thinking = str(msg.thinking); if (thinking) { messages.push({ id: `${uuid}-think`, type: "thinking", content: { text: thinking }, timestamp: ts, }); } const text = str(msg.content); if (text) { messages.push({ id: `${uuid}-text`, type: "text", content: { text }, timestamp: ts, }); } if (done) { const evalDuration = num(raw.eval_duration); const durationMs = evalDuration > 0 ? Math.round(evalDuration / 1_000_000) : 0; messages.push({ id: `${uuid}-cost`, type: "cost", content: { totalCostUsd: 0, durationMs, inputTokens: num(raw.prompt_eval_count), outputTokens: num(raw.eval_count), numTurns: 1, isError: str(raw.done_reason) === "error", }, timestamp: ts, }); } return messages; } case "error": return [ { id: uuid, type: "error", content: { message: str(raw.message, "Ollama error") }, timestamp: ts, }, ]; default: return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }]; } }