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:
Junmo Kim
2026-10-28 12:17:14 +08:00
committed by GitHub
parent 83088aae73
commit a4a5779829
10 changed files with 920 additions and 58 deletions
+74 -22
View File
@@ -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 {