From 14f43e9676a597a144d9c84b44c070eb49bf36ab Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 20 Jun 2026 19:33:51 +0800 Subject: [PATCH] fix(query): shallow-copy messages before stripping toolUseResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/query.ts | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/query.ts b/src/query.ts index 3b2053158..79d17b579 100644 --- a/src/query.ts +++ b/src/query.ts @@ -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