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.
This commit is contained in:
DullJZ
2026-07-18 12:26:23 +08:00
committed by GitHub
parent 058a375e6d
commit e737d67aaa
5 changed files with 675 additions and 118 deletions
+39 -83
View File
@@ -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<string, unknown> | null {
return null;
}
function parseToolCall(part: unknown): ParsedToolCall | null {
if (!isObject(part)) {
return null;
}
const record = part as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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',