mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +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:
@@ -577,6 +577,232 @@ describe('AcpMessageHandler', () => {
|
|||||||
expect(results[0].status).toBe('completed');
|
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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_result' }> => 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<string, unknown>;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_call' }> => m.type === 'tool_call'
|
||||||
|
);
|
||||||
|
expect(calls[calls.length - 1].input).toEqual(fullInput);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('intercepts rate_limit_event chunk before it enters the text buffer', () => {
|
it('intercepts rate_limit_event chunk before it enters the text buffer', () => {
|
||||||
const messages: AgentMessage[] = [];
|
const messages: AgentMessage[] = [];
|
||||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'complete
|
|||||||
return 'pending';
|
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<typeof deriveToolNameWithSource>;
|
type DerivedToolName = ReturnType<typeof deriveToolNameWithSource>;
|
||||||
|
|
||||||
const REASONING_SNAPSHOT_INTERVAL_MS = 250;
|
const REASONING_SNAPSHOT_INTERVAL_MS = 250;
|
||||||
@@ -658,21 +681,20 @@ export class AcpMessageHandler {
|
|||||||
metaKind: null
|
metaKind: null
|
||||||
});
|
});
|
||||||
const name = derivedName.name;
|
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
|
// Kimi ACP streams tool arguments as JSON text in the content array
|
||||||
// instead of rawInput/kind. Try all three sources.
|
// instead of rawInput/kind. Try all three sources.
|
||||||
let input: unknown;
|
const candidate = isUsableRawInput(update.rawInput)
|
||||||
if (update.rawInput != null) {
|
? update.rawInput
|
||||||
input = update.rawInput;
|
: resolveToolInputFallbacks(
|
||||||
} else {
|
asString(update.kind),
|
||||||
const fromKindTitle = deriveInputFromKindAndTitle(asString(update.kind), asString(update.title), update.locations);
|
asString(update.title),
|
||||||
if (fromKindTitle) {
|
update.locations,
|
||||||
input = fromKindTitle;
|
update.content
|
||||||
} else {
|
);
|
||||||
const fromContent = extractJsonInputFromContent(update.content);
|
// Content JSON can be `{}` (same as unusable rawInput); never lock that in.
|
||||||
input = fromContent;
|
const input = isUsableRawInput(candidate) ? candidate : null;
|
||||||
}
|
|
||||||
}
|
|
||||||
const status = normalizeStatus(update.status);
|
const status = normalizeStatus(update.status);
|
||||||
|
|
||||||
this.toolCalls.set(toolCallId, { name, input });
|
this.toolCalls.set(toolCallId, { name, input });
|
||||||
@@ -693,7 +715,7 @@ export class AcpMessageHandler {
|
|||||||
const status = normalizeStatus(update.status);
|
const status = normalizeStatus(update.status);
|
||||||
const existing = this.toolCalls.get(toolCallId);
|
const existing = this.toolCalls.get(toolCallId);
|
||||||
|
|
||||||
if (update.rawInput != null) {
|
if (isUsableRawInput(update.rawInput)) {
|
||||||
const derivedName = deriveToolNameFromUpdate(update);
|
const derivedName = deriveToolNameFromUpdate(update);
|
||||||
const name = this.selectToolNameForUpdate(existing?.name ?? null, derivedName);
|
const name = this.selectToolNameForUpdate(existing?.name ?? null, derivedName);
|
||||||
const input = update.rawInput;
|
const input = update.rawInput;
|
||||||
@@ -707,15 +729,21 @@ export class AcpMessageHandler {
|
|||||||
});
|
});
|
||||||
} else if (existing) {
|
} else if (existing) {
|
||||||
// Enrich existing.input from update's kind+title when initial tool_call
|
// Enrich existing.input from update's kind+title when initial tool_call
|
||||||
// had neither rawInput nor a hoistable thought. Re-emit when we just
|
// had neither usable rawInput nor a hoistable thought. Never let an
|
||||||
// enriched the input or when the call is still active.
|
// 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 input = existing.input;
|
||||||
let name = existing.name;
|
let name = existing.name;
|
||||||
let rederived = false;
|
let rederived = false;
|
||||||
const updateTitle = asString(update.title);
|
const updateTitle = asString(update.title);
|
||||||
if (input == null || isStaleDerivedInput(input, updateTitle, asString(update.kind))) {
|
if (!isUsableRawInput(input) || isStaleDerivedInput(input, updateTitle, asString(update.kind))) {
|
||||||
const fallback = deriveInputFromKindAndTitle(asString(update.kind), updateTitle, update.locations);
|
const fallback = resolveToolInputFallbacks(
|
||||||
if (fallback) {
|
asString(update.kind),
|
||||||
|
updateTitle,
|
||||||
|
update.locations,
|
||||||
|
update.content
|
||||||
|
);
|
||||||
|
if (isUsableRawInput(fallback)) {
|
||||||
input = fallback;
|
input = fallback;
|
||||||
const derivedName = deriveToolNameFromUpdate(update);
|
const derivedName = deriveToolNameFromUpdate(update);
|
||||||
name = this.selectToolNameForUpdate(existing.name ?? null, derivedName);
|
name = this.selectToolNameForUpdate(existing.name ?? null, derivedName);
|
||||||
@@ -723,19 +751,7 @@ export class AcpMessageHandler {
|
|||||||
rederived = true;
|
rederived = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Kimi ACP streams tool arguments as JSON text in the content array.
|
const justEnriched = (!isUsableRawInput(existing.input) && isUsableRawInput(input)) || rederived;
|
||||||
// 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;
|
|
||||||
if (status === 'in_progress' || status === 'pending' || justEnriched) {
|
if (status === 'in_progress' || status === 'pending' || justEnriched) {
|
||||||
this.onMessage({
|
this.onMessage({
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
@@ -758,9 +774,8 @@ export class AcpMessageHandler {
|
|||||||
//
|
//
|
||||||
// Only runs on status=completed (not failed): a failed write_file must never
|
// 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.
|
// promote the tool name to Write/Edit, as no diff was actually applied.
|
||||||
// Uses == null to catch both undefined and null rawInput (Gemini path).
|
// Skip when a usable rawInput already supplied the input above.
|
||||||
// When rawInput is present the input was already set above and no re-emit needed.
|
if (status === 'completed' && !isUsableRawInput(update.rawInput) && existing) {
|
||||||
if (status === 'completed' && update.rawInput == null && existing) {
|
|
||||||
const hoisted = hoistDiffContentIntoInput(update.content);
|
const hoisted = hoistDiffContentIntoInput(update.content);
|
||||||
if (hoisted) {
|
if (hoisted) {
|
||||||
this.toolCalls.set(toolCallId, { name: hoisted.name, input: hoisted.input });
|
this.toolCalls.set(toolCallId, { name: hoisted.name, input: hoisted.input });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
|
|||||||
import type { OpencodeHookEvent } from './types';
|
import type { OpencodeHookEvent } from './types';
|
||||||
import type { OpencodeHookServer } from './utils/startOpencodeHookServer';
|
import type { OpencodeHookServer } from './utils/startOpencodeHookServer';
|
||||||
import { createOpencodeStorageScanner, type OpencodeStorageScannerHandle } from './utils/opencodeStorageScanner';
|
import { createOpencodeStorageScanner, type OpencodeStorageScannerHandle } from './utils/opencodeStorageScanner';
|
||||||
|
import { isUsableToolInput, parseToolCall, parseToolResult } from './utils/opencodeLocalToolParse';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { isObject } from '@hapi/protocol';
|
import { isObject } from '@hapi/protocol';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -22,17 +23,6 @@ type OpencodeLocalLauncherOptions = {
|
|||||||
hookUrl: string;
|
hookUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParsedToolCall = {
|
|
||||||
callId: string;
|
|
||||||
name: string;
|
|
||||||
input: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ParsedToolResult = {
|
|
||||||
callId: string;
|
|
||||||
output: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PermissionDecision = PermissionCompletion['decision'];
|
type PermissionDecision = PermissionCompletion['decision'];
|
||||||
|
|
||||||
function getString(value: unknown): string | null {
|
function getString(value: unknown): string | null {
|
||||||
@@ -161,75 +151,6 @@ function unwrapPart(payload: unknown): Record<string, unknown> | null {
|
|||||||
return 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 {
|
function normalizeDecision(response: string | null, approved: boolean): PermissionDecision {
|
||||||
if (response === 'always' || response === 'approved_for_session') {
|
if (response === 'always' || response === 'approved_for_session') {
|
||||||
return 'approved_for_session';
|
return 'approved_for_session';
|
||||||
@@ -407,7 +328,8 @@ export async function opencodeLocalLauncher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolCall = parseToolCall(part);
|
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);
|
sentToolCalls.add(toolCall.callId);
|
||||||
session.sendAgentMessage({
|
session.sendAgentMessage({
|
||||||
type: 'tool-call',
|
type: 'tool-call',
|
||||||
@@ -419,6 +341,17 @@ export async function opencodeLocalLauncher(
|
|||||||
|
|
||||||
const toolResult = parseToolResult(part);
|
const toolResult = parseToolResult(part);
|
||||||
if (toolResult && !sentToolResults.has(toolResult.callId)) {
|
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);
|
sentToolResults.add(toolResult.callId);
|
||||||
session.sendAgentMessage({
|
session.sendAgentMessage({
|
||||||
type: 'tool-call-result',
|
type: 'tool-call-result',
|
||||||
@@ -446,6 +379,7 @@ export async function opencodeLocalLauncher(
|
|||||||
|| getString(tool.tool_call_id)
|
|| getString(tool.tool_call_id)
|
||||||
|| getString(tool.toolCallId);
|
|| getString(tool.toolCallId);
|
||||||
const isBefore = eventType === 'tool.execute.before';
|
const isBefore = eventType === 'tool.execute.before';
|
||||||
|
const usableInput = isUsableToolInput(toolInput);
|
||||||
let callId = existingId;
|
let callId = existingId;
|
||||||
|
|
||||||
if (!callId) {
|
if (!callId) {
|
||||||
@@ -457,8 +391,15 @@ export async function opencodeLocalLauncher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isBefore) {
|
if (isBefore) {
|
||||||
pushQueue(toolExecutionQueues, signature, callId);
|
// Empty `{}` before must not enqueue under the empty-input
|
||||||
if (fallbackSignature !== signature) {
|
// 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);
|
pushQueue(toolExecutionQueues, fallbackSignature, callId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -468,6 +409,10 @@ export async function opencodeLocalLauncher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (eventType === 'tool.execute.before' && !sentToolCalls.has(callId)) {
|
if (eventType === 'tool.execute.before' && !sentToolCalls.has(callId)) {
|
||||||
|
// Match message.part.updated path: skip empty placeholder args.
|
||||||
|
if (!usableInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sentToolCalls.add(callId);
|
sentToolCalls.add(callId);
|
||||||
session.sendAgentMessage({
|
session.sendAgentMessage({
|
||||||
type: 'tool-call',
|
type: 'tool-call',
|
||||||
@@ -478,6 +423,17 @@ export async function opencodeLocalLauncher(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (eventType === 'tool.execute.after' && !sentToolResults.has(callId)) {
|
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);
|
sentToolResults.add(callId);
|
||||||
session.sendAgentMessage({
|
session.sendAgentMessage({
|
||||||
type: 'tool-call-result',
|
type: 'tool-call-result',
|
||||||
|
|||||||
@@ -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