one card per tool call, however often it is announced

a tool's input ARRIVES — the SDK emits tool:start again as more of it
streams in — and this appended a new message every time. an AskUserQuestion
therefore rendered twice: once half-built, with two of its three options
still missing their descriptions, then again complete. the second carried
the answer; the first sat above it forever, showing an unanswered question
that could not be answered.

upsert on toolCallId instead, and only when there is one — a call with no
id cannot be recognised later, and collapsing those on anything else would
merge two genuinely different calls.

the existing message's other fields survive: `output` arrives on
tool:result, and a late tool:start must not erase it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 05:49:27 +00:00
co-authored by Claude Opus 5
parent d4b26be706
commit e201bceafb
+25 -3
View File
@@ -456,10 +456,32 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
toolInput: msg.toolInput,
toolCallId: msg.toolCallId,
};
/*
One card per tool call, however many times the harness announces it.
A tool's input ARRIVES — the SDK emits `tool:start` again as more of it streams in — and this
appended every time, so an AskUserQuestion showed up twice: once half-built with two of its three
options still missing their descriptions, then again complete. The second carried the answer and
the first sat above it forever, unanswerable.
Keyed on `toolCallId`, and only when there is one: a call without an id cannot be recognised
later, and collapsing those on anything else would merge two genuinely different calls.
*/
const upsert = (list: ChatMessage[]): ChatMessage[] => {
const at = msg.toolCallId ? list.findIndex((m) => m.role === 'tool' && m.toolCallId === msg.toolCallId) : -1;
if (at === -1) return [...list, call];
// Keep whatever the earlier event already carried — `output` arrives on `tool:result` and a
// late `tool:start` must not erase it.
const next = [...list];
const existing = next[at]!;
if (existing.role !== 'tool') return [...list, call];
next[at] = { ...existing, toolName: msg.toolName, toolInput: msg.toolInput };
return next;
};
const parent = msg.parentToolUseId;
setMessages((prev) =>
parent ? (withChildren(prev, parent, (kids) => [...kids, call]) ?? [...prev, call]) : [...prev, call],
);
setMessages((prev) => (parent ? (withChildren(prev, parent, upsert) ?? upsert(prev)) : upsert(prev)));
break;
}