mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-09 07:29:51 +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:
@@ -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<string>();
|
||||
const sentToolResults = new Set<string>();
|
||||
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<string, string[]>, key: string, value: string): void {
|
||||
const queue = map.get(key) ?? [];
|
||||
queue.push(value);
|
||||
map.set(key, queue);
|
||||
}
|
||||
|
||||
function shiftQueue(map: Map<string, string[]>, 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<string, string[]>, 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<string>();
|
||||
const sentToolResults = new Set<string>();
|
||||
const toolExecutionQueues = new Map<string, string[]>();
|
||||
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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
// 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
// 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<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 };
|
||||
}
|
||||
Reference in New Issue
Block a user