fix(query): shallow-copy messages before stripping toolUseResult

Previously the per-query cleanup mutated messagesForQuery entries in
place via `delete msg.toolUseResult`. Those entries are references
shared with mutableMessages (UI state), so the delete stripped the
field from the live message object. The next query can start within
milliseconds of tool_result creation — before the React UI commit
lands — so UserToolSuccessMessage's `!message.toolUseResult` check
returned null and tool.renderToolResultMessage was never called,
leaving tool-result rows blank.

Map to a stripped copy instead so mutableMessages keeps the original
for the UI. Downstream API transformations (applyToolResultBudget,
snip, microcompact) already build new arrays via .map(), so they
compose cleanly with this copy.

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-20 19:33:51 +08:00
parent 2e29e362b1
commit 14f43e9676

View File

@@ -522,21 +522,35 @@ async function* queryLoop(
let messagesForQuery = getMessagesAfterCompactBoundary(messages)
// Release toolUseResult payloads from previous turns. By this point the
// UI has already rendered those results and the next API call only needs
// message.message.content (tool_result blocks), not the raw output object.
// This prevents unbounded memory growth in long sessions before compact
// triggers — a single FileRead of a 400KB file would otherwise stay in
// mutableMessages forever.
for (const msg of messagesForQuery) {
// Release toolUseResult payloads from previous turns — the next API call
// only needs message.message.content (tool_result blocks), not the raw
// output object. This prevents unbounded memory growth in long sessions
// before compact triggers (a single FileRead of a 400KB file would
// otherwise stay in mutableMessages forever).
//
// IMPORTANT: shallow-copy rather than mutate. messagesForQuery elements
// are references shared with mutableMessages (UI state); deleting
// toolUseResult in place strips it from the live message while React may
// still be rendering it. The next query can start within milliseconds of
// tool_result creation (model immediately calls the next tool), before
// the UI commit lands — UserToolSuccessMessage reads
// message.toolUseResult to delegate to tool.renderToolResultMessage, so a
// mutation race makes tool-result rows render blank. Map to a stripped
// copy so mutableMessages keeps the original for the UI; downstream API
// transformations (applyToolResultBudget, snip, microcompact) already
// build new arrays via .map(), so they compose cleanly with this copy.
messagesForQuery = messagesForQuery.map(msg => {
if (
msg.type === 'user' &&
'toolUseResult' in msg &&
msg.toolUseResult !== undefined
msg.type !== 'user' ||
!('toolUseResult' in msg) ||
(msg as { toolUseResult?: unknown }).toolUseResult === undefined
) {
delete (msg as Message & { toolUseResult?: unknown }).toolUseResult
return msg
}
}
const copy: typeof msg = { ...msg }
delete (copy as Message & { toolUseResult?: unknown }).toolUseResult
return copy
})
let tracking = autoCompactTracking