import type { ChatEvent, MessageCost } from '../types'; // Translates OpenCode's SSE events into our internal ChatEvent union — the OpenCode analog of // sidecar/claude/stream-parser.ts. Shapes verified live against opencode 1.17.9's `GET /event`: // // message.part.updated { part:{ id, type } } → learn partID→type // message.part.delta { partID, delta } → delta (only if partID is a text part) // message.part.updated { part:{ type:'tool', callID, tool, state } } → tool:start / tool:result // message.updated { info:{ role:'assistant', cost, tokens } } → stash final cost // session.idle → result (turn complete) // session.error → error // // IMPORTANT: deltas always carry `field:'text'` even for the model's *reasoning* — reasoning and answer // are only distinguishable by the delta's part TYPE (a message.part.updated declaring the part as // `reasoning` vs `text` always precedes that part's deltas). So we gate deltas on partID being a `text` // part; reasoning-part deltas are dropped (parity with the Claude harness, which hides thinking). // createEventHandler flushes the assistant text buffer on tool:start and result, so no explicit `text` // event is needed — the streamed answer deltas are enough. export type OpenCodeEvent = { id?: string; type: string; properties?: Record; }; type ToolState = { status?: string; input?: Record; output?: string; error?: string; }; type OpenCodePart = { id?: string; type?: string; callID?: string; tool?: string; field?: string; state?: ToolState; }; type AssistantInfo = { role?: string; cost?: number; tokens?: { input?: number; output?: number }; }; /** * Returns a stateful handler that maps raw OpenCode events to ChatEvents, invoking `onEvent` for each. * State tracks per-callID tool progress (to emit start/result exactly once) and the latest assistant * cost/tokens (emitted with the terminal `result`). */ export function createEventMapper(onEvent: (event: ChatEvent) => void) { const toolStarted = new Set(); const toolFinished = new Set(); const partTypes = new Map(); // partID → type (declared before that part's deltas) let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; let done = false; const finish = (event: ChatEvent) => { if (done) return; done = true; onEvent(event); }; return function handle(evt: OpenCodeEvent): void { const p = evt.properties ?? {}; switch (evt.type) { case 'message.part.delta': { // Only stream deltas belonging to a `text` part — reasoning parts also emit field:'text' deltas. const partID = p.partID as string | undefined; if (partID && partTypes.get(partID) === 'text' && typeof p.delta === 'string' && p.delta.length > 0) { onEvent({ type: 'delta', text: p.delta }); } return; } case 'message.part.updated': { const part = p.part as OpenCodePart | undefined; if (!part) return; if (part.id && part.type) partTypes.set(part.id, part.type); if (part.type !== 'tool' || !part.callID) return; const callID = part.callID; const status = part.state?.status; if ((status === 'running' || status === 'completed' || status === 'error') && !toolStarted.has(callID)) { toolStarted.add(callID); onEvent({ type: 'tool:start', toolCallId: callID, toolName: part.tool ?? 'tool', toolInput: part.state?.input ?? {}, }); } if (status === 'completed' && !toolFinished.has(callID)) { toolFinished.add(callID); onEvent({ type: 'tool:result', toolCallId: callID, output: String(part.state?.output ?? ''), isError: false }); } else if (status === 'error' && !toolFinished.has(callID)) { toolFinished.add(callID); onEvent({ type: 'tool:result', toolCallId: callID, output: String(part.state?.error ?? 'Tool failed'), isError: true, }); } return; } case 'message.updated': { const info = ((p.info as AssistantInfo | undefined) ?? (p as AssistantInfo)) ?? {}; if (info.role === 'assistant' && info.tokens) { cost = { inputTokens: info.tokens.input ?? 0, outputTokens: info.tokens.output ?? 0, totalUSD: info.cost ?? 0, }; } return; } case 'session.idle': { finish({ type: 'result', cost }); return; } case 'session.error': { const error = p.error; const message = typeof error === 'string' ? error : ((error as { message?: string })?.message ?? 'OpenCode error'); finish({ type: 'error', message }); return; } default: return; } }; }