mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user