From e737d67aaa4680ea04fb1c6fb48b77f170c894c7 Mon Sep 17 00:00:00 2001 From: DullJZ <79080562+DullJZ@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:26:23 +0800 Subject: [PATCH] fix(opencode): treat empty tool input as missing and recover late tool-calls (#1052) * fix(opencode): stop treating empty tool input as final args OpenCode emits input/rawInput as {} on tool start (and sometimes again during permission), then fills real arguments on running/completed. Treat empty objects as unusable so ACP and local hooks keep waiting for real args, never clobber them, and ignore non-tool parts as fake results. * fix(web): add exec timing fields to ToolCard test fixture ChatToolCall now requires execStartedAt/execCompletedAt; update the fixture so typecheck passes. * fix(opencode): recover late tool-call after empty execute.before Skip empty before under name-only queue pairing, emit tool-call on after when still missing, and reject content JSON {} on ACP initial tool_call. --- .../backends/acp/AcpMessageHandler.test.ts | 226 +++++++++++++++++ .../agent/backends/acp/AcpMessageHandler.ts | 85 ++++--- cli/src/opencode/opencodeLocalLauncher.ts | 122 +++------- .../utils/opencodeLocalToolParse.test.ts | 229 ++++++++++++++++++ .../opencode/utils/opencodeLocalToolParse.ts | 131 ++++++++++ 5 files changed, 675 insertions(+), 118 deletions(-) create mode 100644 cli/src/opencode/utils/opencodeLocalToolParse.test.ts create mode 100644 cli/src/opencode/utils/opencodeLocalToolParse.ts diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index ec882ce7..fbb1a963 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -577,6 +577,232 @@ describe('AcpMessageHandler', () => { expect(results[0].status).toBe('completed'); }); + describe('OpenCode rawInput lifecycle (empty {} is not usable input)', () => { + it('ignores empty content JSON {} on initial tool_call when rawInput is missing', () => { + // Kimi-style content JSON can be `{}`; initial path must not lock that as input. + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'oc-empty-content-1', + title: 'other', + kind: 'other', + status: 'pending', + content: [{ type: 'content', content: { type: 'text', text: '{}' } }] + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'oc-empty-content-1', + title: 'other', + kind: 'other', + status: 'in_progress', + rawInput: { url: 'https://example.com' } + }); + + const calls = messages.filter( + (m): m is Extract => m.type === 'tool_call' + ); + expect(calls).toHaveLength(2); + expect(calls[0].input).toBeNull(); + expect(calls[1].input).toEqual({ url: 'https://example.com' }); + }); + + it('ignores rawInput: {} on tool start and accepts real args on update', () => { + // OpenCode toolStart emits rawInput: {} with title=tool name, then a + // running update carries part.state.input. + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'oc-bash-1', + title: 'bash', + kind: 'execute', + status: 'pending', + locations: [], + rawInput: {} + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'oc-bash-1', + title: "echo 'hi'", + kind: 'execute', + status: 'in_progress', + rawInput: { command: "echo 'hi'", description: "Print hi" } + }); + + const calls = messages.filter( + (m): m is Extract => m.type === 'tool_call' + ); + expect(calls).toHaveLength(2); + // Start: empty {} must not lock input as {}; title "bash" alone is a weak + // execute fallback, but must not block the later real rawInput. + expect(calls[0].input).not.toEqual({}); + expect(calls[1].input).toEqual({ command: "echo 'hi'", description: "Print hi" }); + }); + + it('does not let permission rawInput: {} clobber a previously captured input', () => { + // OpenCode #7370: permission request / intermediate update can re-send + // rawInput: {} after a good running update. + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'oc-bash-2', + title: 'bash', + kind: 'execute', + status: 'pending', + rawInput: {} + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'oc-bash-2', + title: 'ls -la', + kind: 'execute', + status: 'in_progress', + rawInput: { command: 'ls -la' } + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'oc-bash-2', + title: 'bash', + kind: 'execute', + status: 'pending', + rawInput: {} + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'oc-bash-2', + status: 'completed', + // completed may omit rawInput entirely + content: [{ type: 'content', content: { type: 'text', text: 'ok' } }] + }); + + const calls = messages.filter( + (m): m is Extract => m.type === 'tool_call' + ); + const lastCall = calls[calls.length - 1]; + expect(lastCall.input).toEqual({ command: 'ls -la' }); + + const results = messages.filter( + (m): m is Extract => m.type === 'tool_result' + ); + expect(results).toHaveLength(1); + expect(results[0].status).toBe('completed'); + }); + + it('preserves OpenCode other/fetch/think tool rawInput (MCP, webfetch, task)', () => { + // These kinds have no kind+title fallback in HAPI — usable rawInput is + // the only path. Empty {} must not be stored in place of later args. + const cases: Array<{ + id: string; + kind: string; + title: string; + rawInput: Record; + }> = [ + { + id: 'oc-webfetch', + kind: 'fetch', + title: 'webfetch', + rawInput: { url: 'https://example.com', format: 'text' } + }, + { + id: 'oc-task', + kind: 'think', + title: 'task', + rawInput: { + description: 'Explore', + subagent_type: 'explorer', + prompt: 'find null tool input' + } + }, + { + id: 'oc-mcp', + kind: 'other', + title: 'hapi_change_title', + rawInput: { title: 'fixed title' } + } + ]; + + for (const c of cases) { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: c.id, + title: c.title, + kind: c.kind, + status: 'pending', + rawInput: {} + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: c.id, + title: c.title, + kind: c.kind, + status: 'in_progress', + rawInput: c.rawInput + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: c.id, + status: 'completed', + rawInput: {} + }); + + const calls = messages.filter( + (m): m is Extract => m.type === 'tool_call' + ); + expect(calls[calls.length - 1].input, c.id).toEqual(c.rawInput); + } + }); + + it('keeps full edit rawInput (filePath/oldString/newString) over locations-only fallback', () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'oc-edit-1', + title: 'edit', + kind: 'edit', + status: 'pending', + locations: [], + rawInput: {} + }); + + const fullInput = { + filePath: '/tmp/a.ts', + oldString: 'foo', + newString: 'bar' + }; + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'oc-edit-1', + title: 'a.ts', + kind: 'edit', + status: 'in_progress', + locations: [{ path: '/tmp/a.ts' }], + rawInput: fullInput + }); + + const calls = messages.filter( + (m): m is Extract => m.type === 'tool_call' + ); + expect(calls[calls.length - 1].input).toEqual(fullInput); + }); + }); + it('intercepts rate_limit_event chunk before it enters the text buffer', () => { const messages: AgentMessage[] = []; const handler = new AcpMessageHandler((message) => messages.push(message)); diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index f994f229..06a7616f 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -13,6 +13,29 @@ function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'complete return 'pending'; } +/** + * OpenCode ACP often emits `rawInput: {}` on tool start / permission requests + * before (or after) the real arguments arrive. An empty object is not usable + * tool input — treating it as valid blocks kind+title/content fallbacks and can + * clobber a previously captured non-empty input. + */ +function isUsableRawInput(value: unknown): boolean { + if (value == null) return false; + if (isObject(value) && Object.keys(value).length === 0) return false; + return true; +} + +function resolveToolInputFallbacks( + kind: string | null, + title: string | null, + locations: unknown, + content: unknown +): unknown { + const fromKindTitle = deriveInputFromKindAndTitle(kind, title, locations); + if (fromKindTitle) return fromKindTitle; + return extractJsonInputFromContent(content); +} + type DerivedToolName = ReturnType; const REASONING_SNAPSHOT_INTERVAL_MS = 250; @@ -658,21 +681,20 @@ export class AcpMessageHandler { metaKind: null }); const name = derivedName.name; - // Priority: rawInput > kind+title fallback > content JSON fallback. + // Priority: usable rawInput > kind+title fallback > content JSON fallback. + // Empty `{}` is treated as missing (OpenCode tool-start / permission clobber). // Kimi ACP streams tool arguments as JSON text in the content array // instead of rawInput/kind. Try all three sources. - let input: unknown; - if (update.rawInput != null) { - input = update.rawInput; - } else { - const fromKindTitle = deriveInputFromKindAndTitle(asString(update.kind), asString(update.title), update.locations); - if (fromKindTitle) { - input = fromKindTitle; - } else { - const fromContent = extractJsonInputFromContent(update.content); - input = fromContent; - } - } + const candidate = isUsableRawInput(update.rawInput) + ? update.rawInput + : resolveToolInputFallbacks( + asString(update.kind), + asString(update.title), + update.locations, + update.content + ); + // Content JSON can be `{}` (same as unusable rawInput); never lock that in. + const input = isUsableRawInput(candidate) ? candidate : null; const status = normalizeStatus(update.status); this.toolCalls.set(toolCallId, { name, input }); @@ -693,7 +715,7 @@ export class AcpMessageHandler { const status = normalizeStatus(update.status); const existing = this.toolCalls.get(toolCallId); - if (update.rawInput != null) { + if (isUsableRawInput(update.rawInput)) { const derivedName = deriveToolNameFromUpdate(update); const name = this.selectToolNameForUpdate(existing?.name ?? null, derivedName); const input = update.rawInput; @@ -707,15 +729,21 @@ export class AcpMessageHandler { }); } else if (existing) { // Enrich existing.input from update's kind+title when initial tool_call - // had neither rawInput nor a hoistable thought. Re-emit when we just - // enriched the input or when the call is still active. + // had neither usable rawInput nor a hoistable thought. Never let an + // empty `rawInput: {}` (OpenCode permission / start) clobber a good input. + // Re-emit when we just enriched the input or when the call is still active. let input = existing.input; let name = existing.name; let rederived = false; const updateTitle = asString(update.title); - if (input == null || isStaleDerivedInput(input, updateTitle, asString(update.kind))) { - const fallback = deriveInputFromKindAndTitle(asString(update.kind), updateTitle, update.locations); - if (fallback) { + if (!isUsableRawInput(input) || isStaleDerivedInput(input, updateTitle, asString(update.kind))) { + const fallback = resolveToolInputFallbacks( + asString(update.kind), + updateTitle, + update.locations, + update.content + ); + if (isUsableRawInput(fallback)) { input = fallback; const derivedName = deriveToolNameFromUpdate(update); name = this.selectToolNameForUpdate(existing.name ?? null, derivedName); @@ -723,19 +751,7 @@ export class AcpMessageHandler { rederived = true; } } - // Kimi ACP streams tool arguments as JSON text in the content array. - // If we still don't have a useful input, try to parse the content. - if (!rederived && (input == null || isStaleDerivedInput(input, updateTitle, asString(update.kind)))) { - const fromContent = extractJsonInputFromContent(update.content); - if (fromContent && isObject(fromContent)) { - input = fromContent; - const derivedName = deriveToolNameFromUpdate(update); - name = this.selectToolNameForUpdate(existing.name ?? null, derivedName); - this.toolCalls.set(toolCallId, { name, input }); - rederived = true; - } - } - const justEnriched = (existing.input == null && input != null) || rederived; + const justEnriched = (!isUsableRawInput(existing.input) && isUsableRawInput(input)) || rederived; if (status === 'in_progress' || status === 'pending' || justEnriched) { this.onMessage({ type: 'tool_call', @@ -758,9 +774,8 @@ export class AcpMessageHandler { // // Only runs on status=completed (not failed): a failed write_file must never // promote the tool name to Write/Edit, as no diff was actually applied. - // Uses == null to catch both undefined and null rawInput (Gemini path). - // When rawInput is present the input was already set above and no re-emit needed. - if (status === 'completed' && update.rawInput == null && existing) { + // Skip when a usable rawInput already supplied the input above. + if (status === 'completed' && !isUsableRawInput(update.rawInput) && existing) { const hoisted = hoistDiffContentIntoInput(update.content); if (hoisted) { this.toolCalls.set(toolCallId, { name: hoisted.name, input: hoisted.input }); diff --git a/cli/src/opencode/opencodeLocalLauncher.ts b/cli/src/opencode/opencodeLocalLauncher.ts index 544f9c21..0f49ae3b 100644 --- a/cli/src/opencode/opencodeLocalLauncher.ts +++ b/cli/src/opencode/opencodeLocalLauncher.ts @@ -9,6 +9,7 @@ import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; import type { OpencodeHookEvent } from './types'; import type { OpencodeHookServer } from './utils/startOpencodeHookServer'; import { createOpencodeStorageScanner, type OpencodeStorageScannerHandle } from './utils/opencodeStorageScanner'; +import { isUsableToolInput, parseToolCall, parseToolResult } from './utils/opencodeLocalToolParse'; import { randomUUID } from 'node:crypto'; import { isObject } from '@hapi/protocol'; import { join } from 'node:path'; @@ -22,17 +23,6 @@ type OpencodeLocalLauncherOptions = { hookUrl: string; }; -type ParsedToolCall = { - callId: string; - name: string; - input: unknown; -}; - -type ParsedToolResult = { - callId: string; - output: unknown; -}; - type PermissionDecision = PermissionCompletion['decision']; function getString(value: unknown): string | null { @@ -161,75 +151,6 @@ function unwrapPart(payload: unknown): Record | null { return null; } -function parseToolCall(part: unknown): ParsedToolCall | null { - if (!isObject(part)) { - return null; - } - const record = part as Record; - const name = getString(record.tool) || getString(record.name); - const callId = getString(record.callID) - || getString(record.callId) - || getString(record.id) - || getString(record.tool_call_id) - || getString(record.toolCallId); - if (!name || !callId) { - return null; - } - if (getString(record.type) === 'tool' && isObject(record.state)) { - const state = record.state as Record; - const status = getString(state.status); - if (status !== 'pending' && status !== 'running') { - return null; - } - const input = parseMaybeJson(state.input ?? state.raw ?? record.input ?? record.args ?? record.arguments); - return { callId, name, input }; - } - const input = parseMaybeJson(record.input ?? record.args ?? record.arguments ?? record.raw); - return { callId, name, input }; -} - -function parseToolResult(part: unknown): ParsedToolResult | null { - if (!isObject(part)) { - return null; - } - const record = part as Record; - const callId = getString(record.callID) - || getString(record.callId) - || getString(record.tool_call_id) - || getString(record.toolCallId) - || getString(record.id); - if (!callId) { - return null; - } - if (getString(record.type) === 'tool' && isObject(record.state)) { - const state = record.state as Record; - const status = getString(state.status); - if (status === 'completed') { - const output = { - content: state.output ?? state.title, - metadata: state.metadata, - title: state.title, - attachments: state.attachments - }; - return { callId, output }; - } - if (status === 'error') { - const output = { - content: state.error, - isError: true - }; - return { callId, output }; - } - return null; - } - const output = { - content: record.content, - metadata: record.metadata, - isError: record.is_error - }; - return { callId, output }; -} - function normalizeDecision(response: string | null, approved: boolean): PermissionDecision { if (response === 'always' || response === 'approved_for_session') { return 'approved_for_session'; @@ -407,7 +328,8 @@ export async function opencodeLocalLauncher( } const toolCall = parseToolCall(part); - if (toolCall && !sentToolCalls.has(toolCall.callId)) { + if (toolCall && isUsableToolInput(toolCall.input) && !sentToolCalls.has(toolCall.callId)) { + // Wait for non-empty input (OpenCode pending often has input:{}). sentToolCalls.add(toolCall.callId); session.sendAgentMessage({ type: 'tool-call', @@ -419,6 +341,17 @@ export async function opencodeLocalLauncher( const toolResult = parseToolResult(part); if (toolResult && !sentToolResults.has(toolResult.callId)) { + // If we skipped the empty pending call, emit the call now with + // whatever input is on this completed part before the result. + if (!sentToolCalls.has(toolResult.callId) && toolCall) { + sentToolCalls.add(toolResult.callId); + session.sendAgentMessage({ + type: 'tool-call', + name: toolCall.name, + callId: toolCall.callId, + input: toolCall.input + }); + } sentToolResults.add(toolResult.callId); session.sendAgentMessage({ type: 'tool-call-result', @@ -446,6 +379,7 @@ export async function opencodeLocalLauncher( || getString(tool.tool_call_id) || getString(tool.toolCallId); const isBefore = eventType === 'tool.execute.before'; + const usableInput = isUsableToolInput(toolInput); let callId = existingId; if (!callId) { @@ -457,8 +391,15 @@ export async function opencodeLocalLauncher( } if (isBefore) { - pushQueue(toolExecutionQueues, signature, callId); - if (fallbackSignature !== signature) { + // Empty `{}` before must not enqueue under the empty-input + // signature: after usually carries real args and would miss. + // Name-only fallback keeps pairing when tool ids are absent. + if (usableInput) { + pushQueue(toolExecutionQueues, signature, callId); + if (fallbackSignature !== signature) { + pushQueue(toolExecutionQueues, fallbackSignature, callId); + } + } else { pushQueue(toolExecutionQueues, fallbackSignature, callId); } } else { @@ -468,6 +409,10 @@ export async function opencodeLocalLauncher( } } if (eventType === 'tool.execute.before' && !sentToolCalls.has(callId)) { + // Match message.part.updated path: skip empty placeholder args. + if (!usableInput) { + return; + } sentToolCalls.add(callId); session.sendAgentMessage({ type: 'tool-call', @@ -478,6 +423,17 @@ export async function opencodeLocalLauncher( return; } if (eventType === 'tool.execute.after' && !sentToolResults.has(callId)) { + // Late tool-call recovery: before may have skipped empty `{}`. + // Mirror message.part.updated so result is never unpaired. + if (!sentToolCalls.has(callId)) { + sentToolCalls.add(callId); + session.sendAgentMessage({ + type: 'tool-call', + name, + callId, + input: toolInput + }); + } sentToolResults.add(callId); session.sendAgentMessage({ type: 'tool-call-result', diff --git a/cli/src/opencode/utils/opencodeLocalToolParse.test.ts b/cli/src/opencode/utils/opencodeLocalToolParse.test.ts new file mode 100644 index 00000000..cbfe167d --- /dev/null +++ b/cli/src/opencode/utils/opencodeLocalToolParse.test.ts @@ -0,0 +1,229 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { isUsableToolInput, parseToolCall, parseToolResult } from './opencodeLocalToolParse'; + +/** Simulate the emit policy used by the local launcher hook handler. */ +function collectMessages(parts: unknown[]): Array<{ type: string; name?: string; callId: string; input?: unknown; output?: unknown }> { + const sentToolCalls = new Set(); + const sentToolResults = new Set(); + const out: Array<{ type: string; name?: string; callId: string; input?: unknown; output?: unknown }> = []; + + for (const part of parts) { + const toolCall = parseToolCall(part); + if (toolCall && isUsableToolInput(toolCall.input) && !sentToolCalls.has(toolCall.callId)) { + sentToolCalls.add(toolCall.callId); + out.push({ type: 'tool-call', name: toolCall.name, callId: toolCall.callId, input: toolCall.input }); + } + const toolResult = parseToolResult(part); + if (toolResult && !sentToolResults.has(toolResult.callId)) { + if (!sentToolCalls.has(toolResult.callId) && toolCall) { + sentToolCalls.add(toolResult.callId); + out.push({ type: 'tool-call', name: toolCall.name, callId: toolCall.callId, input: toolCall.input }); + } + sentToolResults.add(toolResult.callId); + out.push({ type: 'tool-call-result', callId: toolResult.callId, output: toolResult.output }); + } + } + return out; +} + +describe('OpenCode local tool part parsing', () => { + it('does not emit tool-call on pending with empty input; emits on running with real args', () => { + const callId = 'call-6049b4cf-0272-4651-be9a-402c3a40c933-0'; + const messages = collectMessages([ + { + type: 'tool', + tool: 'hapi_change_title', + callID: callId, + id: 'prt_pending', + state: { status: 'pending', input: {}, raw: '' } + }, + { + type: 'tool', + tool: 'hapi_change_title', + callID: callId, + id: 'prt_pending', + state: { status: 'running', input: { title: 'New chat' } } + }, + { + type: 'tool', + tool: 'hapi_change_title', + callID: callId, + id: 'prt_pending', + state: { + status: 'completed', + input: { title: 'New chat' }, + output: 'Successfully changed chat title to: "New chat"', + metadata: { truncated: false }, + title: '' + } + } + ]); + + expect(messages).toEqual([ + { + type: 'tool-call', + name: 'hapi_change_title', + callId, + input: { title: 'New chat' } + }, + { + type: 'tool-call-result', + callId, + output: { + content: 'Successfully changed chat title to: "New chat"', + metadata: { truncated: false }, + title: '', + attachments: undefined + } + } + ]); + }); + + it('does not treat step-start / reasoning parts as tool results', () => { + const messages = collectMessages([ + { type: 'step-start', id: 'prt_f6aa2a4ef001zo366S85sECnN2' }, + { type: 'reasoning', id: 'prt_f6aa2a4f800197fHOPPOxUPYq0', text: 'thinking' }, + { type: 'step-finish', id: 'prt_f6aa2b267001KCNUN7ZBnwirwj', reason: 'stop' } + ]); + expect(messages).toEqual([]); + }); + + it('emits late tool-call from completed part when pending never had args', () => { + const callId = 'call-late'; + const messages = collectMessages([ + { + type: 'tool', + tool: 'bash', + callID: callId, + state: { status: 'pending', input: {} } + }, + { + type: 'tool', + tool: 'bash', + callID: callId, + state: { + status: 'completed', + input: { command: 'echo hi' }, + output: 'hi' + } + } + ]); + expect(messages.map((m) => m.type)).toEqual(['tool-call', 'tool-call-result']); + expect(messages[0].input).toEqual({ command: 'echo hi' }); + }); +}); + +function hashObject(obj: unknown): string { + return createHash('sha256').update(JSON.stringify(obj)).digest('hex'); +} + +function buildToolSignature(name: string, input: unknown): string { + return `${name}:${hashObject(input ?? null)}`; +} + +function pushQueue(map: Map, key: string, value: string): void { + const queue = map.get(key) ?? []; + queue.push(value); + map.set(key, queue); +} + +function shiftQueue(map: Map, key: string): string | null { + const queue = map.get(key); + if (!queue || queue.length === 0) return null; + const value = queue.shift() ?? null; + if (!queue.length) map.delete(key); + else map.set(key, queue); + return value; +} + +function removeFromQueue(map: Map, key: string, value: string): void { + const queue = map.get(key); + if (!queue || queue.length === 0) return; + const nextQueue = queue.filter((entry) => entry !== value); + if (!nextQueue.length) map.delete(key); + else map.set(key, nextQueue); +} + +/** Simulate execute-hook emit policy used by opencodeLocalLauncher. */ +function collectExecuteHookMessages( + events: Array<{ type: 'before' | 'after'; name: string; input?: unknown; id?: string; output?: unknown }> +): Array<{ type: string; name?: string; callId: string; input?: unknown; output?: unknown }> { + const sentToolCalls = new Set(); + const sentToolResults = new Set(); + const toolExecutionQueues = new Map(); + const out: Array<{ type: string; name?: string; callId: string; input?: unknown; output?: unknown }> = []; + let nextId = 0; + + for (const event of events) { + const toolInput = event.input; + const signature = buildToolSignature(event.name, toolInput); + const fallbackSignature = buildToolSignature(event.name, null); + const existingId = event.id ?? null; + const isBefore = event.type === 'before'; + const usableInput = isUsableToolInput(toolInput); + let callId = existingId; + + if (!callId) { + callId = isBefore + ? `gen-${nextId++}` + : shiftQueue(toolExecutionQueues, signature) + ?? shiftQueue(toolExecutionQueues, fallbackSignature) + ?? `gen-${nextId++}`; + } + + if (isBefore) { + if (usableInput) { + pushQueue(toolExecutionQueues, signature, callId); + if (fallbackSignature !== signature) { + pushQueue(toolExecutionQueues, fallbackSignature, callId); + } + } else { + pushQueue(toolExecutionQueues, fallbackSignature, callId); + } + if (!sentToolCalls.has(callId)) { + if (!usableInput) continue; + sentToolCalls.add(callId); + out.push({ type: 'tool-call', name: event.name, callId, input: toolInput }); + } + continue; + } + + removeFromQueue(toolExecutionQueues, signature, callId); + if (fallbackSignature !== signature) { + removeFromQueue(toolExecutionQueues, fallbackSignature, callId); + } + if (!sentToolResults.has(callId)) { + if (!sentToolCalls.has(callId)) { + sentToolCalls.add(callId); + out.push({ type: 'tool-call', name: event.name, callId, input: toolInput }); + } + sentToolResults.add(callId); + out.push({ type: 'tool-call-result', callId, output: event.output }); + } + } + return out; +} + +describe('OpenCode local execute-hook tool emit policy', () => { + it('pairs empty before with real after via fallback signature and late tool-call', () => { + const messages = collectExecuteHookMessages([ + { type: 'before', name: 'bash', input: {} }, + { type: 'after', name: 'bash', input: { command: 'echo hi' }, output: 'hi' } + ]); + expect(messages.map((m) => m.type)).toEqual(['tool-call', 'tool-call-result']); + expect(messages[0].callId).toEqual(messages[1].callId); + expect(messages[0].input).toEqual({ command: 'echo hi' }); + }); + + it('does not orphan after when before skipped empty input with stable id', () => { + const messages = collectExecuteHookMessages([ + { type: 'before', name: 'bash', id: 'stable-1', input: {} }, + { type: 'after', name: 'bash', id: 'stable-1', input: { command: 'ls' }, output: 'ok' } + ]); + expect(messages).toEqual([ + { type: 'tool-call', name: 'bash', callId: 'stable-1', input: { command: 'ls' } }, + { type: 'tool-call-result', callId: 'stable-1', output: 'ok' } + ]); + }); +}); diff --git a/cli/src/opencode/utils/opencodeLocalToolParse.ts b/cli/src/opencode/utils/opencodeLocalToolParse.ts new file mode 100644 index 00000000..fc7daf8f --- /dev/null +++ b/cli/src/opencode/utils/opencodeLocalToolParse.ts @@ -0,0 +1,131 @@ +import { isObject } from '@hapi/protocol'; + +export type ParsedToolCall = { + callId: string; + name: string; + input: unknown; +}; + +export type ParsedToolResult = { + callId: string; + output: unknown; +}; + +/** + * OpenCode local hooks often emit tool parts with `state.input: {}` while still + * pending, then fill real args on `running`/`completed`. Empty objects are not + * useful tool input for the web UI. + */ +export function isUsableToolInput(value: unknown): boolean { + if (value == null) return false; + if (isObject(value) && Object.keys(value).length === 0) return false; + if (typeof value === 'string' && value.trim().length === 0) return false; + return true; +} + +function getString(value: unknown): string | null { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + return null; +} + +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') { + return value; + } + const trimmed = value.trim(); + if (!trimmed) { + return value; + } + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.parse(trimmed); + } catch { + return value; + } + } + return value; +} + +export function parseToolCall(part: unknown): ParsedToolCall | null { + if (!isObject(part)) { + return null; + } + const record = part as Record; + // Only real tool parts. Non-tool parts (step-start/reasoning/...) reuse part.id + // and must not be mistaken for tool_call / tool_result. + if (getString(record.type) !== 'tool') { + return null; + } + const name = getString(record.tool) || getString(record.name); + // Prefer OpenCode's tool callID over part.id so lifecycle updates share one id. + const callId = getString(record.callID) + || getString(record.callId) + || getString(record.tool_call_id) + || getString(record.toolCallId) + || getString(record.id); + if (!name || !callId) { + return null; + } + if (isObject(record.state)) { + const state = record.state as Record; + const status = getString(state.status); + // pending/running/completed/error all carry tool identity; input may only + // become usable on running/completed. + if (status !== 'pending' && status !== 'running' && status !== 'completed' && status !== 'error') { + return null; + } + const input = parseMaybeJson(state.input ?? state.raw ?? record.input ?? record.args ?? record.arguments); + return { callId, name, input }; + } + const input = parseMaybeJson(record.input ?? record.args ?? record.arguments ?? record.raw); + return { callId, name, input }; +} + +export function parseToolResult(part: unknown): ParsedToolResult | null { + if (!isObject(part)) { + return null; + } + const record = part as Record; + // Only completed/error tool parts produce results. step-start/reasoning etc. + // previously leaked as tool-call-result with output:{} via part.id fallback. + if (getString(record.type) !== 'tool') { + return null; + } + const callId = getString(record.callID) + || getString(record.callId) + || getString(record.tool_call_id) + || getString(record.toolCallId) + || getString(record.id); + if (!callId) { + return null; + } + if (isObject(record.state)) { + const state = record.state as Record; + const status = getString(state.status); + if (status === 'completed') { + const output = { + content: state.output ?? state.title, + metadata: state.metadata, + title: state.title, + attachments: state.attachments + }; + return { callId, output }; + } + if (status === 'error') { + const output = { + content: state.error, + isError: true + }; + return { callId, output }; + } + return null; + } + const output = { + content: record.content, + metadata: record.metadata, + isError: record.is_error + }; + return { callId, output }; +}