From e5617546e720ae6d1ef32ee2d2c63511036fc7cf Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 27 Jan 2026 11:11:55 -0500 Subject: [PATCH] fix: use ID-based lookup for parallel tool finalization Replaces map-based index tracking with direct ID lookup when finalizing streaming tool calls. This prevents duplicate rendering of parallel tools by ensuring idempotent processing. Changes: - Use findIndex with partial===true check instead of streamingToolCallIndices - Skip already-finalized tools to eliminate race conditions - Works for both sequential and parallel tool calling modes The previous approach relied on streamingToolCallIndices map which could become stale when multiple code paths (finalization loop, sweep fix, pending updates) process the same tool, causing duplicate rendering. --- src/core/task/Task.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 11eebde78fb..cee87f7cb89 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3240,17 +3240,28 @@ export class Task extends EventEmitter implements TaskLike { // Finalize the streaming tool call const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id) - // Get the index for this tool call - const toolUseIndex = this.streamingToolCallIndices.get(event.id) + // Find by ID, not tracked index - can't get stale + // Only match partial blocks to ensure idempotent processing + // If a tool was already finalized (partial === false), skip it + const toolIndex = this.assistantMessageContent.findIndex( + (block) => + (block.type === "tool_use" || block.type === "mcp_tool_use") && + (block as any).id === event.id && + block.partial === true, + ) + + if (toolIndex === -1) { + // Already finalized - clean up tracking and skip + this.streamingToolCallIndices.delete(event.id) + continue + } if (finalToolUse) { // Store the tool call ID ;(finalToolUse as any).id = event.id - // Get the index and replace partial with final - if (toolUseIndex !== undefined) { - this.assistantMessageContent[toolUseIndex] = finalToolUse - } + // Replace partial with final + this.assistantMessageContent[toolIndex] = finalToolUse // Clean up tracking this.streamingToolCallIndices.delete(event.id) @@ -3260,11 +3271,11 @@ export class Task extends EventEmitter implements TaskLike { // Present the finalized tool call presentAssistantMessage(this) - } else if (toolUseIndex !== undefined) { + } else { // finalizeStreamingToolCall returned null (malformed JSON or missing args) // We still need to mark the tool as non-partial so it gets executed // The tool's validation will catch any missing required parameters - const existingToolUse = this.assistantMessageContent[toolUseIndex] + const existingToolUse = this.assistantMessageContent[toolIndex] if (existingToolUse && existingToolUse.type === "tool_use") { existingToolUse.partial = false // Ensure it has the ID for native protocol