/** * Copilot SDK → Unified Type Converters * * Translates Copilot SessionEvent objects into EngineOutput (unified types) * so the stream-manager and frontend can stay engine-agnostic. * * Streaming model: * session.start → SystemInitEvent * assistant.turn_start → StreamLifecycleEvent { event:'start' } * assistant.reasoning_delta → TextDeltaEvent { reasoning:true } (and reasoning stream lifecycle) * assistant.reasoning → ReasoningMessage (final reasoning text) * assistant.message_delta → TextDeltaEvent { reasoning:false } * assistant.message → AssistantMessage (text + tool_use blocks) * tool.execution_start → AssistantMessage { tool_use only } * tool.execution_complete → UserMessage { tool_result only } * assistant.usage → captured for ResultEvent * assistant.turn_end → StreamLifecycleEvent { event:'stop' } * session.idle → SuccessResultEvent (or aborted no-op) * session.error → throw buildSessionError(data) */ import type { MessageEngine } from '$shared/types/unified'; import type { EngineOutput, UserMessage, AssistantMessage as UnifiedAssistantMessage, ReasoningMessage, AssistantContentBlock, UserContentBlock, ToolUseBlock, TextDeltaEvent, StreamLifecycleEvent, SuccessResultEvent, SystemInitEvent, TokenUsage, AgentInput, BashInput, ReadInput, EditInput, WriteInput, GlobInput, GrepInput, WebFetchInput, WebSearchInput, AskUserQuestionInput, TodoWriteInput, ToolSearchInput, ListInput, } from '$shared/types/unified'; import { toCanonicalToolName } from '$shared/types/unified'; import type { SessionEvent } from '@github/copilot-sdk'; import { resolveOpenCodeToolName } from '../../../mcp'; // Derive data payload types from the SessionEvent discriminated union so we // don't depend on internal type re-exports that aren't part of the SDK's // public surface. type StartData = Extract['data']; type AssistantReasoningData = Extract['data']; type AssistantReasoningDeltaData = Extract['data']; type AssistantMessageData = Extract['data']; type AssistantMessageDeltaData = Extract['data']; type AssistantMessageToolRequest = NonNullable[number]; type AssistantTurnEndData = Extract['data']; type AssistantUsageData = Extract['data']; type ToolExecutionStartData = Extract['data']; type ToolExecutionCompleteData = Extract['data']; type SubagentStartedData = Extract['data']; type SubagentCompletedData = Extract['data']; type SubagentFailedData = Extract['data']; // ============================================================================ // Engine Identity // ============================================================================ /** Build a MessageEngine descriptor for Copilot messages. */ export function buildEngine(modelId: string): MessageEngine { return { type: 'copilot', provider: 'github', model: { id: modelId, name: '' }, account: { id: 0, name: '' }, }; } // ============================================================================ // Tool Name Mapping // ============================================================================ type RawToolArgs = Record; /** * Copilot CLI built-in tool names → canonical UI tool names. * * The Copilot CLI emits tool names in snake_case. The `str_replace_editor` * umbrella tool is split server-side into discriminated subcommands * (`view`, `create`, `str_replace`, `edit`, `insert`) which arrive as * separate `toolName` values in `tool.execution_*` events. */ const COPILOT_TOOL_NAME_MAP: Record = { // Shell / commands 'bash': 'Bash', 'shell': 'Bash', 'run_in_terminal': 'Bash', // File read (str_replace_editor view subcommand) 'view': 'Read', 'read': 'Read', 'read_file': 'Read', // File create (str_replace_editor create subcommand) 'create': 'Write', 'write': 'Write', 'write_file': 'Write', 'create_file': 'Write', // File edit (str_replace_editor edit/str_replace/insert subcommands) 'str_replace': 'Edit', 'str_replace_editor': 'Edit', 'edit': 'Edit', 'edit_file': 'Edit', 'insert': 'Edit', // Search / discovery 'grep': 'Grep', 'grep_search': 'Grep', 'glob': 'Glob', 'glob_search': 'Glob', 'list': 'List', 'list_dir': 'List', 'ls': 'List', // Task / planning 'update_todo': 'TodoWrite', 'todo_write': 'TodoWrite', 'todowrite': 'TodoWrite', // Sub-agent dispatch (Copilot's `task` tool spawns a sub-agent that runs // asynchronously; events from the sub-agent arrive with `agentId` set and // must thread back to this tool's call id via parent.toolUseId). 'task': 'Agent', 'write_agent': 'SendMessage', 'list_agents': 'ListAgents', 'read_agent': 'ReadAgent', 'send_inbox': 'SendInbox', 'context_board': 'ContextBoard', // User interaction 'ask_user': 'AskUserQuestion', 'question': 'AskUserQuestion', // Web 'web_search': 'WebSearch', 'websearch': 'WebSearch', 'fetch': 'WebFetch', 'fetch_url': 'WebFetch', 'web_fetch': 'WebFetch', 'webfetch': 'WebFetch', // Tool discovery 'tool_search': 'ToolSearch', 'tool_search_tool_regex': 'ToolSearch', }; /** * Copilot CLI internal tools used for model↔harness coordination (intent * reporting, task completion markers, work proposals, doc fetches). They * carry no user-actionable information, so we filter them out at the adapter * boundary instead of rendering them as `Unknown:*` blocks in the chat UI. */ const IGNORED_COPILOT_TOOLS = new Set([ 'report_intent', 'task_complete', 'propose_work', 'fetch_copilot_cli_documentation', ]); /** True if a tool name should be silently dropped from the stream. */ export function isIgnoredCopilotTool(rawName: string, mcpServerName?: string): boolean { if (mcpServerName) return false; return IGNORED_COPILOT_TOOLS.has(rawName.toLowerCase()); } /** Map a raw Copilot tool name (with optional MCP server) to a canonical UI name. */ export function mapCopilotToolName(rawName: string, mcpServerName?: string): string { if (mcpServerName) { // Tools served via our remote MCP HTTP endpoint (`clopen-mcp`) come // back as `{ name: '', mcpServerName: 'clopen-mcp' }`. Resolve // the bare name to `mcp____` via the same registry helper // Open Code uses so the UI renders the matching MCP tool component. const resolved = resolveOpenCodeToolName(rawName); if (resolved) return resolved; return `mcp__${mcpServerName}__${rawName}`; } const lower = rawName.toLowerCase(); const mapped = COPILOT_TOOL_NAME_MAP[lower] ?? COPILOT_TOOL_NAME_MAP[rawName] ?? rawName; return toCanonicalToolName(mapped); } // ============================================================================ // Tool Input Normalizer Helpers // ============================================================================ function str(raw: RawToolArgs, ...keys: string[]): string { for (const k of keys) { const v = raw[k]; if (v != null) return String(v); } return ''; } function optStr(raw: RawToolArgs, ...keys: string[]): string | undefined { for (const k of keys) { const v = raw[k]; if (v != null) return String(v); } return undefined; } function optNum(raw: RawToolArgs, ...keys: string[]): number | undefined { for (const k of keys) { const v = raw[k]; if (v != null) return Number(v); } return undefined; } function optBool(raw: RawToolArgs, ...keys: string[]): boolean | undefined { for (const k of keys) { const v = raw[k]; if (v != null) return Boolean(v); } return undefined; } // ============================================================================ // Per-Tool Input Normalizers // ============================================================================ function normalizeBashInput(raw: RawToolArgs): BashInput { const result: BashInput = { command: str(raw, 'command', 'cmd', 'shell') }; const description = optStr(raw, 'description', 'desc'); if (description != null) result.description = description; const timeout = optNum(raw, 'timeout', 'timeoutMs', 'timeout_ms'); if (timeout != null) result.timeout = timeout; const runInBackground = optBool(raw, 'run_in_background', 'runInBackground', 'background'); if (runInBackground != null) result.runInBackground = runInBackground; return result; } /** * `view` subcommand of `str_replace_editor` accepts `view_range: [start, end]` * (1-indexed, inclusive). Translate it into Read's `offset` + `limit`. */ function normalizeReadInput(raw: RawToolArgs): ReadInput { const result: ReadInput = { filePath: str(raw, 'path', 'file_path', 'filePath'), }; const offset = optNum(raw, 'offset'); if (offset != null) result.offset = offset; const limit = optNum(raw, 'limit'); if (limit != null) result.limit = limit; const range = raw.view_range ?? raw.viewRange; if (Array.isArray(range) && range.length >= 1) { const start = Number(range[0]); const end = range.length >= 2 ? Number(range[1]) : undefined; if (Number.isFinite(start)) { if (result.offset == null) result.offset = start; if (result.limit == null && end != null && Number.isFinite(end)) { result.limit = Math.max(end - start + 1, 1); } } } return result; } function normalizeWriteInput(raw: RawToolArgs): WriteInput { return { filePath: str(raw, 'path', 'file_path', 'filePath'), content: str(raw, 'file_text', 'content', 'text'), }; } /** * Edit covers three Copilot subcommands: `str_replace`, `edit`, and `insert`. * - str_replace/edit: `{ path, old_str, new_str }` * - insert: `{ path, insert_line, new_str }` (oldString left empty) */ function normalizeEditInput(raw: RawToolArgs): EditInput { const result: EditInput = { filePath: str(raw, 'path', 'file_path', 'filePath'), oldString: str(raw, 'old_str', 'old_string', 'oldString'), newString: str(raw, 'new_str', 'new_string', 'newString'), }; const replaceAll = optBool(raw, 'replace_all', 'replaceAll'); if (replaceAll != null) result.replaceAll = replaceAll; return result; } function normalizeGrepInput(raw: RawToolArgs): GrepInput { const result: GrepInput = { pattern: str(raw, 'pattern', 'query') }; const path = optStr(raw, 'path', 'directory', 'dir'); if (path != null) result.path = path; const glob = optStr(raw, 'glob', 'include', 'file_pattern'); if (glob != null) result.glob = glob; const outputMode = optStr(raw, 'output_mode', 'outputMode') as GrepInput['outputMode']; if (outputMode != null) result.outputMode = outputMode; const type = optStr(raw, 'type', 'file_type'); if (type != null) result.type = type; const headLimit = optNum(raw, 'head_limit', 'headLimit', 'max_results'); if (headLimit != null) result.headLimit = headLimit; const offset = optNum(raw, 'offset'); if (offset != null) result.offset = offset; const multiline = optBool(raw, 'multiline'); if (multiline != null) result.multiline = multiline; const caseInsensitive = optBool(raw, 'case_insensitive', 'caseInsensitive', 'ignore_case', '-i'); if (caseInsensitive != null) result.caseInsensitive = caseInsensitive; const beforeContext = optNum(raw, 'before_context', 'beforeContext', '-B'); if (beforeContext != null) result.beforeContext = beforeContext; const afterContext = optNum(raw, 'after_context', 'afterContext', '-A'); if (afterContext != null) result.afterContext = afterContext; const context = optNum(raw, 'context', '-C'); if (context != null) result.context = context; const lineNumbers = optBool(raw, 'line_numbers', 'lineNumbers', '-n'); if (lineNumbers != null) result.lineNumbers = lineNumbers; return result; } function normalizeGlobInput(raw: RawToolArgs): GlobInput { const result: GlobInput = { pattern: str(raw, 'pattern', 'glob', 'query') }; const path = optStr(raw, 'path', 'directory', 'dir'); if (path != null) result.path = path; return result; } function normalizeListInput(raw: RawToolArgs): ListInput { const result: ListInput = {}; const path = optStr(raw, 'path', 'directory', 'dir'); if (path != null) result.path = path; const ignore = raw.ignore; if (Array.isArray(ignore) && ignore.length) result.ignore = ignore.map(String); return result; } function normalizeWebSearchInput(raw: RawToolArgs): WebSearchInput { const result: WebSearchInput = { query: str(raw, 'query', 'q', 'search') }; const allowed = raw.allowed_domains ?? raw.allowedDomains; if (Array.isArray(allowed) && allowed.length) result.allowedDomains = allowed.map(String); const blocked = raw.blocked_domains ?? raw.blockedDomains; if (Array.isArray(blocked) && blocked.length) result.blockedDomains = blocked.map(String); return result; } function normalizeWebFetchInput(raw: RawToolArgs): WebFetchInput { return { url: str(raw, 'url', 'href'), prompt: str(raw, 'prompt', 'instruction', 'query', 'format'), }; } function normalizeAskUserQuestionInput(raw: RawToolArgs): AskUserQuestionInput { const questions = raw.questions; if (Array.isArray(questions)) { return { questions: questions as AskUserQuestionInput['questions'] }; } // Copilot's `ask_user` typically passes a single `question` string + optional `choices` array. const question = str(raw, 'question', 'prompt'); if (!question) return { questions: [] }; const choices = raw.choices ?? raw.options; const options = Array.isArray(choices) ? choices.map(c => ({ label: String(typeof c === 'object' && c && 'label' in c ? (c as Record).label : c), description: '', })) : []; return { questions: [{ question, header: '', options, multiSelect: false, }], }; } function normalizeTodoWriteInput(raw: RawToolArgs): TodoWriteInput { const todos = raw.todos ?? raw.items ?? raw.list; return { todos: Array.isArray(todos) ? (todos as TodoWriteInput['todos']) : [] }; } function normalizeToolSearchInput(raw: RawToolArgs): ToolSearchInput { const result: ToolSearchInput = { query: str(raw, 'query', 'pattern', 'regex') }; const maxResults = optNum(raw, 'max_results', 'maxResults', 'limit'); if (maxResults != null) result.maxResults = maxResults; return result; } /** * Copilot's `task` tool dispatches a named sub-agent. Its arguments look like: * { name, agent_type, description, prompt, mode } * Map those to the unified `AgentInput` so the Agent tool component renders * the same way as Claude's `Task` and OpenCode's `task`. */ function normalizeAgentInput(raw: RawToolArgs): AgentInput { return { prompt: str(raw, 'prompt', 'task', 'instruction'), description: optStr(raw, 'description', 'desc') ?? '', subagentType: optStr(raw, 'agent_type', 'agentType', 'subagent_type', 'subagentType', 'agent', 'name') ?? 'general-purpose', }; } function normalizeCoordinationInput(raw: RawToolArgs): Record { const result: Record = {}; for (const [key, value] of Object.entries(raw)) { result[key.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase())] = value; } return result; } /** * Normalize raw Copilot tool arguments → canonical input shape. Unknown/MCP * tools fall through with their args unchanged. */ function normalizeCopilotToolInput(canonical: string, raw: RawToolArgs): Record { if (canonical.startsWith('mcp__') || canonical.startsWith('Unknown:')) return raw; switch (canonical) { case 'Bash': return normalizeBashInput(raw) as unknown as Record; case 'Read': return normalizeReadInput(raw) as unknown as Record; case 'Write': return normalizeWriteInput(raw) as unknown as Record; case 'Edit': return normalizeEditInput(raw) as unknown as Record; case 'Grep': return normalizeGrepInput(raw) as unknown as Record; case 'Glob': return normalizeGlobInput(raw) as unknown as Record; case 'List': return normalizeListInput(raw) as unknown as Record; case 'WebSearch': return normalizeWebSearchInput(raw) as unknown as Record; case 'WebFetch': return normalizeWebFetchInput(raw) as unknown as Record; case 'AskUserQuestion': return normalizeAskUserQuestionInput(raw) as unknown as Record; case 'TodoWrite': return normalizeTodoWriteInput(raw) as unknown as Record; case 'ToolSearch': return normalizeToolSearchInput(raw) as unknown as Record; case 'Agent': return normalizeAgentInput(raw) as unknown as Record; case 'SendMessage': case 'ListAgents': case 'ReadAgent': case 'SendInbox': case 'ContextBoard': return normalizeCoordinationInput(raw); default: return raw; } } // ============================================================================ // Stream State (per-stream, isolated across concurrent sessions) // ============================================================================ export interface StreamConverterState { sessionId: string; modelId: string; /** Reasoning text accumulated from reasoning_delta events, keyed by reasoningId. */ reasoningBuffers: Map; /** ToolCallId → tool name (canonicalised) so tool_result can reference it. */ toolNames: Map; /** Whether the assistant text stream is currently open (for lifecycle pairing). */ textStreamActive: boolean; /** Whether the assistant reasoning stream is currently open. */ reasoningStreamActive: boolean; /** Last seen usage payload — held until session.idle to build the ResultEvent. */ lastUsage: AssistantUsageData | null; /** Most recent assistant.message ID (used for stable parenting if needed). */ lastAssistantMessageId: string | null; /** * Assistant messages awaiting flush. We buffer because `assistant.usage` * arrives AFTER `assistant.message` for the same turn iteration — we want * to attach usage to the originating message before emitting it downstream. */ pendingMessages: UnifiedAssistantMessage[]; /** * Tool call IDs whose tool_use was filtered out (Copilot harness tools). * `tool.execution_complete` for these IDs must also be dropped so the UI * never receives an orphan tool_result without its tool_use. */ ignoredToolCallIds: Set; /** * Sub-agent instance id → tool call id of the parent `task` (Agent) tool. * * Populated on `subagent.started`. Every event a sub-agent emits carries * `event.agentId`; we use this map to resolve the originating Agent * tool_use id and stamp `parent.toolUseId` on the sub-agent's * assistant/user messages. Without this, the message grouper at * `frontend/utils/chat/message-grouper.ts` cannot route sub-agent activity * into the parent Agent block — sub-agent tool calls render as orphan * top-level messages. */ agentParentMap: Map; /** * AskUserQuestion tool call id → original question text. Captured when * the `ask_user` tool_use block is built so that, on * `tool.execution_complete`, we can reformat Copilot's plain-text * "User selected: X" payload into the JSON shape * (`{"answers": {: }}`) the UI's parser expects (see * `frontend/components/chat/tools/variants/.../AskUserQuestionTool.svelte`). * Without this, the UI fails to parse the answer and renders the * AskUserQuestion bubble as a red error instead of the green answered * state. */ askUserQuestions: Map; } export function createStreamConverterState(sessionId: string, modelId: string): StreamConverterState { return { sessionId, modelId, reasoningBuffers: new Map(), toolNames: new Map(), textStreamActive: false, reasoningStreamActive: false, lastUsage: null, lastAssistantMessageId: null, pendingMessages: [], ignoredToolCallIds: new Set(), agentParentMap: new Map(), askUserQuestions: new Map(), }; } /** * Resolve the parent Agent tool call id for an event emitted from inside a * sub-agent. Prefers the (deprecated but still emitted) `data.parentToolCallId` * direct hint; falls back to `event.agentId` looked up in `agentParentMap`. * Returns `null` for events from the root agent or session-level events. */ export function resolveParentToolUseId( event: SessionEvent, state: StreamConverterState, ): string | null { const data = (event as { data?: { parentToolCallId?: string } }).data; const direct = data?.parentToolCallId; if (direct) return direct; const agentId = (event as { agentId?: string }).agentId; if (agentId) { const mapped = state.agentParentMap.get(agentId); if (mapped) return mapped; } return null; } /** * `subagent.started` registers a sub-agent instance. Every subsequent event * with the same `agentId` belongs to this sub-agent, and its `toolCallId` is * the parent Agent (`task`) tool block id. */ export function convertSubagentStarted( event: { agentId?: string; data: SubagentStartedData }, state: StreamConverterState, ): EngineOutput[] { if (event.agentId) { state.agentParentMap.set(event.agentId, event.data.toolCallId); } return []; } /** * Cleanup mapping when a sub-agent finishes. Late-arriving events still resolve * via `data.parentToolCallId`, so dropping the entry is safe. */ export function convertSubagentEnded( event: { agentId?: string; data: SubagentCompletedData | SubagentFailedData }, state: StreamConverterState, ): EngineOutput[] { if (event.agentId) state.agentParentMap.delete(event.agentId); return []; } /** * Drain `pendingMessages` and return them as EngineOutput. Call before any * downstream-visible event (tool execution, turn end, session idle) so that * messages are emitted in source order even if usage attaches late. * * Fallback: if a message is being emitted with no usage attached (i.e. * `assistant.usage` for its LLM call hasn't arrived yet), backfill from the * most recent known usage. This keeps the chat header's context-window * indicator non-zero in turns where the SDK emits usage AFTER the assistant * message has already been forced out (e.g. by a downstream tool event). */ export function flushPending(state: StreamConverterState): EngineOutput[] { if (state.pendingMessages.length === 0) return []; const fallback = state.lastUsage ? mapUsage(state.lastUsage) : null; if (fallback) { for (const msg of state.pendingMessages) { if (msg.usage == null) msg.usage = fallback; } } const out: EngineOutput[] = state.pendingMessages.slice(); state.pendingMessages = []; return out; } // ============================================================================ // System / Lifecycle Converters // ============================================================================ export function convertSessionStart(data: StartData, modelId: string): SystemInitEvent { return { type: 'system_init', sessionId: data.sessionId, model: data.selectedModel || modelId || '', engine: 'copilot', tools: [], mcpServers: [], }; } export function convertTurnStart(state: StreamConverterState): StreamLifecycleEvent { state.textStreamActive = true; return { type: 'stream_event', event: 'start', sessionId: state.sessionId, reasoning: false, }; } export function convertTurnEnd(_data: AssistantTurnEndData, state: StreamConverterState): EngineOutput[] { const events: EngineOutput[] = flushPending(state); if (state.reasoningStreamActive) { events.push({ type: 'stream_event', event: 'stop', sessionId: state.sessionId, reasoning: true }); state.reasoningStreamActive = false; } if (state.textStreamActive) { events.push({ type: 'stream_event', event: 'stop', sessionId: state.sessionId, reasoning: false }); state.textStreamActive = false; } return events; } // ============================================================================ // Reasoning Converters // ============================================================================ export function convertReasoningDelta(data: AssistantReasoningDeltaData, state: StreamConverterState): EngineOutput[] { const out: EngineOutput[] = []; if (!state.reasoningStreamActive) { // Pause text stream while reasoning streams. if (state.textStreamActive) { out.push({ type: 'stream_event', event: 'stop', sessionId: state.sessionId, reasoning: false } as StreamLifecycleEvent); state.textStreamActive = false; } out.push({ type: 'stream_event', event: 'start', sessionId: state.sessionId, reasoning: true } as StreamLifecycleEvent); state.reasoningStreamActive = true; } const prev = state.reasoningBuffers.get(data.reasoningId) ?? ''; state.reasoningBuffers.set(data.reasoningId, prev + (data.deltaContent ?? '')); const delta: TextDeltaEvent = { type: 'stream_event', event: 'delta', sessionId: state.sessionId, text: data.deltaContent ?? '', reasoning: true, }; out.push(delta); return out; } export function convertReasoning( data: AssistantReasoningData, state: StreamConverterState, parentToolUseId: string | null = null, ): EngineOutput[] { const out: EngineOutput[] = []; const text = (data.content ?? state.reasoningBuffers.get(data.reasoningId) ?? '').trim(); state.reasoningBuffers.delete(data.reasoningId); if (state.reasoningStreamActive) { out.push({ type: 'stream_event', event: 'stop', sessionId: state.sessionId, reasoning: true } as StreamLifecycleEvent); state.reasoningStreamActive = false; } if (!text) return out; const reasoning: ReasoningMessage = { type: 'reasoning', createdAt: new Date().toISOString(), messageId: crypto.randomUUID(), sessionId: state.sessionId, parent: { messageId: null, sessionId: null, toolUseId: parentToolUseId }, engine: buildEngine(state.modelId), text, }; out.push(reasoning); return out; } // ============================================================================ // Assistant Message Converters // ============================================================================ export function convertMessageDelta(data: AssistantMessageDeltaData, state: StreamConverterState): EngineOutput[] { const out: EngineOutput[] = []; if (state.reasoningStreamActive) { out.push({ type: 'stream_event', event: 'stop', sessionId: state.sessionId, reasoning: true } as StreamLifecycleEvent); state.reasoningStreamActive = false; } if (!state.textStreamActive) { out.push({ type: 'stream_event', event: 'start', sessionId: state.sessionId, reasoning: false } as StreamLifecycleEvent); state.textStreamActive = true; } const delta: TextDeltaEvent = { type: 'stream_event', event: 'delta', sessionId: state.sessionId, text: data.deltaContent ?? '', reasoning: false, }; out.push(delta); return out; } /** * Convert a final assistant.message event into one or more assistant messages, * one per logical block (text + each tool_use), matching the convention used * by the Claude and OpenCode adapters where every assistant message in the * chat surface holds AT MOST one tool_use block. * * Internal Copilot harness tools (see IGNORED_COPILOT_TOOLS) are dropped here * so they never reach the UI as `Unknown:*` blocks. * * Returns previously-buffered messages and buffers the new ones — the actual * emission happens lazily so `assistant.usage` (which arrives next in the * stream) can attach to the most recent message before it leaves the adapter. */ export function convertAssistantMessage( data: AssistantMessageData, state: StreamConverterState, parentToolUseId: string | null = null, ): EngineOutput[] { state.lastAssistantMessageId = data.messageId; const flushed = flushPending(state); const visibleToolRequests: AssistantMessageToolRequest[] = []; for (const req of data.toolRequests ?? []) { const canonical = mapCopilotToolName(req.name, req.mcpServerName); state.toolNames.set(req.toolCallId, canonical); if (isIgnoredCopilotTool(req.name, req.mcpServerName)) { state.ignoredToolCallIds.add(req.toolCallId); } else { visibleToolRequests.push(req); } } const blocks: AssistantContentBlock[] = []; if (data.content && data.content.length > 0) { blocks.push({ type: 'text', text: data.content }); } for (const req of visibleToolRequests) { blocks.push(buildToolUseBlock(req, state)); } if (blocks.length === 0) { return flushed; } const baseId = data.messageId || crypto.randomUUID(); const messages: UnifiedAssistantMessage[] = blocks.map((block, idx) => ({ type: 'assistant', createdAt: new Date().toISOString(), messageId: blocks.length === 1 ? baseId : `${baseId}:${idx}`, sessionId: state.sessionId, parent: { messageId: null, sessionId: null, toolUseId: parentToolUseId }, engine: buildEngine(state.modelId), content: [block], stopReason: block.type === 'tool_use' ? 'tool_use' : 'end_turn', usage: null, })); state.pendingMessages.push(...messages); return flushed; } function buildToolUseBlock(req: AssistantMessageToolRequest, state: StreamConverterState): ToolUseBlock { const canonical = mapCopilotToolName(req.name, req.mcpServerName); const input = normalizeCopilotToolInput(canonical, (req.arguments ?? {}) as RawToolArgs); // Remember the question text per ask_user toolCallId so we can rewrite // `tool.execution_complete` content into the JSON shape the UI expects. if (canonical === 'AskUserQuestion') { const askInput = input as unknown as AskUserQuestionInput; const firstQuestion = askInput.questions?.[0]?.question; if (firstQuestion) state.askUserQuestions.set(req.toolCallId, firstQuestion); } return { type: 'tool_use', id: req.toolCallId, name: canonical, input, result: null, subActivities: [], skillPrompt: null, interrupted: false, } as ToolUseBlock; } // ============================================================================ // Tool Execution Converters // ============================================================================ /** * tool.execution_start arrives WHEN the assistant begins executing a tool. The * Copilot SDK already emitted the corresponding tool_use block in the prior * assistant.message — so we don't yield anything new here. We just remember * the tool name so a later tool_result can carry the canonical name. */ export function convertToolStart(data: ToolExecutionStartData, state: StreamConverterState): EngineOutput[] { const canonical = mapCopilotToolName(data.toolName, data.mcpServerName); state.toolNames.set(data.toolCallId, canonical); if (isIgnoredCopilotTool(data.toolName, data.mcpServerName)) { state.ignoredToolCallIds.add(data.toolCallId); } return []; } /** * tool.execution_complete carries the LLM-facing result content. We emit it as * a synthetic UserMessage with a single tool_result block, mirroring how Claude's * SDK delivers the same information back to the loop. * * Results for ignored Copilot harness tools (report_intent, task_complete, …) * are dropped so the UI never sees an orphan tool_result without its tool_use. * * IMPORTANT: `parent.toolUseId` MUST be left null. The frontend grouper * (frontend/utils/chat/message-grouper.ts) treats any user message whose * parent.toolUseId is non-null as a sub-agent message — i.e. one emitted from * inside an Agent (Task) tool execution — and either redirects it into the * sub-agent activity bucket or silently drops it. The toolCallId belongs on * the inner `tool_result` block, not on the message-level parent pointer. * Setting it on `parent.toolUseId` was a bug that caused tool_results to * never reach the UI, leaving `tool_use.result` null at render time. */ export function convertToolComplete( data: ToolExecutionCompleteData, state: StreamConverterState, parentToolUseId: string | null = null, ): EngineOutput[] { if (state.ignoredToolCallIds.has(data.toolCallId)) { state.ignoredToolCallIds.delete(data.toolCallId); state.toolNames.delete(data.toolCallId); return []; } let resultText = extractToolResultContent(data); // AskUserQuestion: rewrite Copilot's plain-text result // (e.g. "User selected: X") into the `{"answers": {: }}` JSON // shape the UI's parseResultAnswers expects. Without this rewrite, the // parser finds no matching question key and renders the bubble in the // red error state even though the user answered successfully. if (state.toolNames.get(data.toolCallId) === 'AskUserQuestion') { const question = state.askUserQuestions.get(data.toolCallId); state.askUserQuestions.delete(data.toolCallId); if (question) { const answer = extractAskUserAnswer(resultText); resultText = JSON.stringify({ answers: { [question]: answer } }); } } const userBlock: UserContentBlock = { type: 'tool_result', toolUseId: data.toolCallId, content: resultText, isError: !data.success || !!data.error, }; const message: UserMessage = { type: 'user', createdAt: new Date().toISOString(), messageId: crypto.randomUUID(), sessionId: state.sessionId, parent: { messageId: null, sessionId: null, toolUseId: parentToolUseId }, engine: buildEngine(state.modelId), sender: { id: '', name: '' }, content: [userBlock], synthetic: true, }; return [message]; } /** * Pull the user's answer out of Copilot's `ask_user` result text. The * Copilot CLI formats the result as "User selected: " for the * agent's own context, so we strip that prefix to recover the raw answer * before re-emitting it as JSON for the UI. */ function extractAskUserAnswer(resultText: string): string { const trimmed = resultText.trim(); const prefix = 'User selected:'; if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) { return trimmed.slice(prefix.length).trim(); } return trimmed; } /** * Extract the human-readable content for a tool_result from a Copilot * `tool.execution_complete` payload. Prefers `detailedContent` (the diff / * full output the model receives), then `content` (short summary), then any * structured `contents` blocks, and finally falls back to the error message * for failed tool calls. */ function extractToolResultContent(data: ToolExecutionCompleteData): string { const result = data.result as | { detailedContent?: string; content?: string; contents?: unknown } | undefined; if (typeof result?.detailedContent === 'string' && result.detailedContent.length > 0) { return result.detailedContent; } if (typeof result?.content === 'string' && result.content.length > 0) { return result.content; } if (Array.isArray(result?.contents)) { const parts: string[] = []; for (const item of result.contents) { if (typeof item === 'string') { parts.push(item); } else if (item && typeof item === 'object') { const obj = item as Record; if (typeof obj.text === 'string') parts.push(obj.text); else if (typeof obj.content === 'string') parts.push(obj.content); } } const joined = parts.join('\n').trim(); if (joined) return joined; } if (data.error?.message) return `Error: ${data.error.message}`; if (!data.success) return 'Tool execution failed'; return ''; } // ============================================================================ // Usage / Idle Converters // ============================================================================ /** * `assistant.usage` arrives once per LLM call, AFTER the corresponding * `assistant.message` (and any tool_use blocks split out of it) but BEFORE * tool execution events. We attach the usage to the most recent pending * message so the persisted row carries token counts, then flush. * * The aggregate usage is also retained on `state.lastUsage` for the final * `result` event built at session.idle. */ export function captureUsage(data: AssistantUsageData, state: StreamConverterState): EngineOutput[] { state.lastUsage = data; const pending = state.pendingMessages; if (pending.length > 0) { const target = pending[pending.length - 1]!; target.usage = mapUsage(data); } return flushPending(state); } export function buildResultEvent(state: StreamConverterState, aborted: boolean): SuccessResultEvent { const usage = mapUsage(state.lastUsage); return { type: 'result', subtype: 'success', sessionId: state.sessionId, numTurns: 0, totalCostUsd: state.lastUsage?.cost ?? 0, usage, stopReason: aborted ? 'interrupted' : 'end_turn', }; } function mapUsage(raw: AssistantUsageData | null): TokenUsage { if (!raw) { return { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; } return { inputTokens: raw.inputTokens ?? 0, outputTokens: raw.outputTokens ?? 0, cacheCreationInputTokens: raw.cacheWriteTokens ?? 0, cacheReadInputTokens: raw.cacheReadTokens ?? 0, }; }