/** * Bridge module: converts Claude Code's SDKMessage stream events from * QueryEngine.submitMessage() into ACP SessionUpdate notifications. * * Handles all SDKMessage types: * - system (compact_boundary, api_retry, local_command_output) * - user (message replay) * - assistant (full messages with content blocks) * - stream_event (real-time streaming: content_block_start/delta) * - result (turn termination with usage/cost) * - progress (subagent progress) * - tool_use_summary */ import type { AgentSideConnection, ClientCapabilities, ContentBlock, PlanEntry, SessionNotification, SessionUpdate, StopReason, ToolCallContent, ToolCallLocation, ToolKind, } from '@agentclientprotocol/sdk' import type { SDKMessage } from '../../entrypoints/sdk/coreTypes.generated.js' import { toDisplayPath, markdownEscape } from './utils.js' // ── ToolUseCache ────────────────────────────────────────────────── export type ToolUseCache = { [key: string]: { type: 'tool_use' | 'server_tool_use' | 'mcp_tool_use' id: string name: string input: unknown } } // ── Session usage tracking ──────────────────────────────────────── export type SessionUsage = { inputTokens: number outputTokens: number cachedReadTokens: number cachedWriteTokens: number } // ── Tool info conversion ────────────────────────────────────────── interface ToolInfo { title: string kind: ToolKind content: ToolCallContent[] locations?: ToolCallLocation[] } export function toolInfoFromToolUse( toolUse: { name: string; id: string; input: Record }, _supportsTerminalOutput: boolean = false, cwd?: string, ): ToolInfo { const name = toolUse.name const input = toolUse.input switch (name) { case 'Agent': case 'Task': { const description = (input?.description as string | undefined) ?? 'Task' const prompt = input?.prompt as string | undefined return { title: description, kind: 'think', content: prompt ? [{ type: 'content' as const, content: { type: 'text' as const, text: prompt } }] : [], } } case 'Bash': { const command = (input?.command as string | undefined) ?? 'Terminal' const description = input?.description as string | undefined return { title: command, kind: 'execute', content: _supportsTerminalOutput ? [{ type: 'terminal' as const, terminalId: toolUse.id }] : description ? [{ type: 'content' as const, content: { type: 'text' as const, text: description } }] : [], } } case 'Read': { const filePath = (input?.file_path as string | undefined) ?? 'File' const offset = input?.offset as number | undefined const limit = input?.limit as number | undefined let suffix = '' if (limit && limit > 0) { suffix = ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` } else if (offset) { suffix = ` (from line ${offset})` } const displayPath = filePath ? toDisplayPath(filePath, cwd) : 'File' return { title: `Read ${displayPath}${suffix}`, kind: 'read', locations: filePath ? [{ path: filePath, line: offset ?? 1 }] : [], content: [], } } case 'Write': { const filePath = (input?.file_path as string | undefined) ?? '' const content = (input?.content as string | undefined) ?? '' const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined return { title: displayPath ? `Write ${displayPath}` : 'Write', kind: 'edit', content: filePath ? [{ type: 'diff' as const, path: filePath, oldText: null, newText: content }] : [{ type: 'content' as const, content: { type: 'text' as const, text: content } }], locations: filePath ? [{ path: filePath }] : [], } } case 'Edit': { const filePath = (input?.file_path as string | undefined) ?? '' const oldString = (input?.old_string as string | undefined) ?? '' const newString = (input?.new_string as string | undefined) ?? '' const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined return { title: displayPath ? `Edit ${displayPath}` : 'Edit', kind: 'edit', content: filePath ? [{ type: 'diff' as const, path: filePath, oldText: oldString || null, newText: newString }] : [], locations: filePath ? [{ path: filePath }] : [], } } case 'Glob': { const globPath = (input?.path as string | undefined) ?? '' const pattern = (input?.pattern as string | undefined) ?? '' let label = 'Find' if (globPath) label += ` \`${globPath}\`` if (pattern) label += ` \`${pattern}\`` return { title: label, kind: 'search', content: [], locations: globPath ? [{ path: globPath }] : [], } } case 'Grep': { const grepPattern = (input?.pattern as string | undefined) ?? '' const grepPath = (input?.path as string | undefined) ?? '' let label = 'grep' if (input?.['-i']) label += ' -i' if (input?.['-n']) label += ' -n' if (input?.['-A'] !== undefined) label += ` -A ${input['-A'] as number}` if (input?.['-B'] !== undefined) label += ` -B ${input['-B'] as number}` if (input?.['-C'] !== undefined) label += ` -C ${input['-C'] as number}` if (input?.output_mode === 'files_with_matches') label += ' -l' else if (input?.output_mode === 'count') label += ' -c' if (input?.head_limit !== undefined) label += ` | head -${input.head_limit as number}` if (input?.glob) label += ` --include="${input.glob as string}"` if (input?.type) label += ` --type=${input.type as string}` if (input?.multiline) label += ' -P' if (grepPattern) label += ` "${grepPattern}"` if (grepPath) label += ` ${grepPath}` return { title: label, kind: 'search', content: [], } } case 'WebFetch': { const url = (input?.url as string | undefined) ?? '' const fetchPrompt = input?.prompt as string | undefined return { title: url ? `Fetch ${url}` : 'Fetch', kind: 'fetch', content: fetchPrompt ? [{ type: 'content' as const, content: { type: 'text' as const, text: fetchPrompt } }] : [], } } case 'WebSearch': { const query = (input?.query as string | undefined) ?? 'Web search' let label = `"${query}"` const allowed = input?.allowed_domains as string[] | undefined const blocked = input?.blocked_domains as string[] | undefined if (allowed && allowed.length > 0) label += ` (allowed: ${allowed.join(', ')})` if (blocked && blocked.length > 0) label += ` (blocked: ${blocked.join(', ')})` return { title: label, kind: 'fetch', content: [], } } case 'TodoWrite': { const todos = input?.todos as Array<{ content: string }> | undefined return { title: Array.isArray(todos) ? `Update TODOs: ${todos.map((t) => t.content).join(', ')}` : 'Update TODOs', kind: 'think', content: [], } } case 'ExitPlanMode': { const plan = (input as Record)?.plan as string | undefined return { title: 'Ready to code?', kind: 'switch_mode', content: plan ? [{ type: 'content' as const, content: { type: 'text' as const, text: plan } }] : [], } } default: return { title: name || 'Unknown Tool', kind: 'other', content: [], } } } // ── Tool result conversion ──────────────────────────────────────── export function toolUpdateFromToolResult( toolResult: Record, toolUse: { name: string; id: string } | undefined, _supportsTerminalOutput: boolean = false, ): { content?: ToolCallContent[]; title?: string; _meta?: Record } { if (!toolUse) return {} const isError = toolResult.is_error === true const resultContent = toolResult.content as | string | Array> | undefined // For error results, return error content if (isError && resultContent) { return toAcpContentUpdate(resultContent, true) } switch (toolUse.name) { case 'Read': { if (typeof resultContent === 'string' && resultContent.length > 0) { return { content: [ { type: 'content' as const, content: { type: 'text' as const, text: markdownEscape(resultContent) }, }, ], } } if (Array.isArray(resultContent) && resultContent.length > 0) { return { content: resultContent.map((c: Record) => ({ type: 'content' as const, content: c.type === 'text' ? { type: 'text' as const, text: markdownEscape(c.text as string) } : toAcpContentBlock(c, false), })), } } return {} } case 'Bash': { let output = '' let exitCode = isError ? 1 : 0 const terminalId = String(toolUse.id) // Handle bash_code_execution_result format if ( resultContent && typeof resultContent === 'object' && !Array.isArray(resultContent) && (resultContent as Record).type === 'bash_code_execution_result' ) { const bashResult = resultContent as Record output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join('\n') exitCode = (bashResult.return_code as number) ?? (isError ? 1 : 0) } else if (typeof resultContent === 'string') { output = resultContent } else if (Array.isArray(resultContent) && resultContent.length > 0) { output = resultContent .map((c: Record) => c.type === 'text' ? (c.text as string) : '', ) .join('\n') } if (_supportsTerminalOutput) { return { content: [{ type: 'terminal' as const, terminalId }], _meta: { terminal_info: { terminal_id: terminalId }, terminal_output: { terminal_id: terminalId, data: output }, terminal_exit: { terminal_id: terminalId, exit_code: exitCode, signal: null }, }, } } if (output.trim()) { return { content: [ { type: 'content' as const, content: { type: 'text' as const, text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``, }, }, ], } } return {} } case 'Edit': case 'Write': { return {} } case 'ExitPlanMode': { return { title: 'Exited Plan Mode' } } default: { return toAcpContentUpdate( resultContent ?? '', isError, ) } } } function toAcpContentUpdate( content: unknown, isError: boolean, ): { content?: ToolCallContent[] } { if (Array.isArray(content) && content.length > 0) { return { content: content.map((c: Record) => ({ type: 'content' as const, content: toAcpContentBlock(c, isError), })), } } if (typeof content === 'string' && content.length > 0) { return { content: [ { type: 'content' as const, content: { type: 'text' as const, text: isError ? `\`\`\`\n${content}\n\`\`\`` : content, }, }, ], } } return {} } function toAcpContentBlock( content: Record, isError: boolean, ): ContentBlock { const wrapText = (text: string): ContentBlock => ({ type: 'text', text: isError ? `\`\`\`\n${text}\n\`\`\`` : text, }) const type = content.type as string switch (type) { case 'text': { const text = content.text as string return { type: 'text', text: isError ? `\`\`\`\n${text}\n\`\`\`` : text } } case 'image': { const source = content.source as Record | undefined if (source?.type === 'base64') { return { type: 'image', data: source.data as string, mimeType: source.media_type as string, } } return wrapText( source?.type === 'url' ? `[image: ${source.url as string}]` : '[image: file reference]', ) } case 'tool_reference': return wrapText(`Tool: ${content.tool_name as string}`) case 'tool_search_tool_search_result': { const refs = content.tool_references as Array<{ tool_name: string }> | undefined return wrapText(`Tools found: ${refs?.map((r) => r.tool_name).join(', ') || 'none'}`) } case 'tool_search_tool_result_error': return wrapText( `Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`, ) case 'web_search_result': return wrapText(`${content.title as string} (${content.url as string})`) case 'web_search_tool_result_error': return wrapText(`Error: ${content.error_code as string}`) case 'web_fetch_result': return wrapText(`Fetched: ${content.url as string}`) case 'web_fetch_tool_result_error': return wrapText(`Error: ${content.error_code as string}`) case 'code_execution_result': case 'bash_code_execution_result': return wrapText(`Output: ${(content.stdout as string) || (content.stderr as string) || ''}`) case 'code_execution_tool_result_error': case 'bash_code_execution_tool_result_error': return wrapText(`Error: ${content.error_code as string}`) case 'text_editor_code_execution_view_result': return wrapText(content.content as string) case 'text_editor_code_execution_create_result': return wrapText(content.is_file_update ? 'File updated' : 'File created') case 'text_editor_code_execution_str_replace_result': { const lines = content.lines as string[] | undefined return wrapText(lines?.join('\n') || '') } case 'text_editor_code_execution_tool_result_error': return wrapText( `Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`, ) default: try { return { type: 'text', text: JSON.stringify(content) } } catch { return { type: 'text', text: '[content]' } } } } // ── Edit tool response → diff ────────────────────────────────────── interface EditToolResponseHunk { oldStart: number oldLines: number newStart: number newLines: number lines: string[] } interface EditToolResponse { filePath?: string structuredPatch?: EditToolResponseHunk[] } /** * Builds diff ToolUpdate content from the structured Edit toolResponse. * Parses structuredPatch hunks (lines prefixed with -, +, space) into * oldText/newText diff pairs. */ export function toolUpdateFromEditToolResponse(toolResponse: unknown): { content?: ToolCallContent[] locations?: ToolCallLocation[] } { if (!toolResponse || typeof toolResponse !== 'object') return {} const response = toolResponse as EditToolResponse if (!response.filePath || !Array.isArray(response.structuredPatch)) return {} const content: ToolCallContent[] = [] const locations: ToolCallLocation[] = [] for (const { lines, newStart } of response.structuredPatch) { const oldText: string[] = [] const newText: string[] = [] for (const line of lines) { if (line.startsWith('-')) { oldText.push(line.slice(1)) } else if (line.startsWith('+')) { newText.push(line.slice(1)) } else { oldText.push(line.slice(1)) newText.push(line.slice(1)) } } if (oldText.length > 0 || newText.length > 0) { locations.push({ path: response.filePath, line: newStart }) content.push({ type: 'diff', path: response.filePath, oldText: oldText.join('\n') || null, newText: newText.join('\n'), }) } } const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {} if (content.length > 0) result.content = content if (locations.length > 0) result.locations = locations return result } // ── Prompt conversion ───────────────────────────────────────────── /** * Convert ACP PromptRequest content blocks into content for QueryEngine. */ export function promptToQueryContent( prompt: Array | undefined, ): string { if (!prompt) return '' return prompt .map((block) => { const b = block as Record if (b.type === 'text') return b.text as string if (b.type === 'resource_link') return `[${b.name ?? ''}](${b.uri as string})` if (b.type === 'resource') { const resource = b.resource as Record | undefined if (resource && 'text' in resource) return resource.text as string } return '' }) .filter(Boolean) .join('\n') } // ── Main forwarding function ────────────────────────────────────── /** * Iterates SDKMessages from QueryEngine.submitMessage(), converts each * to ACP SessionUpdate notifications, and sends them via conn.sessionUpdate(). * Returns the final StopReason and accumulated usage for the prompt turn. */ export async function forwardSessionUpdates( sessionId: string, sdkMessages: AsyncGenerator, conn: AgentSideConnection, abortSignal: AbortSignal, toolUseCache: ToolUseCache, clientCapabilities?: ClientCapabilities, cwd?: string, isCancelled?: () => boolean, ): Promise<{ stopReason: StopReason; usage?: SessionUsage }> { let stopReason: StopReason = 'end_turn' const accumulatedUsage: SessionUsage = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, } // Track last assistant usage/model for context window size computation let lastAssistantTotalUsage: number | null = null let lastAssistantModel: string | null = null let lastContextWindowSize = 200000 try { while (!abortSignal.aborted) { // Race the next message against the abort signal so we unblock // immediately when cancelled, even if the generator is waiting for // a slow API response. const nextResult = await Promise.race([ sdkMessages.next(), new Promise>((resolve) => { if (abortSignal.aborted) { resolve({ done: true, value: undefined }) return } const handler = () => resolve({ done: true, value: undefined }) abortSignal.addEventListener('abort', handler, { once: true }) }), ]) if (nextResult.done || abortSignal.aborted) break const msg = nextResult.value if (msg == null) continue const type = msg.type as string switch (type) { // ── System messages ──────────────────────────────────────── case 'system': { const subtype = msg.subtype as string | undefined if (subtype === 'compact_boundary') { // Reset assistant usage tracking after compaction lastAssistantTotalUsage = 0 // Send usage reset after compaction await conn.sessionUpdate({ sessionId, update: { sessionUpdate: 'usage_update', used: 0, size: lastContextWindowSize, }, }) await conn.sessionUpdate({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '\n\nCompacting completed.' }, }, }) } // api_retry, local_command_output — skip for now break } // ── Result messages ──────────────────────────────────────── case 'result': { const usage = msg.usage as | { input_tokens: number output_tokens: number cache_read_input_tokens: number cache_creation_input_tokens: number } | undefined if (usage) { accumulatedUsage.inputTokens += usage.input_tokens accumulatedUsage.outputTokens += usage.output_tokens accumulatedUsage.cachedReadTokens += usage.cache_read_input_tokens accumulatedUsage.cachedWriteTokens += usage.cache_creation_input_tokens } // Resolve context window size from modelUsage via prefix matching const modelUsage = msg.modelUsage as | Record | undefined if (modelUsage && lastAssistantModel) { const match = getMatchingModelUsage(modelUsage, lastAssistantModel) if (match?.contextWindow) { lastContextWindowSize = match.contextWindow } } // Send usage_update — use lastAssistantTotalUsage if available // (more accurate than accumulatedUsage which may include background tasks) const usedTokens = lastAssistantTotalUsage ?? ( accumulatedUsage.inputTokens + accumulatedUsage.outputTokens + accumulatedUsage.cachedReadTokens + accumulatedUsage.cachedWriteTokens ) const totalCostUsd = msg.total_cost_usd as number | undefined await conn.sessionUpdate({ sessionId, update: { sessionUpdate: 'usage_update', used: usedTokens, size: lastContextWindowSize, cost: totalCostUsd != null ? { amount: totalCostUsd, currency: 'USD' } : undefined, }, }) // Determine stop reason const subtype = msg.subtype as string | undefined const isError = msg.is_error as boolean | undefined if (abortSignal.aborted) { stopReason = 'cancelled' break } switch (subtype) { case 'success': { const stopReasonStr = msg.stop_reason as string | null if (stopReasonStr === 'max_tokens') { stopReason = 'max_tokens' } if (isError) { // Report error as end_turn stopReason = 'end_turn' } break } case 'error_during_execution': { if ((msg.stop_reason as string | null) === 'max_tokens') { stopReason = 'max_tokens' } else if (isError) { stopReason = 'end_turn' } else { stopReason = 'end_turn' } break } case 'error_max_budget_usd': case 'error_max_turns': case 'error_max_structured_output_retries': if (isError) { stopReason = 'max_turn_requests' } else { stopReason = 'max_turn_requests' } break } break } // ── Stream events ────────────────────────────────────────── case 'stream_event': { const notifications = streamEventToAcpNotifications( msg, sessionId, toolUseCache, conn, { clientCapabilities, cwd, }, ) for (const notification of notifications) { await conn.sessionUpdate(notification) } break } // ── Assistant messages ───────────────────────────────────── case 'assistant': { // Track last assistant total usage for context window computation // (only for top-level messages, not subagents) const assistantMsg = msg.message as Record | undefined const parentToolUseId = msg.parent_tool_use_id as string | null | undefined if (assistantMsg?.usage && parentToolUseId === null) { const msgUsage = assistantMsg.usage as Record lastAssistantTotalUsage = ((msgUsage.input_tokens as number) ?? 0) + ((msgUsage.output_tokens as number) ?? 0) + ((msgUsage.cache_read_input_tokens as number) ?? 0) + ((msgUsage.cache_creation_input_tokens as number) ?? 0) } // Track the current top-level model for context window size lookup if ( parentToolUseId === null && assistantMsg?.model && assistantMsg.model !== '' ) { lastAssistantModel = assistantMsg.model as string } const notifications = assistantMessageToAcpNotifications( msg, sessionId, toolUseCache, conn, { clientCapabilities, cwd, }, ) for (const notification of notifications) { await conn.sessionUpdate(notification) } break } // ── User messages ────────────────────────────────────────── case 'user': { // In ACP mode, user messages from replay/synthetic are typically skipped // The client already knows what the user sent break } // ── Progress messages ────────────────────────────────────── case 'progress': { const progressData = msg.data as Record | undefined if (!progressData) break // Handle agent/skill subagent progress const progressType = progressData.type as string | undefined if (progressType === 'agent_progress' || progressType === 'skill_progress') { const progressMessage = progressData.message as | Record | undefined if (progressMessage) { const content = progressMessage.content as | Array> | undefined if (content) { for (const block of content) { if (block.type === 'text') { await conn.sessionUpdate({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: block.text as string }, }, }) } } } } } break } // ── Tool use summary ─────────────────────────────────────── case 'tool_use_summary': { // Skip for now — not critical for basic functionality break } // ── Attachment messages ──────────────────────────────────── case 'attachment': { // Skip — handled by QueryEngine internally break } // ── Compact boundary ─────────────────────────────────────── case 'compact_boundary': { lastAssistantTotalUsage = 0 await conn.sessionUpdate({ sessionId, update: { sessionUpdate: 'usage_update', used: 0, size: lastContextWindowSize, }, }) await conn.sessionUpdate({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '\n\nCompacting completed.' }, }, }) break } default: // Ignore unknown message types break } } // If we exited the loop because abort fired or cancel was requested, return cancelled if (abortSignal.aborted || isCancelled?.()) { return { stopReason: 'cancelled', usage: accumulatedUsage } } } catch (err: unknown) { if (abortSignal.aborted) { return { stopReason: 'cancelled', usage: accumulatedUsage } } throw err } return { stopReason, usage: accumulatedUsage } } // ── Assistant message conversion ────────────────────────────────── function assistantMessageToAcpNotifications( msg: SDKMessage, sessionId: string, toolUseCache: ToolUseCache, conn: AgentSideConnection, options?: { clientCapabilities?: ClientCapabilities parentToolUseId?: string | null cwd?: string }, ): SessionNotification[] { const message = msg.message as Record | undefined if (!message) return [] const content = message.content as | string | Array> | undefined if (!content) return [] // If content is a string, treat as text if (typeof content === 'string') { return [ { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: content }, }, }, ] } return toAcpNotifications(content, 'assistant', sessionId, toolUseCache, conn, undefined, options) } // ── Stream event conversion ─────────────────────────────────────── function streamEventToAcpNotifications( msg: SDKMessage, sessionId: string, toolUseCache: ToolUseCache, conn: AgentSideConnection, options?: { clientCapabilities?: ClientCapabilities cwd?: string }, ): SessionNotification[] { const event = (msg as unknown as { event: Record }).event if (!event) return [] switch (event.type as string) { case 'content_block_start': { const contentBlock = event.content_block as Record | undefined if (!contentBlock) return [] return toAcpNotifications( [contentBlock], 'assistant', sessionId, toolUseCache, conn, undefined, { clientCapabilities: options?.clientCapabilities, parentToolUseId: msg.parent_tool_use_id as string | null | undefined, cwd: options?.cwd, }, ) } case 'content_block_delta': { const delta = event.delta as Record | undefined if (!delta) return [] return toAcpNotifications( [delta], 'assistant', sessionId, toolUseCache, conn, undefined, { clientCapabilities: options?.clientCapabilities, parentToolUseId: msg.parent_tool_use_id as string | null | undefined, cwd: options?.cwd, }, ) } // No content to emit case 'message_start': case 'message_delta': case 'message_stop': case 'content_block_stop': return [] default: return [] } } // ── Core content block → ACP notification conversion ────────────── function toAcpNotifications( content: Array>, role: 'assistant' | 'user', sessionId: string, toolUseCache: ToolUseCache, _conn: AgentSideConnection, _logger?: { error: (...args: unknown[]) => void }, options?: { registerHooks?: boolean clientCapabilities?: ClientCapabilities parentToolUseId?: string | null cwd?: string }, ): SessionNotification[] { const output: SessionNotification[] = [] for (const chunk of content) { const chunkType = chunk.type as string let update: SessionUpdate | null = null switch (chunkType) { case 'text': case 'text_delta': { const text = (chunk.text as string) ?? '' update = { sessionUpdate: role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', content: { type: 'text', text }, } break } case 'thinking': case 'thinking_delta': { const thinking = (chunk.thinking as string) ?? '' update = { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: thinking }, } break } case 'image': { const source = chunk.source as Record | undefined if (source?.type === 'base64') { update = { sessionUpdate: role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', content: { type: 'image', data: source.data as string, mimeType: source.media_type as string, }, } } break } case 'tool_use': case 'server_tool_use': case 'mcp_tool_use': { const toolUseId = (chunk.id as string) ?? '' const toolName = (chunk.name as string) ?? 'unknown' const toolInput = chunk.input as Record | undefined const alreadyCached = toolUseId in toolUseCache // Cache this tool_use for later matching toolUseCache[toolUseId] = { type: chunkType as 'tool_use' | 'server_tool_use' | 'mcp_tool_use', id: toolUseId, name: toolName, input: toolInput, } // TodoWrite → plan update if (toolName === 'TodoWrite') { const todos = (toolInput as Record)?.todos as | Array<{ content: string; status: string }> | undefined if (Array.isArray(todos)) { const entries: PlanEntry[] = todos.map((todo) => ({ content: todo.content, status: normalizePlanStatus(todo.status), priority: 'medium', })) update = { sessionUpdate: 'plan', entries, } } } else { // Regular tool call let rawInput: Record | undefined try { rawInput = JSON.parse(JSON.stringify(toolInput ?? {})) } catch { // Ignore parse failures } if (alreadyCached) { // Second encounter — send as tool_call_update update = { _meta: { claudeCode: { toolName }, }, toolCallId: toolUseId, sessionUpdate: 'tool_call_update', rawInput, ...toolInfoFromToolUse( { name: toolName, id: toolUseId, input: toolInput ?? {} }, false, options?.cwd, ), } } else { // First encounter — send as tool_call update = { _meta: { claudeCode: { toolName }, }, toolCallId: toolUseId, sessionUpdate: 'tool_call', rawInput, status: 'pending', ...toolInfoFromToolUse( { name: toolName, id: toolUseId, input: toolInput ?? {} }, false, options?.cwd, ), } } } break } case 'tool_result': case 'mcp_tool_result': { const toolUseId = (chunk.tool_use_id as string | undefined) ?? '' const toolUse = toolUseCache[toolUseId] if (!toolUse) break if (toolUse.name !== 'TodoWrite') { const toolUpdate = toolUpdateFromToolResult( chunk as unknown as Record, { name: toolUse.name, id: toolUse.id }, false, ) update = { _meta: { claudeCode: { toolName: toolUse.name }, }, toolCallId: toolUseId, sessionUpdate: 'tool_call_update', status: (chunk.is_error as boolean | undefined) === true ? 'failed' : 'completed', rawOutput: chunk.content, ...toolUpdate, } } break } case 'redacted_thinking': case 'input_json_delta': case 'citations_delta': case 'signature_delta': case 'container_upload': case 'compaction': case 'compaction_delta': // Skip these types break } if (update) { // Add parentToolUseId to _meta if present if (options?.parentToolUseId) { const existingMeta = (update as Record)._meta as | Record | undefined ;(update as Record)._meta = { ...existingMeta, claudeCode: { ...((existingMeta?.claudeCode as Record) ?? {}), parentToolUseId: options.parentToolUseId, }, } } output.push({ sessionId, update }) } } return output } function normalizePlanStatus( status: string, ): 'pending' | 'in_progress' | 'completed' { if (status === 'in_progress') return 'in_progress' if (status === 'completed') return 'completed' return 'pending' } // ── History replay ────────────────────────────────────────────────── /** * Replays conversation history messages to the ACP client as session updates. * Used when resuming/loading a session to show the client the previous conversation. */ export async function replayHistoryMessages( sessionId: string, messages: Array>, conn: AgentSideConnection, toolUseCache: ToolUseCache, clientCapabilities?: ClientCapabilities, cwd?: string, ): Promise { for (const msg of messages) { const type = msg.type as string // Skip non-conversation messages if (type !== 'user' && type !== 'assistant') continue // Skip meta messages (synthetic continuation prompts) if (msg.isMeta === true) continue const messageData = msg.message as Record | undefined const content = messageData?.content if (!content) continue const role: 'assistant' | 'user' = type === 'assistant' ? 'assistant' : 'user' if (typeof content === 'string') { if (!content.trim()) continue await conn.sessionUpdate({ sessionId, update: { sessionUpdate: role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', content: { type: 'text', text: content }, }, }) continue } if (Array.isArray(content)) { const notifications = toAcpNotifications( content as Array>, role, sessionId, toolUseCache, conn, undefined, { clientCapabilities, cwd }, ) for (const notification of notifications) { await conn.sessionUpdate(notification) } } } } // ── Model usage matching ────────────────────────────────────────── function commonPrefixLength(a: string, b: string): number { let i = 0 const maxLen = Math.min(a.length, b.length) while (i < maxLen && a[i] === b[i]) i++ return i } function getMatchingModelUsage( modelUsage: Record, currentModel: string, ): { contextWindow?: number } | null { let bestKey: string | null = null let bestLen = 0 for (const key of Object.keys(modelUsage)) { const len = commonPrefixLength(key, currentModel) if (len > bestLen) { bestLen = len bestKey = key } } return bestKey ? modelUsage[bestKey] ?? null : null }