/** * Open Code → EngineOutput Converter * * Converts Open Code SDK messages and parts into EngineOutput (unified types) * with type-safe tool input normalization. * * Content blocks are split into separate messages: * - Consecutive text blocks → one message * - Each tool_use block → its own message */ import { resolveOpenCodeToolName } from '../../../mcp'; import { toCanonicalToolName } from '$shared/types/unified'; import type { Message as OCMessage, Part, ToolPart, AssistantMessage } from '@opencode-ai/sdk'; import type { EngineOutput, UserMessage, AssistantMessage as UnifiedAssistantMessage, ReasoningMessage, TextDeltaEvent, StreamLifecycleEvent, SuccessResultEvent, ErrorResultEvent, SystemInitEvent, TextBlock, ToolUseBlock, ToolResult, AssistantContentBlock, UserContentBlock, TokenUsage, StopReason, BashInput, ReadInput, EditInput, WriteInput, GlobInput, GrepInput, WebFetchInput, WebSearchInput, AskUserQuestionInput, ListMcpResourcesInput, ReadMcpResourceInput, TodoWriteInput, PatchInput, AgentInput, } from '$shared/types/unified'; // ============================================================ // Types // ============================================================ /** Raw tool input from OpenCode SDK (ToolPart.state.input) */ type OCToolInput = ToolPart['state']['input']; /** * Resolve tool input from ToolPart. * Pending tools may have empty input ({}) while data is in state.raw. * This ensures we always get the actual input even during progressive rendering. */ export function getToolInput(toolPart: ToolPart): OCToolInput { const input = toolPart.state.input; // If input already has data, use it directly if (Object.keys(input).length > 0) return input; // Pending state has a raw JSON string — try parsing it if (toolPart.state.status === 'pending') { const raw = toolPart.state.raw; if (raw) { try { const parsed = JSON.parse(raw); if (typeof parsed === 'object' && parsed !== null) { return parsed as OCToolInput; } } catch { // Incomplete JSON during streaming — will get full input in next update } } } return input; } /** * Normalized tool input types (unified camelCase format). */ type NormalizedToolInput = | BashInput | ReadInput | EditInput | WriteInput | GlobInput | GrepInput | WebFetchInput | WebSearchInput | AskUserQuestionInput | TodoWriteInput | ListMcpResourcesInput | ReadMcpResourceInput | PatchInput | AgentInput | Record; // ============================================================ // Tool Name Mapping // ============================================================ /** * OpenCode tool names → Claude Code tool names (for UI rendering) * * Only maps tools that exist in OpenCode SDK: * bash, read, edit, write, glob, grep, webfetch, websearch, * question, todowrite, todoread, patch, list, skill, lsp * * @see https://opencode.ai/docs/tools */ export const TOOL_NAME_MAP: Record = { // File operations 'bash': 'Bash', 'view': 'Read', 'read': 'Read', 'write': 'Write', 'edit': 'Edit', 'patch': 'Patch', // OpenAI GPT-5 family emits `apply_patch` with a V4A-format `patch_text` // payload. Normalize to canonical `Patch` so PatchTool.svelte renders it. 'apply_patch': 'Patch', // Search & discovery 'glob': 'Glob', 'grep': 'Grep', 'list': 'List', // Web 'fetch': 'WebFetch', 'web_fetch': 'WebFetch', 'webfetch': 'WebFetch', 'web_search': 'WebSearch', 'websearch': 'WebSearch', // Task management 'todo_write': 'TodoWrite', 'todowrite': 'TodoWrite', 'todoread': 'TodoWrite', // Agent / sub-agent 'task': 'Agent', // User interaction 'question': 'AskUserQuestion', // Code intelligence & utilities 'skill': 'Skill', 'lsp': 'Lsp', // MCP (custom servers) 'list_mcp_resources': 'ListMcpResources', 'read_mcp_resource': 'ReadMcpResource', }; /** Map Open Code tool name to canonical UI tool name. */ export function mapToolName(openCodeToolName: string): string { // Check if this is a custom MCP tool (resolves via single source in backend/mcp) const mcpName = resolveOpenCodeToolName(openCodeToolName); if (mcpName) return mcpName; const lower = openCodeToolName.toLowerCase(); const mapped = TOOL_NAME_MAP[lower] || TOOL_NAME_MAP[openCodeToolName] || openCodeToolName; return toCanonicalToolName(mapped); } // ============================================================ // Tool Input Normalizer Helpers // ============================================================ /** Get string value, checking both snake_case and camelCase keys */ function str(raw: OCToolInput, snakeCase: string, camelCase: string, fallback = ''): string { const val = raw[snakeCase] ?? raw[camelCase]; return val != null ? String(val) : fallback; } /** Get optional number from either key variant */ function optNum(raw: OCToolInput, snakeCase: string, camelCase: string): number | undefined { const val = raw[snakeCase] ?? raw[camelCase]; return val != null ? Number(val) : undefined; } /** Get optional boolean from either key variant */ function optBool(raw: OCToolInput, snakeCase: string, camelCase: string): boolean | undefined { const val = raw[snakeCase] ?? raw[camelCase]; return val != null ? Boolean(val) : undefined; } /** Get optional string from either key variant */ function optStr(raw: OCToolInput, snakeCase: string, camelCase: string): string | undefined { const val = raw[snakeCase] ?? raw[camelCase]; return val != null ? String(val) : undefined; } /** Convert camelCase string to snake_case */ function camelToSnake(s: string): string { return s.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); } // ============================================================ // Per-Tool Input Normalizers // ============================================================ function normalizeReadInput(raw: OCToolInput): ReadInput { const result: ReadInput = { filePath: str(raw, 'file_path', 'filePath'), }; const offset = optNum(raw, 'offset', 'offset'); if (offset != null) result.offset = offset; const limit = optNum(raw, 'limit', 'limit'); if (limit != null) result.limit = limit; return result; } function normalizeEditInput(raw: OCToolInput): EditInput { const result: EditInput = { filePath: str(raw, 'file_path', 'filePath'), oldString: str(raw, 'old_string', 'oldString'), newString: str(raw, 'new_string', 'newString'), }; const replaceAll = optBool(raw, 'replace_all', 'replaceAll'); if (replaceAll != null) result.replaceAll = replaceAll; return result; } function normalizeWriteInput(raw: OCToolInput): WriteInput { return { filePath: str(raw, 'file_path', 'filePath'), content: str(raw, 'content', 'content'), }; } function normalizeBashInput(raw: OCToolInput): BashInput { const result: BashInput = { command: str(raw, 'command', 'command'), }; const timeout = optNum(raw, 'timeout', 'timeout'); if (timeout != null) result.timeout = timeout; const description = optStr(raw, 'description', 'description'); if (description != null) result.description = description; const runInBackground = optBool(raw, 'run_in_background', 'runInBackground'); if (runInBackground != null) result.runInBackground = runInBackground; return result; } function normalizeGlobInput(raw: OCToolInput): GlobInput { const result: GlobInput = { pattern: str(raw, 'pattern', 'pattern'), }; const path = optStr(raw, 'path', 'path'); if (path != null) result.path = path; return result; } function normalizeGrepInput(raw: OCToolInput): GrepInput { const result: GrepInput = { pattern: str(raw, 'pattern', 'pattern'), }; const path = optStr(raw, 'path', 'path'); if (path != null) result.path = path; // OpenCode uses 'include' for file filter const glob = optStr(raw, 'glob', 'glob') ?? optStr(raw, 'include', 'include'); 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', 'type'); if (type != null) result.type = type; const headLimit = optNum(raw, 'head_limit', 'headLimit'); if (headLimit != null) result.headLimit = headLimit; const offset = optNum(raw, 'offset', 'offset'); if (offset != null) result.offset = offset; const multiline = optBool(raw, 'multiline', 'multiline'); if (multiline != null) result.multiline = multiline; const caseInsensitive = optBool(raw, '-i', 'caseInsensitive'); if (caseInsensitive != null) result.caseInsensitive = caseInsensitive; const beforeContext = optNum(raw, '-B', 'beforeContext'); if (beforeContext != null) result.beforeContext = beforeContext; const afterContext = optNum(raw, '-A', 'afterContext'); if (afterContext != null) result.afterContext = afterContext; const context = optNum(raw, '-C', 'context'); if (context != null) result.context = context; const lineNumbers = optBool(raw, '-n', 'lineNumbers'); if (lineNumbers != null) result.lineNumbers = lineNumbers; return result; } function normalizeWebFetchInput(raw: OCToolInput): WebFetchInput { return { url: str(raw, 'url', 'url'), // OpenCode uses 'format', unified uses 'prompt' prompt: str(raw, 'prompt', 'prompt') || str(raw, 'format', 'format'), }; } function normalizeWebSearchInput(raw: OCToolInput): WebSearchInput { const result: WebSearchInput = { query: str(raw, 'query', 'query'), }; const allowedDomains = raw.allowed_domains ?? raw.allowedDomains; if (Array.isArray(allowedDomains) && allowedDomains.length) { result.allowedDomains = allowedDomains as string[]; } const blockedDomains = raw.blocked_domains ?? raw.blockedDomains; if (Array.isArray(blockedDomains) && blockedDomains.length) { result.blockedDomains = blockedDomains as string[]; } return result; } function normalizeAskUserQuestionInput(raw: OCToolInput): AskUserQuestionInput { return { questions: (raw.questions ?? []) as AskUserQuestionInput['questions'], }; } function normalizeListMcpResourcesInput(raw: OCToolInput): ListMcpResourcesInput { const result: ListMcpResourcesInput = {}; const server = optStr(raw, 'server', 'server'); if (server != null) result.server = server; return result; } function normalizeReadMcpResourceInput(raw: OCToolInput): ReadMcpResourceInput { return { server: str(raw, 'server', 'server'), uri: str(raw, 'uri', 'uri'), }; } function normalizeTodoWriteInput(raw: OCToolInput): TodoWriteInput { return { todos: (raw.todos ?? []) as TodoWriteInput['todos'], }; } /** * Handle both OpenCode's `patch` tool (`{ file_path, patch }`) and OpenAI * GPT-5's `apply_patch` (`{ patch_text }`). For apply_patch we extract the * first file path from the V4A header so the Patch tool header has something * meaningful; the full patch body still shows every file. */ function normalizePatchInput(raw: OCToolInput): PatchInput { const patchText = str(raw, 'patch_text', 'patchText'); const patchBody = patchText || str(raw, 'patch', 'patch'); const explicitPath = str(raw, 'file_path', 'filePath'); let filePath = explicitPath; if (!filePath && patchBody) { const match = patchBody.match(/^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/m); if (match) filePath = match[1].trim(); } return { filePath, patch: patchBody }; } /** * OpenCode's `task` tool dispatches a named sub-agent. Some providers (e.g. * GLM/z.ai via opencode) emit already-snake_case args like `subagent_type`. * Map those to the unified `AgentInput` so the Agent tool component renders * consistently with Claude's `Task` and Copilot's `task`. */ function normalizeAgentInput(raw: OCToolInput): AgentInput { return { prompt: str(raw, 'prompt', 'prompt') || str(raw, 'task', 'task') || str(raw, 'instruction', 'instruction'), description: str(raw, 'description', 'description'), subagentType: optStr(raw, 'subagent_type', 'subagentType') ?? optStr(raw, 'agent_type', 'agentType') ?? optStr(raw, 'agent', 'agent') ?? optStr(raw, 'name', 'name') ?? 'general-purpose', }; } // ============================================================ // Normalizer Dispatcher // ============================================================ /** * Normalize OpenCode tool input → Claude Code tool input format. * Handles camelCase → snake_case conversion and field name differences. */ function normalizeToolInput(claudeToolName: string, raw: OCToolInput): NormalizedToolInput { // Custom MCP tools (mcp__*) — pass input through as-is if (claudeToolName.startsWith('mcp__')) { return raw as NormalizedToolInput; } switch (claudeToolName) { case 'Read': return normalizeReadInput(raw); case 'Edit': return normalizeEditInput(raw); case 'Write': return normalizeWriteInput(raw); case 'Bash': return normalizeBashInput(raw); case 'Glob': return normalizeGlobInput(raw); case 'Grep': return normalizeGrepInput(raw); case 'WebFetch': return normalizeWebFetchInput(raw); case 'WebSearch': return normalizeWebSearchInput(raw); case 'AskUserQuestion': return normalizeAskUserQuestionInput(raw); case 'ListMcpResources': return normalizeListMcpResourcesInput(raw); case 'ReadMcpResource': return normalizeReadMcpResourceInput(raw); case 'TodoWrite': return normalizeTodoWriteInput(raw); case 'Patch': return normalizePatchInput(raw); case 'Agent': return normalizeAgentInput(raw); default: { // Unknown tool: generic camelCase → snake_case key normalization const normalized: Record = {}; for (const [key, value] of Object.entries(raw)) { normalized[camelToSnake(key)] = value as string | number | boolean; } return normalized as NormalizedToolInput; } } } // ============================================================ // Tool Error Detection // ============================================================ /** * Common error prefixes in tool output content. * OpenCode SDK may mark a tool as 'completed' even when the output is an error * (e.g. "Error: File not found"). These patterns detect such cases. */ const ERROR_CONTENT_PATTERNS = [ /^Error:\s/i, /^ENOENT:\s/i, /^EPERM:\s/i, /^EACCES:\s/i, /^Command failed/i, /^Permission denied/i, ]; /** * Determine if a tool result should be marked as is_error. * Returns true when the tool part status is 'error', OR when the output * content matches a known error pattern (for tools that complete with error output). */ function isToolError(status: string, content: string): boolean { if (status === 'error') return true; if (!content || status !== 'completed') return false; return ERROR_CONTENT_PATTERNS.some(pattern => pattern.test(content)); } // ============================================================ // Stop Reason Mapping // ============================================================ /** Map OpenCode finish reason → unified StopReason */ function mapStopReason(finish: string | undefined): StopReason | null { switch (finish) { case 'tool-calls': return 'tool_use'; case 'stop': return 'end_turn'; case 'length': return 'max_tokens'; default: return finish ? 'end_turn' : null; } } /** Map OpenCode tokens → unified TokenUsage */ function mapUsage(tokens: { input?: number; output?: number; cache?: { write?: number; read?: number } } | undefined): TokenUsage | null { if (!tokens) return null; return { inputTokens: tokens.input || 0, outputTokens: tokens.output || 0, cacheCreationInputTokens: tokens.cache?.write || 0, cacheReadInputTokens: tokens.cache?.read || 0, }; } // ============================================================ // Public Converters // ============================================================ /** * Convert Open Code user message → UserMessage (unified) */ export function convertUserMessage( ocMessage: OCMessage, ocParts: Part[], sessionId: string ): UserMessage { const content: UserContentBlock[] = []; for (const part of ocParts) { if (part.type === 'text') { content.push({ type: 'text', text: part.text || '' }); } } if (content.length === 0) { content.push({ type: 'text', text: '' }); } return { type: 'user', createdAt: new Date().toISOString(), messageId: ocMessage.id || crypto.randomUUID(), sessionId, parent: { messageId: null, sessionId: null, toolUseId: null }, engine: { type: 'opencode' as const, provider: '', model: { id: '', name: '' }, account: { id: 0, name: '' } }, sender: { id: '', name: '' }, content, synthetic: false, }; } /** * Convert Open Code assistant message + parts → EngineOutput[] * * Follows the Claude Code pattern: every tool_use block is emitted with * `result: null`, and completed/errored tools emit a separate UserMessage * with the tool_result so the frontend has a single stitching path. * * Splits content into separate messages: * - Consecutive text blocks → one assistant message * - Each tool_use block → its own assistant message (+ optional tool_result user message) */ export function convertAssistantMessages( ocMessage: OCMessage, ocParts: Part[], sessionId: string ): EngineOutput[] { // 1. Parse parts into (block, optional trailing tool_result) entries. type Entry = | { kind: 'text'; block: TextBlock } | { kind: 'tool'; block: ToolUseBlock; result: ToolResult | null }; const entries: Entry[] = []; for (const part of ocParts) { if (part.type === 'text') { entries.push({ kind: 'text', block: { type: 'text', text: part.text || '' } as TextBlock }); } else if (part.type === 'tool') { const toolPart = part as ToolPart; const claudeName = mapToolName(toolPart.tool || 'unknown'); const resolvedInput = getToolInput(toolPart); const normalizedInput = normalizeToolInput(claudeName, resolvedInput); const toolUseId = toolPart.callID || toolPart.id || crypto.randomUUID(); let result: ToolResult | null = null; if (toolPart.state.status === 'completed') { const output = toolPart.state.output || ''; result = { type: 'tool_result', toolUseId, content: output, isError: isToolError('completed', output), }; } else if (toolPart.state.status === 'error') { result = { type: 'tool_result', toolUseId, content: toolPart.state.error || 'Tool execution failed', isError: true, }; } entries.push({ kind: 'tool', block: { type: 'tool_use', id: toolUseId, name: claudeName, input: normalizedInput, result: null, subActivities: [], skillPrompt: null, interrupted: false, } as ToolUseBlock, result, }); } else if (part.type === 'subtask') { const subtaskPart = part as { id?: string; prompt?: string; description?: string; agent?: string }; entries.push({ kind: 'tool', block: { type: 'tool_use', id: subtaskPart.id || crypto.randomUUID(), name: 'Agent', input: { prompt: subtaskPart.prompt || '', description: subtaskPart.description || '', subagentType: subtaskPart.agent || 'general-purpose', }, result: null, subActivities: [], skillPrompt: null, interrupted: false, } as ToolUseBlock, result: null, }); } // Skip: reasoning, step-start, step-finish, snapshot, patch, agent, retry, compaction } // 2. Collapse consecutive text entries into groups; each tool entry is its own group. type Group = | { kind: 'text'; blocks: TextBlock[] } | { kind: 'tool'; entry: Extract }; const groups: Group[] = []; let textBuf: TextBlock[] = []; for (const entry of entries) { if (entry.kind === 'text') { textBuf.push(entry.block); } else { if (textBuf.length > 0) { groups.push({ kind: 'text', blocks: textBuf }); textBuf = []; } groups.push({ kind: 'tool', entry }); } } if (textBuf.length > 0) groups.push({ kind: 'text', blocks: textBuf }); if (groups.length === 0) groups.push({ kind: 'text', blocks: [{ type: 'text', text: '' } as TextBlock] }); // 3. Build unified messages from groups. const assistantMsg = ocMessage.role === 'assistant' ? ocMessage as AssistantMessage : null; const providerID = assistantMsg?.providerID || ''; const modelID = assistantMsg?.modelID || ''; const mappedStop = mapStopReason(assistantMsg?.finish); const usage = mapUsage(assistantMsg?.tokens); const messages: EngineOutput[] = []; const baseId = ocMessage.id || crypto.randomUUID(); for (let i = 0; i < groups.length; i++) { const isLast = i === groups.length - 1; const group = groups[i]; const hasToolUse = group.kind === 'tool'; let stopReason: StopReason | null; if (isLast) { stopReason = mappedStop; } else if (hasToolUse) { stopReason = 'tool_use'; } else { stopReason = null; } const content: AssistantContentBlock[] = group.kind === 'text' ? group.blocks : [group.entry.block]; const msg: UnifiedAssistantMessage = { type: 'assistant', createdAt: new Date().toISOString(), messageId: i === 0 ? baseId : crypto.randomUUID(), sessionId, parent: { messageId: null, sessionId: null, toolUseId: null }, engine: { type: 'opencode' as const, provider: providerID, model: { id: modelID, name: '' }, account: { id: 0, name: '' } }, content, stopReason, usage: isLast ? usage : null, }; messages.push(msg); // For completed/errored tools, emit a paired tool_result user message — // matches the Claude Code adapter's contract. if (group.kind === 'tool' && group.entry.result) { const result = group.entry.result; const toolResultMsg: UserMessage = { type: 'user', createdAt: new Date().toISOString(), messageId: crypto.randomUUID(), sessionId, parent: { messageId: null, sessionId: null, toolUseId: null }, engine: { type: 'opencode' as const, provider: providerID, model: { id: modelID, name: '' }, account: { id: 0, name: '' } }, sender: { id: '', name: '' }, content: [result], synthetic: true, }; messages.push(toolResultMsg); } } return messages; } /** * Convert text parts that have not yet been finalized. * * OpenCode can append a tool part to the same assistant message while the * preceding text is still represented by a live stream placeholder. Flushing * that text before emitting the tool preserves the source part order. Tracking * part IDs prevents the normal message/session finalizer from emitting it a * second time later. */ export function convertPendingTextParts( ocMessage: OCMessage, ocParts: Part[], sessionId: string, emittedTextPartIds: Set, ): EngineOutput[] { const pendingTextParts = ocParts.filter(part => part.type === 'text' && !emittedTextPartIds.has(part.id) ); for (const part of pendingTextParts) { emittedTextPartIds.add(part.id); } const visibleTextParts = pendingTextParts.filter(part => part.type === 'text' && Boolean(part.text) ); return visibleTextParts.length > 0 ? convertAssistantMessages(ocMessage, visibleTextParts, sessionId) : []; } /** * Convert Open Code result/completion → ResultEvent */ export function convertResultMessage( ocMessage: OCMessage, sessionId: string ): EngineOutput { const assistantMsg = ocMessage.role === 'assistant' ? ocMessage as AssistantMessage : null; if (assistantMsg?.error) { return { type: 'result', subtype: 'error_during_execution', sessionId, errors: [JSON.stringify(assistantMsg.error)], } as ErrorResultEvent; } return { type: 'result', subtype: 'success', sessionId, numTurns: 1, totalCostUsd: assistantMsg?.cost || 0, usage: mapUsage(assistantMsg?.tokens) || { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }, stopReason: assistantMsg?.finish || null, } as SuccessResultEvent; } /** * Convert Open Code system/init event → SystemInitEvent */ export function convertSystemInitMessage(sessionId: string, model: string): SystemInitEvent { return { type: 'system_init', sessionId, model, engine: 'opencode', tools: [], mcpServers: [], }; } /** * Convert Open Code text delta → TextDeltaEvent */ export function convertPartialTextDelta( text: string, sessionId: string, ): TextDeltaEvent { return { type: 'stream_event', event: 'delta', sessionId, text, reasoning: false, }; } /** * Convert Open Code stream start → StreamLifecycleEvent */ export function convertStreamStart(sessionId: string): StreamLifecycleEvent { return { type: 'stream_event', event: 'start', sessionId, reasoning: false, }; } /** * Convert Open Code stream stop → StreamLifecycleEvent */ export function convertStreamStop(sessionId: string): StreamLifecycleEvent { return { type: 'stream_event', event: 'stop', sessionId, reasoning: false, }; } /** * Convert a single tool part → AssistantMessage with tool_use only (no result). * Used for progressive tool rendering. */ export function convertToolUseOnly( toolPart: ToolPart, ocMessage: OCMessage, sessionId: string, parentToolUseId?: string, ): UnifiedAssistantMessage { const claudeName = mapToolName(toolPart.tool || 'unknown'); const resolvedInput = getToolInput(toolPart); const normalizedInput = normalizeToolInput(claudeName, resolvedInput); const toolUseId = toolPart.callID || toolPart.id || crypto.randomUUID(); const assistantMsg = ocMessage.role === 'assistant' ? ocMessage as AssistantMessage : null; const provId = assistantMsg?.providerID || ''; const mdlId = assistantMsg?.modelID || ''; return { type: 'assistant', createdAt: new Date().toISOString(), messageId: crypto.randomUUID(), sessionId, parent: { messageId: null, sessionId: null, toolUseId: parentToolUseId || null }, engine: { type: 'opencode' as const, provider: provId, model: { id: mdlId, name: '' }, account: { id: 0, name: '' } }, content: [{ type: 'tool_use', id: toolUseId, name: claudeName, input: normalizedInput, result: null, subActivities: [], skillPrompt: null, interrupted: false, } as ToolUseBlock], stopReason: 'tool_use', usage: null, }; } /** * Convert reasoning text → ReasoningMessage */ export function convertReasoningMessage( reasoningText: string, ocMessage: OCMessage, sessionId: string, ): ReasoningMessage { const assistantMsg = ocMessage.role === 'assistant' ? ocMessage as AssistantMessage : null; const provId = assistantMsg?.providerID || ''; const mdlId = assistantMsg?.modelID || ''; return { type: 'reasoning', createdAt: new Date().toISOString(), messageId: crypto.randomUUID(), sessionId, parent: { messageId: null, sessionId: null, toolUseId: null }, engine: { type: 'opencode' as const, provider: provId, model: { id: mdlId, name: '' }, account: { id: 0, name: '' } }, text: reasoningText, }; } /** * Convert reasoning delta → TextDeltaEvent (reasoning: true) */ export function convertPartialReasoningDelta( text: string, sessionId: string, ): TextDeltaEvent { return { type: 'stream_event', event: 'delta', sessionId, text, reasoning: true, }; } /** * Convert reasoning stream start → StreamLifecycleEvent (reasoning: true) */ export function convertReasoningStreamStart(sessionId: string): StreamLifecycleEvent { return { type: 'stream_event', event: 'start', sessionId, reasoning: true, }; } /** * Convert reasoning stream stop → StreamLifecycleEvent (reasoning: true) */ export function convertReasoningStreamStop(sessionId: string): StreamLifecycleEvent { return { type: 'stream_event', event: 'stop', sessionId, reasoning: true, }; } /** * Convert a subtask part → AssistantMessage with Agent tool_use. */ export function convertSubtaskToolUseOnly( subtaskPart: { id: string; prompt: string; description: string; agent: string }, ocMessage: OCMessage, sessionId: string, ): UnifiedAssistantMessage { const assistantMsg = ocMessage.role === 'assistant' ? ocMessage as AssistantMessage : null; const provId = assistantMsg?.providerID || ''; const mdlId = assistantMsg?.modelID || ''; return { type: 'assistant', createdAt: new Date().toISOString(), messageId: crypto.randomUUID(), sessionId, parent: { messageId: null, sessionId: null, toolUseId: null }, engine: { type: 'opencode' as const, provider: provId, model: { id: mdlId, name: '' }, account: { id: 0, name: '' } }, content: [{ type: 'tool_use', id: subtaskPart.id || crypto.randomUUID(), name: 'Agent', input: { prompt: subtaskPart.prompt || '', description: subtaskPart.description || '', subagentType: subtaskPart.agent || 'general-purpose', }, result: null, subActivities: [], skillPrompt: null, interrupted: false, } as ToolUseBlock], stopReason: 'tool_use', usage: null, }; } /** * Convert a completed/errored tool part → UserMessage with tool_result content. */ export function convertToolResultOnly( toolPart: ToolPart, sessionId: string, parentToolUseId?: string, ): UserMessage { const toolUseId = toolPart.callID || toolPart.id || crypto.randomUUID(); let content: string; if (toolPart.state.status === 'completed') { content = toolPart.state.output || ''; } else if (toolPart.state.status === 'error') { content = toolPart.state.error || 'Tool execution failed'; } else { content = ''; } const hasError = isToolError(toolPart.state.status, content); return { type: 'user', createdAt: new Date().toISOString(), messageId: crypto.randomUUID(), sessionId, parent: { messageId: null, sessionId: null, toolUseId: parentToolUseId || null }, engine: { type: 'opencode' as const, provider: '', model: { id: '', name: '' }, account: { id: 0, name: '' } }, sender: { id: '', name: '' }, content: [{ type: 'tool_result', toolUseId, content, isError: hasError, }], synthetic: true, }; }