mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
fix: render opencode edit/write/read tool calls through the shared views (#1644)
* feat(cli): add diff-shape tool input canonicalization helper
* fix(cli): canonicalize ACP diff-shaped tool inputs for the Edit/Write views
* fix(opencode): normalize local-mode edit/write tool parts to the diff view shapes
* fix(opencode): preserve replaceAll and fix id-less hook pairing
- canonicalizeDiffToolInput now carries OpenCode's optional replaceAll through
to the canonical Edit input as replace_all, so persisted history keeps
whether an edit replaced one occurrence or all.
- opencodeLocalLauncher computes the execute-hook pairing signature from the
raw name/input BEFORE canonicalizing, so an id-less 'before' with empty or
partial args shares a queue key with the full 'after'.
* fix(opencode): preserve snake_case replace_all in edit canonicalization
canonicalizeDiffToolInput accepts already-canonical snake_case edit inputs
(file_path/old_string/new_string), but only read camelCase replaceAll. Read
replace_all too so the execution argument survives every call site.
* fix(opencode): upgrade partial local edit/write calls when full args arrive
A partial native before ({filePath}) was emitted as-is and recorded as sent,
so the later canonical full input was deduped and the web Edit/Write view never
received the complete arguments. Track the emitted input per callId and, when
a partial native edit/write call is superseded by a canonical full input, emit
a replacement tool-call (same callId) before the result. Applies to both the
message.part.updated and tool.execute.* local event paths.
* fix(opencode): release completed tool inputs from upgrade tracking
emittedToolInputs retained the full emitted Write content and Edit old/new
strings for the whole launcher lifetime. Once a call is completed
(sentToolResults), later upgrades are already blocked, so drop the stored
payload in both result paths. Add a lifecycle test that a stale part for a
completed callId neither re-emits nor emits a duplicate result.
* style(opencode): restore 4-space indentation on local launcher tracking decls
The per-session tracking declarations (sentTextParts, sentToolCalls,
sentToolResults, emittedToolInputs) were flush-left inside opencodeLocalLauncher,
violating the repo's 4-space indentation rule.
* fix(opencode): gate diff canonicalization on edit/write semantics
canonicalizeDiffToolInput now takes the tool's semantic name/kind and only
normalizes known edit/write aliases (edit, write, write_file, replace, modify,
file_edit). The Edit shape (path + both old/new) is unambiguous on its own, but
Write is just {path, content} — a shape many non-edit MCP tools also accept —
so without the gate those tools were renamed to Write and their extra args
dropped. OpenCode maps both edit and write to kind 'edit', which stays in the
allow-list, so the native-shape cases keep working. ACP callers pass the update
kind (falling back to the derived name); local callers pass the tool name.
* fix(opencode): re-emit canonical edit/write when the input changes
shouldEmit only handled the null->canonical transition, so a shape-complete
Write/Edit that already canonicalizes (e.g. {content: ""}) was suppressed
when the final content arrived, leaving the web diff with stale input. Emit on
first usable input and re-emit whenever the canonical input value changes,
across both message.part.updated and tool.execute.* paths.
* test(opencode): cover changed-input re-emit on the message.part.updated path
The execute-hook path already had a regression for canonical inputs changing
from empty to final content; add the matching case for message.part.updated so
both local emit paths are covered.
* fix(web): render opencode native read output as a file content code block
OpenCode's lowercase read tool fell through to the generic markdown
result view, collapsing the whole file into a soft-wrapped paragraph
and dropping the <path>/<type>/<content> markup. Register the read
alias, unwrap the opencode payload (including the trailing
"(End of file ...)" trailer), parse its 1-indexed line numbering into
the code block gutter, and resolve the camelCase filePath key in the
card title.
* fix(web): only unwrap complete opencode read envelopes
Anchor the envelope match to the full <path>/<type>file/<content>
structure so plain Read results whose file text contains a literal
<content> element are left untouched, capture the body greedily so an
embedded </content> survives, and stop trimming real trailing
whitespace.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { logger } from '@/ui/logger';
|
||||
import { canonicalizeDiffToolInput } from '@/agent/utils';
|
||||
import { opencodeLocal } from './opencodeLocal';
|
||||
import { OpencodeSession } from './session';
|
||||
import { ensureOpencodeHookPlugin } from './utils/hookPlugin';
|
||||
@@ -238,6 +239,10 @@ export async function opencodeLocalLauncher(
|
||||
const sentTextParts = new Set<string>();
|
||||
const sentToolCalls = new Set<string>();
|
||||
const sentToolResults = new Set<string>();
|
||||
// Emitted tool input per callId. When a partial native edit/write call is
|
||||
// emitted first (e.g. `{filePath}`), a later canonical full input replaces
|
||||
// it so the web Edit/Write view receives the complete arguments.
|
||||
const emittedToolInputs = new Map<string, unknown>();
|
||||
const textBuffers = new Map<string, string>();
|
||||
const toolExecutionQueues = new Map<string, string[]>();
|
||||
|
||||
@@ -328,16 +333,29 @@ export async function opencodeLocalLauncher(
|
||||
}
|
||||
|
||||
const toolCall = parseToolCall(part);
|
||||
if (toolCall && isUsableToolInput(toolCall.input) && !sentToolCalls.has(toolCall.callId)) {
|
||||
if (toolCall && isUsableToolInput(toolCall.input) && !sentToolResults.has(toolCall.callId)) {
|
||||
// Wait for non-empty input (OpenCode pending often has input:{}).
|
||||
sentToolCalls.add(toolCall.callId);
|
||||
session.sendAgentMessage({
|
||||
type: 'tool-call',
|
||||
name: toolCall.name,
|
||||
callId: toolCall.callId,
|
||||
input: toolCall.input,
|
||||
...(toolCall.title ? { nativeTitle: toolCall.title } : {})
|
||||
});
|
||||
const previousInput = emittedToolInputs.get(toolCall.callId);
|
||||
const previousCanonical = canonicalizeDiffToolInput(previousInput, toolCall.name);
|
||||
const currentCanonical = canonicalizeDiffToolInput(toolCall.input, toolCall.name);
|
||||
// Emit on first usable input, and re-emit whenever the canonical
|
||||
// input changes (a partial native edit/write that already
|
||||
// canonicalizes — e.g. {content: ""} — must be replaced when the
|
||||
// final content arrives).
|
||||
const canonicalChanged = currentCanonical !== null
|
||||
&& hashObject(currentCanonical) !== hashObject(previousCanonical);
|
||||
const shouldEmit = previousInput === undefined || canonicalChanged;
|
||||
if (shouldEmit) {
|
||||
emittedToolInputs.set(toolCall.callId, toolCall.input);
|
||||
sentToolCalls.add(toolCall.callId);
|
||||
session.sendAgentMessage({
|
||||
type: 'tool-call',
|
||||
name: toolCall.name,
|
||||
callId: toolCall.callId,
|
||||
input: toolCall.input,
|
||||
...(toolCall.title ? { nativeTitle: toolCall.title } : {})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toolResult = parseToolResult(part);
|
||||
@@ -355,6 +373,7 @@ export async function opencodeLocalLauncher(
|
||||
});
|
||||
}
|
||||
sentToolResults.add(toolResult.callId);
|
||||
emittedToolInputs.delete(toolResult.callId);
|
||||
session.sendAgentMessage({
|
||||
type: 'tool-call-result',
|
||||
callId: toolResult.callId,
|
||||
@@ -370,13 +389,25 @@ export async function opencodeLocalLauncher(
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const tool = isObject(record.tool) ? record.tool as Record<string, unknown> : record;
|
||||
const name = getString(tool.name) || getString(record.name);
|
||||
let name = getString(tool.name) || getString(record.name);
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const toolInput = parseMaybeJson(tool.input ?? tool.args ?? record.input ?? record.args);
|
||||
let toolInput: unknown = parseMaybeJson(tool.input ?? tool.args ?? record.input ?? record.args);
|
||||
// Compute the pairing signature from the raw name/input BEFORE
|
||||
// canonicalizing: an id-less `before` with empty/partial args must
|
||||
// share a queue key with the full `after`. Canonicalizing first would
|
||||
// re-key the `after` (edit -> Edit) and miss the queued call.
|
||||
const signature = buildToolSignature(name, toolInput);
|
||||
const fallbackSignature = buildToolSignature(name, null);
|
||||
// Native edit/write args are canonicalized for emit so the web
|
||||
// Edit/Write views match the ACP path. Gate on the tool name so an
|
||||
// arbitrary non-edit tool isn't misclassified as Write.
|
||||
const canonical = canonicalizeDiffToolInput(toolInput, name);
|
||||
if (canonical) {
|
||||
name = canonical.name;
|
||||
toolInput = canonical.input;
|
||||
}
|
||||
const existingId = getString(tool.id)
|
||||
|| getString(tool.tool_call_id)
|
||||
|| getString(tool.toolCallId);
|
||||
@@ -410,25 +441,45 @@ export async function opencodeLocalLauncher(
|
||||
removeFromQueue(toolExecutionQueues, fallbackSignature, callId);
|
||||
}
|
||||
}
|
||||
if (eventType === 'tool.execute.before' && !sentToolCalls.has(callId)) {
|
||||
if (eventType === 'tool.execute.before' && !sentToolResults.has(callId)) {
|
||||
// Match message.part.updated path: skip empty placeholder args.
|
||||
if (!usableInput) {
|
||||
return;
|
||||
}
|
||||
sentToolCalls.add(callId);
|
||||
session.sendAgentMessage({
|
||||
type: 'tool-call',
|
||||
name,
|
||||
callId,
|
||||
input: toolInput,
|
||||
...(getString(tool.title ?? record.title) ? { nativeTitle: getString(tool.title ?? record.title) } : {})
|
||||
});
|
||||
const previousInput = emittedToolInputs.get(callId);
|
||||
const previousCanonical = canonicalizeDiffToolInput(previousInput, name);
|
||||
const currentCanonical = canonicalizeDiffToolInput(toolInput, name);
|
||||
// Emit on first usable input; re-emit whenever the canonical
|
||||
// input changes (a partial native edit/write that already
|
||||
// canonicalizes must be replaced when the final input arrives).
|
||||
const canonicalChanged = currentCanonical !== null
|
||||
&& hashObject(currentCanonical) !== hashObject(previousCanonical);
|
||||
const shouldEmit = previousInput === undefined || canonicalChanged;
|
||||
if (shouldEmit) {
|
||||
emittedToolInputs.set(callId, toolInput);
|
||||
sentToolCalls.add(callId);
|
||||
session.sendAgentMessage({
|
||||
type: 'tool-call',
|
||||
name,
|
||||
callId,
|
||||
input: toolInput,
|
||||
...(getString(tool.title ?? record.title) ? { nativeTitle: getString(tool.title ?? record.title) } : {})
|
||||
});
|
||||
}
|
||||
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)) {
|
||||
// Mirror message.part.updated so result is never unpaired, and
|
||||
// replace a partial native edit/write call with the full input.
|
||||
const previousInput = emittedToolInputs.get(callId);
|
||||
const previousCanonical = canonicalizeDiffToolInput(previousInput, name);
|
||||
const currentCanonical = canonicalizeDiffToolInput(toolInput, name);
|
||||
const canonicalChanged = currentCanonical !== null
|
||||
&& hashObject(currentCanonical) !== hashObject(previousCanonical);
|
||||
const shouldEmit = previousInput === undefined || canonicalChanged;
|
||||
if (!sentToolCalls.has(callId) || shouldEmit) {
|
||||
emittedToolInputs.set(callId, toolInput);
|
||||
sentToolCalls.add(callId);
|
||||
session.sendAgentMessage({
|
||||
type: 'tool-call',
|
||||
@@ -439,6 +490,7 @@ export async function opencodeLocalLauncher(
|
||||
});
|
||||
}
|
||||
sentToolResults.add(callId);
|
||||
emittedToolInputs.delete(callId);
|
||||
session.sendAgentMessage({
|
||||
type: 'tool-call-result',
|
||||
callId,
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { canonicalizeDiffToolInput } from '@/agent/utils';
|
||||
import { isUsableToolInput, parseToolCall, parseToolResult } from './opencodeLocalToolParse';
|
||||
|
||||
/** Mirrors the launcher's execute-hook normalization: pairing signatures are
|
||||
* computed from the RAW name/input BEFORE canonicalizing, so an id-less
|
||||
* `before` shares a queue key with the full `after`; canonicalization only
|
||||
* affects what gets emitted. */
|
||||
function canonicalizeHookPair(name: string, input: unknown): { name: string; input: unknown } {
|
||||
const canonical = canonicalizeDiffToolInput(input, name);
|
||||
return canonical ? { name: canonical.name, input: canonical.input } : { name, input };
|
||||
}
|
||||
|
||||
/** 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 emittedToolInputs = new Map<string, unknown>();
|
||||
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 });
|
||||
if (toolCall && isUsableToolInput(toolCall.input) && !sentToolResults.has(toolCall.callId)) {
|
||||
const previousInput = emittedToolInputs.get(toolCall.callId);
|
||||
const previousCanonical = canonicalizeDiffToolInput(previousInput, toolCall.name);
|
||||
const currentCanonical = canonicalizeDiffToolInput(toolCall.input, toolCall.name);
|
||||
const canonicalChanged = currentCanonical !== null
|
||||
&& hashObject(currentCanonical) !== hashObject(previousCanonical);
|
||||
const shouldEmit = previousInput === undefined || canonicalChanged;
|
||||
if (shouldEmit) {
|
||||
emittedToolInputs.set(toolCall.callId, toolCall.input);
|
||||
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)) {
|
||||
@@ -21,6 +41,7 @@ function collectMessages(parts: unknown[]): Array<{ type: string; name?: string;
|
||||
out.push({ type: 'tool-call', name: toolCall.name, callId: toolCall.callId, input: toolCall.input });
|
||||
}
|
||||
sentToolResults.add(toolResult.callId);
|
||||
emittedToolInputs.delete(toolResult.callId);
|
||||
out.push({ type: 'tool-call-result', callId: toolResult.callId, output: toolResult.output });
|
||||
}
|
||||
}
|
||||
@@ -41,6 +62,51 @@ describe('OpenCode local tool part parsing', () => {
|
||||
})).toMatchObject({ title: 'Run project tests' });
|
||||
});
|
||||
|
||||
it('canonicalizes edit tool parts to the Edit view shape', () => {
|
||||
expect(parseToolCall({
|
||||
type: 'tool',
|
||||
tool: 'edit',
|
||||
callID: 'call-edit-canonical',
|
||||
state: {
|
||||
status: 'running',
|
||||
input: { filePath: '/tmp/a.ts', oldString: 'foo', newString: 'bar' }
|
||||
}
|
||||
})).toEqual({
|
||||
callId: 'call-edit-canonical',
|
||||
name: 'Edit',
|
||||
input: { file_path: '/tmp/a.ts', old_string: 'foo', new_string: 'bar' }
|
||||
});
|
||||
});
|
||||
|
||||
it('canonicalizes write tool parts to the Write view shape', () => {
|
||||
expect(parseToolCall({
|
||||
type: 'tool',
|
||||
tool: 'write',
|
||||
callID: 'call-write-canonical',
|
||||
state: {
|
||||
status: 'running',
|
||||
input: { filePath: '/tmp/b.txt', content: 'hello\n' }
|
||||
}
|
||||
})).toEqual({
|
||||
callId: 'call-write-canonical',
|
||||
name: 'Write',
|
||||
input: { file_path: '/tmp/b.txt', content: 'hello\n' }
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-diff tool parts untouched', () => {
|
||||
expect(parseToolCall({
|
||||
type: 'tool',
|
||||
tool: 'bash',
|
||||
callID: 'call-bash-canonical',
|
||||
state: { status: 'running', input: { command: 'ls -la' } }
|
||||
})).toEqual({
|
||||
callId: 'call-bash-canonical',
|
||||
name: 'bash',
|
||||
input: { command: 'ls -la' }
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
@@ -125,6 +191,38 @@ describe('OpenCode local tool part parsing', () => {
|
||||
expect(messages.map((m) => m.type)).toEqual(['tool-call', 'tool-call-result']);
|
||||
expect(messages[0].input).toEqual({ command: 'echo hi' });
|
||||
});
|
||||
|
||||
it('re-emits a running write when content changes from empty to final (message.part.updated path)', () => {
|
||||
const callId = 'call-write-stream';
|
||||
const messages = collectMessages([
|
||||
{
|
||||
type: 'tool',
|
||||
tool: 'write',
|
||||
callID: callId,
|
||||
state: { status: 'running', input: { filePath: '/tmp/b.txt', content: '' } }
|
||||
},
|
||||
{
|
||||
type: 'tool',
|
||||
tool: 'write',
|
||||
callID: callId,
|
||||
state: { status: 'running', input: { filePath: '/tmp/b.txt', content: 'final\n' } }
|
||||
},
|
||||
{
|
||||
type: 'tool',
|
||||
tool: 'write',
|
||||
callID: callId,
|
||||
state: { status: 'completed', input: { filePath: '/tmp/b.txt', content: 'final\n' }, output: 'wrote' }
|
||||
}
|
||||
]);
|
||||
const calls = messages.filter((m) => m.type === 'tool-call');
|
||||
const results = messages.filter((m) => m.type === 'tool-call-result');
|
||||
expect(calls.length).toBe(2);
|
||||
expect(results.length).toBe(1);
|
||||
expect(calls[calls.length - 1]).toMatchObject({
|
||||
name: 'Write',
|
||||
input: { file_path: '/tmp/b.txt', content: 'final\n' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function hashObject(obj: unknown): string {
|
||||
@@ -164,14 +262,29 @@ function collectExecuteHookMessages(
|
||||
): Array<{ type: string; name?: string; callId: string; input?: unknown; output?: unknown }> {
|
||||
const sentToolCalls = new Set<string>();
|
||||
const sentToolResults = new Set<string>();
|
||||
const emittedToolInputs = new Map<string, unknown>();
|
||||
const toolExecutionQueues = new Map<string, string[]>();
|
||||
const out: Array<{ type: string; name?: string; callId: string; input?: unknown; output?: unknown }> = [];
|
||||
let nextId = 0;
|
||||
|
||||
const shouldEmit = (callId: string, toolInput: unknown, hint: string): boolean => {
|
||||
const previousInput = emittedToolInputs.get(callId);
|
||||
const previousCanonical = canonicalizeDiffToolInput(previousInput, hint);
|
||||
const currentCanonical = canonicalizeDiffToolInput(toolInput, hint);
|
||||
const canonicalChanged = currentCanonical !== null
|
||||
&& hashObject(currentCanonical) !== hashObject(previousCanonical);
|
||||
return previousInput === undefined || canonicalChanged;
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
const toolInput = event.input;
|
||||
const signature = buildToolSignature(event.name, toolInput);
|
||||
// Signature is derived from the raw name/input BEFORE canonicalizing
|
||||
// (mirrors opencodeLocalLauncher): an id-less `before` with empty or
|
||||
// partial args must share a queue key with the full `after`.
|
||||
const signature = buildToolSignature(event.name, event.input);
|
||||
const fallbackSignature = buildToolSignature(event.name, null);
|
||||
const normalized = canonicalizeHookPair(event.name, event.input);
|
||||
const eventName = normalized.name;
|
||||
const toolInput = normalized.input;
|
||||
const existingId = event.id ?? null;
|
||||
const isBefore = event.type === 'before';
|
||||
const usableInput = isUsableToolInput(toolInput);
|
||||
@@ -194,10 +307,11 @@ function collectExecuteHookMessages(
|
||||
} else {
|
||||
pushQueue(toolExecutionQueues, fallbackSignature, callId);
|
||||
}
|
||||
if (!sentToolCalls.has(callId)) {
|
||||
if (!sentToolResults.has(callId) && shouldEmit(callId, toolInput, eventName)) {
|
||||
if (!usableInput) continue;
|
||||
emittedToolInputs.set(callId, toolInput);
|
||||
sentToolCalls.add(callId);
|
||||
out.push({ type: 'tool-call', name: event.name, callId, input: toolInput });
|
||||
out.push({ type: 'tool-call', name: eventName, callId, input: toolInput });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -207,11 +321,13 @@ function collectExecuteHookMessages(
|
||||
removeFromQueue(toolExecutionQueues, fallbackSignature, callId);
|
||||
}
|
||||
if (!sentToolResults.has(callId)) {
|
||||
if (!sentToolCalls.has(callId)) {
|
||||
if (!sentToolCalls.has(callId) || shouldEmit(callId, toolInput, eventName)) {
|
||||
emittedToolInputs.set(callId, toolInput);
|
||||
sentToolCalls.add(callId);
|
||||
out.push({ type: 'tool-call', name: event.name, callId, input: toolInput });
|
||||
out.push({ type: 'tool-call', name: eventName, callId, input: toolInput });
|
||||
}
|
||||
sentToolResults.add(callId);
|
||||
emittedToolInputs.delete(callId);
|
||||
out.push({ type: 'tool-call-result', callId, output: event.output });
|
||||
}
|
||||
}
|
||||
@@ -239,4 +355,81 @@ describe('OpenCode local execute-hook tool emit policy', () => {
|
||||
{ type: 'tool-call-result', callId: 'stable-1', output: 'ok' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('canonicalizes edit before/after pairs emitted via the execute hook path', () => {
|
||||
const messages = collectExecuteHookMessages([
|
||||
{ type: 'before', name: 'edit', input: {} },
|
||||
{ type: 'after', name: 'edit', input: { filePath: '/tmp/a.ts', oldString: 'foo', newString: 'bar' }, output: 'ok' }
|
||||
]);
|
||||
expect(messages.map((m) => m.type)).toEqual(['tool-call', 'tool-call-result']);
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
callId: messages[1].callId,
|
||||
input: { file_path: '/tmp/a.ts', old_string: 'foo', new_string: 'bar' }
|
||||
});
|
||||
});
|
||||
|
||||
it('upgrades a partial edit before to the full canonical input when after arrives', () => {
|
||||
// Regression: a partial native `before` ({filePath}) is emitted as-is.
|
||||
// When the full `after` arrives it must REPLACE the earlier partial call
|
||||
// so the web Edit view receives the complete old_string/new_string args.
|
||||
// The replacement is a second tool-call (same callId) followed by the result.
|
||||
const messages = collectExecuteHookMessages([
|
||||
{ type: 'before', name: 'edit', input: { filePath: '/tmp/a.ts' } },
|
||||
{ type: 'after', name: 'edit', input: { filePath: '/tmp/a.ts', oldString: 'foo', newString: 'bar' }, output: 'ok' }
|
||||
]);
|
||||
expect(messages.map((m) => m.type)).toEqual(['tool-call', 'tool-call', 'tool-call-result']);
|
||||
expect(messages[0].callId).toEqual(messages[1].callId);
|
||||
expect(messages[0].callId).toEqual(messages[2].callId);
|
||||
expect(messages[1]).toMatchObject({
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
input: { file_path: '/tmp/a.ts', old_string: 'foo', new_string: 'bar' }
|
||||
});
|
||||
});
|
||||
|
||||
it('canonicalizes write tool calls emitted via the execute hook path', () => {
|
||||
const messages = collectExecuteHookMessages([
|
||||
{ type: 'before', name: 'write', id: 'hook-write-1', input: { filePath: '/tmp/b.txt', content: 'hi\n' } },
|
||||
{ type: 'after', name: 'write', id: 'hook-write-1', input: { filePath: '/tmp/b.txt', content: 'hi\n' }, output: 'wrote' }
|
||||
]);
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: 'tool-call',
|
||||
name: 'Write',
|
||||
input: { file_path: '/tmp/b.txt', content: 'hi\n' }
|
||||
});
|
||||
});
|
||||
|
||||
it('releases completed calls from upgrade tracking (no re-emit after result)', () => {
|
||||
// Once a call is completed (tool-call-result emitted), the upgrade map
|
||||
// entry is released. A later stale part for the same callId must not
|
||||
// trigger a replacement emit or a duplicate result — the lifecycle is
|
||||
// already closed by sentToolResults.
|
||||
const messages = collectExecuteHookMessages([
|
||||
{ type: 'before', name: 'edit', id: 'hook-e-1', input: { filePath: '/tmp/a.ts' } },
|
||||
{ type: 'after', name: 'edit', id: 'hook-e-1', input: { filePath: '/tmp/a.ts', oldString: 'foo', newString: 'bar' }, output: 'ok' },
|
||||
{ type: 'after', name: 'edit', id: 'hook-e-1', input: { filePath: '/tmp/a.ts', oldString: 'x', newString: 'y' }, output: 'late' }
|
||||
]);
|
||||
expect(messages.map((m) => m.type)).toEqual(['tool-call', 'tool-call', 'tool-call-result']);
|
||||
expect(messages.filter((m) => m.type === 'tool-call').length).toBe(2);
|
||||
expect(messages.filter((m) => m.type === 'tool-call-result').length).toBe(1);
|
||||
});
|
||||
|
||||
it('re-emits a canonical write when content changes from empty to final', () => {
|
||||
// {filePath, content:""} already canonicalizes, so the previous
|
||||
// null->canonical rule would suppress the final content. The canonical
|
||||
// value itself changed, so a replacement tool-call must be emitted.
|
||||
const messages = collectExecuteHookMessages([
|
||||
{ type: 'before', name: 'write', id: 'hook-w-1', input: { filePath: '/tmp/b.txt', content: '' } },
|
||||
{ type: 'after', name: 'write', id: 'hook-w-1', input: { filePath: '/tmp/b.txt', content: 'final\n' }, output: 'wrote' }
|
||||
]);
|
||||
expect(messages.map((m) => m.type)).toEqual(['tool-call', 'tool-call', 'tool-call-result']);
|
||||
expect(messages[0].callId).toEqual(messages[1].callId);
|
||||
expect(messages[1]).toMatchObject({
|
||||
type: 'tool-call',
|
||||
name: 'Write',
|
||||
input: { file_path: '/tmp/b.txt', content: 'final\n' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isObject } from '@hapi/protocol';
|
||||
import { canonicalizeDiffToolInput } from '@/agent/utils';
|
||||
|
||||
export type ParsedToolCall = {
|
||||
callId: string;
|
||||
@@ -69,6 +70,19 @@ export function parseToolCall(part: unknown): ParsedToolCall | null {
|
||||
if (!name || !callId) {
|
||||
return null;
|
||||
}
|
||||
// OpenCode native edit/write args ({filePath, oldString, newString} /
|
||||
// {filePath, content}) are canonicalized to the Claude-shaped inputs the
|
||||
// web Edit/Write views render — same contract as the ACP path.
|
||||
const toParsed = (input: unknown, title: string | null): ParsedToolCall => {
|
||||
const canonical = canonicalizeDiffToolInput(input, name);
|
||||
return {
|
||||
callId,
|
||||
name: canonical ? canonical.name : name,
|
||||
input: canonical ? canonical.input : input,
|
||||
...(title ? { title } : {})
|
||||
};
|
||||
};
|
||||
|
||||
if (isObject(record.state)) {
|
||||
const state = record.state as Record<string, unknown>;
|
||||
const status = getString(state.status);
|
||||
@@ -78,12 +92,10 @@ export function parseToolCall(part: unknown): ParsedToolCall | null {
|
||||
return null;
|
||||
}
|
||||
const input = parseMaybeJson(state.input ?? state.raw ?? record.input ?? record.args ?? record.arguments);
|
||||
const title = getString(state.title);
|
||||
return { callId, name, input, ...(title ? { title } : {}) };
|
||||
return toParsed(input, getString(state.title));
|
||||
}
|
||||
const input = parseMaybeJson(record.input ?? record.args ?? record.arguments ?? record.raw);
|
||||
const title = getString(record.title);
|
||||
return { callId, name, input, ...(title ? { title } : {}) };
|
||||
return toParsed(input, getString(record.title));
|
||||
}
|
||||
|
||||
export function parseToolResult(part: unknown): ParsedToolResult | null {
|
||||
|
||||
Reference in New Issue
Block a user