From f8b0fe66a4a14690999800a4b977b6f95d28ae5f Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 26 Jan 2026 16:07:53 -0500 Subject: [PATCH 1/3] Enable parallel tool calling with new_task isolation safeguards - Add CRITICAL warning to new_task tool description - Implement double array truncation to prevent tools after new_task - Save assistant message before tool execution to prevent orphaned results - Make didAlreadyUseTool enforcement conditional on experiment flag - Add 14 comprehensive tests for new_task isolation - Enable feature toggle (default: OFF) - Make settings UI toggle visible Fixes Linear issue EXT-629 --- .../presentAssistantMessage.ts | 21 +- .../sections/__tests__/tool-use.spec.ts | 4 +- .../prompts/tools/native-tools/new_task.ts | 4 +- src/core/task/Task.ts | 80 +++- .../task/__tests__/new-task-isolation.spec.ts | 415 ++++++++++++++++++ .../settings/ExperimentalSettings.tsx | 2 - 6 files changed, 500 insertions(+), 26 deletions(-) create mode 100644 src/core/task/__tests__/new-task-isolation.spec.ts diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index e3f652d352a..8fe42ce0079 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -125,7 +125,11 @@ export async function presentAssistantMessage(cline: Task) { break } - if (cline.didAlreadyUseTool) { + // Get parallel tool calling state from experiments + const mcpState = await cline.providerRef.deref()?.getState() + const mcpParallelToolCallsEnabled = mcpState?.experiments?.multipleNativeToolCalls ?? false + + if (!mcpParallelToolCallsEnabled && cline.didAlreadyUseTool) { const toolCallId = mcpBlock.id const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.` @@ -193,7 +197,10 @@ export async function presentAssistantMessage(cline: Task) { } hasToolResult = true - cline.didAlreadyUseTool = true + // Only set didAlreadyUseTool when parallel tool calling is disabled + if (!mcpParallelToolCallsEnabled) { + cline.didAlreadyUseTool = true + } } const toolDescription = () => `[mcp_tool: ${mcpBlock.serverName}/${mcpBlock.toolName}]` @@ -431,7 +438,10 @@ export async function presentAssistantMessage(cline: Task) { break } - if (cline.didAlreadyUseTool) { + // Get parallel tool calling state from experiments (stateExperiments already fetched above) + const parallelToolCallsEnabled = stateExperiments?.multipleNativeToolCalls ?? false + + if (!parallelToolCallsEnabled && cline.didAlreadyUseTool) { // Ignore any content after a tool has already been used. // For native tool calling, we must send a tool_result for every tool_use to avoid API errors const errorMessage = `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.` @@ -530,7 +540,10 @@ export async function presentAssistantMessage(cline: Task) { } hasToolResult = true - cline.didAlreadyUseTool = true + // Only set didAlreadyUseTool when parallel tool calling is disabled + if (!parallelToolCallsEnabled) { + cline.didAlreadyUseTool = true + } } const askApproval = async ( diff --git a/src/core/prompts/sections/__tests__/tool-use.spec.ts b/src/core/prompts/sections/__tests__/tool-use.spec.ts index 1d945612ea4..fc220d1acd3 100644 --- a/src/core/prompts/sections/__tests__/tool-use.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use.spec.ts @@ -1,8 +1,8 @@ import { getSharedToolUseSection } from "../tool-use" describe("getSharedToolUseSection", () => { - describe("native tool calling", () => { - it("should include one tool per message requirement when experiment is disabled", () => { + describe("with MULTIPLE_NATIVE_TOOL_CALLS disabled (default)", () => { + it("should include one tool per message requirement when experiment is disabled (default)", () => { // No experiment flags passed (default: disabled) const section = getSharedToolUseSection() diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index c2dcb8b9abe..f8e29e549d9 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -1,6 +1,8 @@ import type OpenAI from "openai" -const NEW_TASK_DESCRIPTION = `This will let you create a new task instance in the chosen mode using your provided message and initial todo list (if required).` +const NEW_TASK_DESCRIPTION = `Create a new task instance in the chosen mode using your provided message and initial todo list (if required). + +CRITICAL: This tool MUST be called alone. Do NOT call this tool alongside other tools in the same message turn. If you need to gather information before delegating, use other tools in a separate turn first, then call new_task by itself in the next turn.` const MODE_PARAMETER_DESCRIPTION = `Slug of the mode to begin the new task in (e.g., code, debug, architect)` diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d2be23714e2..ce22b31c026 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3290,16 +3290,6 @@ export class Task extends EventEmitter implements TaskLike { // No legacy streaming parser to finalize. - // Present any partial blocks that were just completed. - // Tool calls are typically presented during streaming via tool_call_partial events, - // but we still present here if any partial blocks remain (e.g., malformed streams). - if (partialBlocks.length > 0) { - // If there is content to update then it will complete and - // update `this.userMessageContentReady` to true, which we - // `pWaitFor` before making the next request. - presentAssistantMessage(this) - } - // Note: updateApiReqMsg() is now called from within drainStreamInBackgroundToFindAllUsage // to ensure usage data is captured even when the stream is interrupted. The background task // uses local variables to accumulate usage data before atomically updating the shared state. @@ -3324,10 +3314,10 @@ export class Task extends EventEmitter implements TaskLike { // No legacy text-stream tool parser state to reset. - // Now add to apiConversationHistory. - // Need to save assistant responses to file before proceeding to - // tool use since user can exit at any moment and we wouldn't be - // able to save the assistant's response. + // CRITICAL: Save assistant message to API history BEFORE executing tools. + // This ensures that when new_task triggers delegation and calls flushPendingToolResultsToHistory(), + // the assistant message is already in history. Otherwise, tool_result blocks would appear + // BEFORE their corresponding tool_use blocks, causing API errors. // Check if we have any content to process (text or tool uses) const hasTextContent = assistantMessage.length > 0 @@ -3424,13 +3414,69 @@ export class Task extends EventEmitter implements TaskLike { } } + // Enforce new_task isolation: if new_task is called alongside other tools, + // truncate any tools that come after it and inject error tool_results. + // This prevents orphaned tools when delegation disposes the parent task. + const newTaskIndex = assistantContent.findIndex( + (block) => block.type === "tool_use" && block.name === "new_task", + ) + + if (newTaskIndex !== -1 && newTaskIndex < assistantContent.length - 1) { + // new_task found but not last - truncate subsequent tools + const truncatedTools = assistantContent.slice(newTaskIndex + 1) + assistantContent.length = newTaskIndex + 1 // Truncate API history array + + // ALSO truncate the execution array (assistantMessageContent) to prevent + // tools after new_task from being executed by presentAssistantMessage(). + // Find new_task index in assistantMessageContent (may differ from assistantContent + // due to text blocks being structured differently). + const executionNewTaskIndex = this.assistantMessageContent.findIndex( + (block) => block.type === "tool_use" && block.name === "new_task", + ) + if (executionNewTaskIndex !== -1) { + this.assistantMessageContent.length = executionNewTaskIndex + 1 + } + + // Pre-inject error tool_results for truncated tools + for (const tool of truncatedTools) { + if (tool.type === "tool_use" && (tool as Anthropic.ToolUseBlockParam).id) { + this.pushToolResultToUserContent({ + type: "tool_result", + tool_use_id: (tool as Anthropic.ToolUseBlockParam).id, + content: + "This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", + is_error: true, + }) + } + } + } + + // Save assistant message BEFORE executing tools + // This is critical for new_task: when it triggers delegation, flushPendingToolResultsToHistory() + // will save the user message with tool_results. The assistant message must already be in history + // so that tool_result blocks appear AFTER their corresponding tool_use blocks. await this.addToApiConversationHistory( { role: "assistant", content: assistantContent }, reasoningMessage || undefined, ) TelemetryService.instance.captureConversationMessage(this.taskId, "assistant") + } + + // Present any partial blocks that were just completed. + // Tool calls are typically presented during streaming via tool_call_partial events, + // but we still present here if any partial blocks remain (e.g., malformed streams). + // NOTE: This MUST happen AFTER saving the assistant message to API history. + // When new_task is in the batch, it triggers delegation which calls flushPendingToolResultsToHistory(). + // If the assistant message isn't saved yet, tool_results would appear before tool_use blocks. + if (partialBlocks.length > 0) { + // If there is content to update then it will complete and + // update `this.userMessageContentReady` to true, which we + // `pWaitFor` before making the next request. + presentAssistantMessage(this) + } + if (hasTextContent || hasToolUses) { // NOTE: This comment is here for future reference - this was a // workaround for `userMessageContent` not getting set to true. // It was due to it not recursively calling for partial blocks @@ -4128,9 +4174,9 @@ export class Task extends EventEmitter implements TaskLike { const shouldIncludeTools = allTools.length > 0 - // Parallel tool calls are disabled - feature is on hold - // Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS) - const parallelToolCallsEnabled = false + // Parallel tool calls can now be enabled safely with new_task isolation enforcement + // The runtime enforcement at line ~3427 prevents tools after new_task from executing + const parallelToolCallsEnabled = state?.experiments?.multipleNativeToolCalls ?? false const metadata: ApiHandlerCreateMessageMetadata = { mode: mode, diff --git a/src/core/task/__tests__/new-task-isolation.spec.ts b/src/core/task/__tests__/new-task-isolation.spec.ts new file mode 100644 index 00000000000..9100fb33993 --- /dev/null +++ b/src/core/task/__tests__/new-task-isolation.spec.ts @@ -0,0 +1,415 @@ +/** + * Tests for new_task tool isolation enforcement. + * + * These tests verify the runtime enforcement that prevents tools from executing + * after `new_task` in parallel tool calls. When `new_task` is called alongside + * other tools, any tools that come after it in the assistant message are truncated + * and their tool_results are pre-injected with error messages. + * + * This prevents orphaned tools when delegation disposes the parent task. + */ + +import type { Anthropic } from "@anthropic-ai/sdk" + +describe("new_task Tool Isolation Enforcement", () => { + /** + * Simulates the new_task isolation enforcement logic from Task.ts. + * This tests the truncation and error injection that happens when building + * assistant message content for the API. + */ + const enforceNewTaskIsolation = ( + assistantContent: Array, + ): { + truncatedContent: Array + injectedToolResults: Anthropic.ToolResultBlockParam[] + } => { + const injectedToolResults: Anthropic.ToolResultBlockParam[] = [] + + // Find the index of new_task tool in the assistantContent array + const newTaskIndex = assistantContent.findIndex( + (block) => block.type === "tool_use" && block.name === "new_task", + ) + + if (newTaskIndex !== -1 && newTaskIndex < assistantContent.length - 1) { + // new_task found but not last - truncate subsequent tools + const truncatedTools = assistantContent.slice(newTaskIndex + 1) + const truncatedContent = assistantContent.slice(0, newTaskIndex + 1) + + // Pre-inject error tool_results for truncated tools + for (const tool of truncatedTools) { + if (tool.type === "tool_use" && tool.id) { + injectedToolResults.push({ + type: "tool_result", + tool_use_id: tool.id, + content: + "This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", + is_error: true, + }) + } + } + + return { truncatedContent, injectedToolResults } + } + + return { truncatedContent: assistantContent, injectedToolResults: [] } + } + + describe("new_task as last tool (no truncation needed)", () => { + it("should not truncate when new_task is the only tool", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.truncatedContent).toHaveLength(1) + expect(result.injectedToolResults).toHaveLength(0) + }) + + it("should not truncate when new_task is the last tool", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.truncatedContent).toHaveLength(2) + expect(result.injectedToolResults).toHaveLength(0) + }) + + it("should not truncate when there is no new_task tool", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + { + type: "tool_use", + id: "toolu_write_1", + name: "write_to_file", + input: { path: "test.txt", content: "hello" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.truncatedContent).toHaveLength(2) + expect(result.injectedToolResults).toHaveLength(0) + }) + }) + + describe("new_task followed by other tools (truncation required)", () => { + it("should truncate tools after new_task", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.truncatedContent).toHaveLength(1) + expect(result.truncatedContent[0].type).toBe("tool_use") + expect((result.truncatedContent[0] as Anthropic.ToolUseBlockParam).name).toBe("new_task") + }) + + it("should inject error tool_results for truncated tools", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.injectedToolResults).toHaveLength(1) + expect(result.injectedToolResults[0]).toMatchObject({ + type: "tool_result", + tool_use_id: "toolu_read_1", + is_error: true, + }) + expect(result.injectedToolResults[0].content).toContain("new_task was called") + }) + + it("should truncate multiple tools after new_task", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + { + type: "tool_use", + id: "toolu_write_1", + name: "write_to_file", + input: { path: "test.txt", content: "hello" }, + }, + { + type: "tool_use", + id: "toolu_execute_1", + name: "execute_command", + input: { command: "ls" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.truncatedContent).toHaveLength(1) + expect(result.injectedToolResults).toHaveLength(3) + + // Verify all truncated tools get error results + const truncatedIds = result.injectedToolResults.map((r) => r.tool_use_id) + expect(truncatedIds).toContain("toolu_read_1") + expect(truncatedIds).toContain("toolu_write_1") + expect(truncatedIds).toContain("toolu_execute_1") + }) + + it("should preserve tools before new_task", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + { + type: "tool_use", + id: "toolu_write_1", + name: "write_to_file", + input: { path: "test.txt", content: "hello" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + // Should preserve read_file and new_task, truncate write_to_file + expect(result.truncatedContent).toHaveLength(2) + expect((result.truncatedContent[0] as Anthropic.ToolUseBlockParam).name).toBe("read_file") + expect((result.truncatedContent[1] as Anthropic.ToolUseBlockParam).name).toBe("new_task") + + // Should inject error for write_to_file only + expect(result.injectedToolResults).toHaveLength(1) + expect(result.injectedToolResults[0].tool_use_id).toBe("toolu_write_1") + }) + }) + + describe("Mixed content (text and tools)", () => { + it("should handle text blocks before new_task", () => { + const assistantContent: Array = [ + { + type: "text", + text: "I will delegate this task.", + }, + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + // Should preserve text and new_task, truncate read_file + expect(result.truncatedContent).toHaveLength(2) + expect(result.truncatedContent[0].type).toBe("text") + expect((result.truncatedContent[1] as Anthropic.ToolUseBlockParam).name).toBe("new_task") + + expect(result.injectedToolResults).toHaveLength(1) + expect(result.injectedToolResults[0].tool_use_id).toBe("toolu_read_1") + }) + + it("should not count text blocks when checking if new_task is last tool", () => { + // This is a subtle case - if text comes AFTER new_task, we need to decide + // whether that counts as "new_task is last tool". The implementation only + // checks array position, so text after new_task means new_task is NOT last. + // However, text blocks don't need tool_results, so this is fine. + const assistantContent: Array = [ + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + { + type: "text", + text: "Done delegating.", + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + // Text after new_task gets truncated but doesn't need tool_result + expect(result.truncatedContent).toHaveLength(1) + expect(result.injectedToolResults).toHaveLength(0) // Text blocks don't get tool_results + }) + }) + + describe("Edge cases", () => { + it("should handle empty content array", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.truncatedContent).toHaveLength(0) + expect(result.injectedToolResults).toHaveLength(0) + }) + + it("should handle tool without id (should not inject error result)", () => { + const assistantContent: Array = [ + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + // Simulating a malformed tool without ID (shouldn't happen, but defensive) + { + type: "tool_use", + name: "read_file", + input: { path: "test.txt" }, + } as any, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.truncatedContent).toHaveLength(1) + // No error result for tool without ID + expect(result.injectedToolResults).toHaveLength(0) + }) + + it("should only consider the first new_task if multiple exist", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "First task" }, + }, + { + type: "tool_use", + id: "toolu_new_task_2", + name: "new_task", + input: { mode: "debug", message: "Second task" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + // Should find first new_task and truncate everything after it + expect(result.truncatedContent).toHaveLength(2) + expect((result.truncatedContent[0] as Anthropic.ToolUseBlockParam).name).toBe("read_file") + expect((result.truncatedContent[1] as Anthropic.ToolUseBlockParam).id).toBe("toolu_new_task_1") + + // Second new_task should get error result + expect(result.injectedToolResults).toHaveLength(1) + expect(result.injectedToolResults[0].tool_use_id).toBe("toolu_new_task_2") + }) + }) + + describe("Error message content", () => { + it("should include descriptive error message", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.injectedToolResults[0].content).toContain("new_task was called") + expect(result.injectedToolResults[0].content).toContain("must be the last tool") + }) + + it("should mark error results with is_error: true", () => { + const assistantContent: Anthropic.ToolUseBlockParam[] = [ + { + type: "tool_use", + id: "toolu_new_task_1", + name: "new_task", + input: { mode: "code", message: "Do something" }, + }, + { + type: "tool_use", + id: "toolu_read_1", + name: "read_file", + input: { path: "test.txt" }, + }, + ] + + const result = enforceNewTaskIsolation(assistantContent) + + expect(result.injectedToolResults[0].is_error).toBe(true) + }) + }) +}) diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 22a7ffb64db..23786ce0b98 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -51,8 +51,6 @@ export const ExperimentalSettings = ({
{Object.entries(experimentConfigsMap) .filter(([key]) => key in EXPERIMENT_IDS) - // Hide MULTIPLE_NATIVE_TOOL_CALLS - feature is on hold - .filter(([key]) => key !== "MULTIPLE_NATIVE_TOOL_CALLS") .map((config) => { // Use the same translation key pattern as ExperimentalFeature const experimentKey = config[0] From f3897331a3735a06e3d78adae0467a4ed6982ad2 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 26 Jan 2026 18:17:52 -0500 Subject: [PATCH 2/3] Fix double tool rendering by moving finalizeRawChunks before partialBlocks capture The issue was that partialBlocks were captured BEFORE finalizeRawChunks(), causing tools to be presented twice: 1. Once during finalizeRawChunks() -> presentAssistantMessage() 2. Again at line 3476 when presenting the captured partialBlocks By moving finalizeRawChunks() logic earlier and capturing partialBlocks AFTER finalization, we ensure each tool is only presented once. Fixes rendering issue where tools appeared duplicated in the UI. --- src/core/task/Task.ts | 107 ++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ce22b31c026..a3e2cfd6f7f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2983,58 +2983,6 @@ export class Task extends EventEmitter implements TaskLike { } } - // Finalize any remaining streaming tool calls that weren't explicitly ended - // This is critical for MCP tools which need tool_call_end events to be properly - // converted from ToolUse to McpToolUse via finalizeStreamingToolCall() - const finalizeEvents = NativeToolCallParser.finalizeRawChunks() - for (const event of finalizeEvents) { - if (event.type === "tool_call_end") { - // 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) - - 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 - } - - // Clean up tracking - this.streamingToolCallIndices.delete(event.id) - - // Mark that we have new content to process - this.userMessageContentReady = false - - // Present the finalized tool call - presentAssistantMessage(this) - } else if (toolUseIndex !== undefined) { - // 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] - if (existingToolUse && existingToolUse.type === "tool_use") { - existingToolUse.partial = false - // Ensure it has the ID for native protocol - ;(existingToolUse as any).id = event.id - } - - // Clean up tracking - this.streamingToolCallIndices.delete(event.id) - - // Mark that we have new content to process - this.userMessageContentReady = false - - // Present the tool call - validation will handle missing params - presentAssistantMessage(this) - } - } - } - // Create a copy of current token values to avoid race conditions const currentTokens = { input: inputTokens, @@ -3282,6 +3230,61 @@ export class Task extends EventEmitter implements TaskLike { // the case, `presentAssistantMessage` relies on these blocks either // to be completed or the user to reject a block in order to proceed // and eventually set userMessageContentReady to true.) + + // Finalize any remaining streaming tool calls that weren't explicitly ended + // This is critical for MCP tools which need tool_call_end events to be properly + // converted from ToolUse to McpToolUse via finalizeStreamingToolCall() + const finalizeEvents = NativeToolCallParser.finalizeRawChunks() + for (const event of finalizeEvents) { + if (event.type === "tool_call_end") { + // 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) + + 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 + } + + // Clean up tracking + this.streamingToolCallIndices.delete(event.id) + + // Mark that we have new content to process + this.userMessageContentReady = false + + // Present the finalized tool call + presentAssistantMessage(this) + } else if (toolUseIndex !== undefined) { + // 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] + if (existingToolUse && existingToolUse.type === "tool_use") { + existingToolUse.partial = false + // Ensure it has the ID for native protocol + ;(existingToolUse as any).id = event.id + } + + // Clean up tracking + this.streamingToolCallIndices.delete(event.id) + + // Mark that we have new content to process + this.userMessageContentReady = false + + // Present the tool call - validation will handle missing params + presentAssistantMessage(this) + } + } + } + + // IMPORTANT: Capture partialBlocks AFTER finalizeRawChunks() to avoid double-presentation. + // Tools finalized above are already presented, so we only want blocks still partial after finalization. const partialBlocks = this.assistantMessageContent.filter((block) => block.partial) partialBlocks.forEach((block) => (block.partial = false)) From 1d3f126f53ae6afd7750a649c4128f87277f41fd Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 26 Jan 2026 22:26:03 -0700 Subject: [PATCH 3/3] Update src/core/task/Task.ts Co-authored-by: Matt Rubens --- src/core/task/Task.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a3e2cfd6f7f..fc2f535f325 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4177,8 +4177,6 @@ export class Task extends EventEmitter implements TaskLike { const shouldIncludeTools = allTools.length > 0 - // Parallel tool calls can now be enabled safely with new_task isolation enforcement - // The runtime enforcement at line ~3427 prevents tools after new_task from executing const parallelToolCallsEnabled = state?.experiments?.multipleNativeToolCalls ?? false const metadata: ApiHandlerCreateMessageMetadata = {