From 6df9befc24ebb41e25a5122d5e1e3df47373e3ea Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 2 Dec 2025 12:28:08 -0500 Subject: [PATCH] Fix parallel tool calls for subtasks --- src/core/task/Task.ts | 184 +++++++++++++ src/core/tools/NewTaskTool.ts | 69 ++++- src/core/tools/__tests__/newTaskTool.spec.ts | 192 ++++++++++++++ src/core/webview/ClineProvider.ts | 259 ++++++++++++++----- 4 files changed, 631 insertions(+), 73 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e0abd53655..b95d1f3149 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -161,6 +161,27 @@ export class Task extends EventEmitter implements TaskLike { childTaskId?: string pendingNewTaskToolCallId?: string + /** Queue of subtasks to execute sequentially when multiple new_task calls are made in parallel. */ + pendingSubtasks: Array<{ + toolCallId: string + message: string + mode: string + todoItems: TodoItem[] + }> = [] + + /** Results from completed subtasks, added to conversation only after ALL subtasks finish. */ + completedSubtaskResults: Array<{ + toolCallId: string + result: string + }> = [] + + /** Pending tool results from OTHER tools called in the same turn. + * Saved with subtask state so they're combined with subtask results at the end. */ + pendingOtherToolResults: Array = [] + + /** Tool call ID of the currently-executing subtask. */ + currentSubtaskToolCallId?: string + readonly instanceId: string readonly metadata: TaskMetadata @@ -821,6 +842,141 @@ export class Task extends EventEmitter implements TaskLike { this.userMessageContent = [] } + /** Execute pending subtasks sequentially. Returns empty array since delegation suspends the parent. */ + public async executePendingSubtasks(): Promise> { + if (this.pendingSubtasks.length === 0) { + return [] + } + + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider not available for subtask execution") + } + + // Save other tool results (like update_todo_list) that were called in the same turn. + // Don't flush them to history yet - they'll be combined with subtask results later. + // Only save non-new_task tool results (filter out new_task tool results which are pending subtasks). + const pendingSubtaskToolIds = new Set(this.pendingSubtasks.map((s) => s.toolCallId)) + if (this.currentSubtaskToolCallId) { + pendingSubtaskToolIds.add(this.currentSubtaskToolCallId) + } + + // Extract tool_result blocks from userMessageContent that are NOT subtask-related + const otherToolResults = this.userMessageContent.filter((block): block is Anthropic.ToolResultBlockParam => { + if (block.type !== "tool_result") return false + return !pendingSubtaskToolIds.has(block.tool_use_id) + }) + + // If this is the first subtask, save the other tool results + if (this.pendingOtherToolResults.length === 0 && otherToolResults.length > 0) { + this.pendingOtherToolResults = otherToolResults + } + + // Clear userMessageContent since we're about to delegate (don't flush to history) + this.userMessageContent = [] + + const currentSubtask = this.pendingSubtasks.shift()! + this.currentSubtaskToolCallId = currentSubtask.toolCallId + + try { + // State must be saved before delegating since a new parent instance is created on resume + await this.savePendingSubtasks() + + await (provider as any).delegateParentAndOpenChild({ + parentTaskId: this.taskId, + message: currentSubtask.message, + initialTodos: currentSubtask.todoItems, + mode: currentSubtask.mode, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.completedSubtaskResults.push({ + toolCallId: currentSubtask.toolCallId, + result: `Failed to execute subtask: ${errorMessage}`, + }) + this.currentSubtaskToolCallId = undefined + await this.savePendingSubtasks() + } + + return [] + } + + public hasPendingSubtasks(): boolean { + return this.pendingSubtasks.length > 0 + } + + /** + * Save subtask state to VSCode's workspaceState via the provider. + * The workspaceState persists across extension restarts, so this state survives + * when the parent is disposed and recreated after a child task completes. + */ + public async savePendingSubtasks(): Promise { + const provider = this.providerRef.deref() + if (!provider) { + console.error(`[Task#savePendingSubtasks] Provider not available`) + return + } + + const hasState = + this.pendingSubtasks.length > 0 || + this.completedSubtaskResults.length > 0 || + this.currentSubtaskToolCallId || + this.pendingOtherToolResults.length > 0 + + if (hasState) { + await provider.setSubtaskState(this.taskId, { + pendingSubtasks: this.pendingSubtasks, + completedSubtaskResults: this.completedSubtaskResults, + currentSubtaskToolCallId: this.currentSubtaskToolCallId, + pendingOtherToolResults: this.pendingOtherToolResults as any, + }) + } else { + // Clean up state if nothing to store + await provider.clearSubtaskState(this.taskId) + } + } + + /** + * Load subtask state from workspaceState via the provider. + * Called when the parent resumes after a child task completes. + */ + public loadPendingSubtasks(): void { + const provider = this.providerRef.deref() + if (!provider) { + return + } + + const state = provider.getSubtaskState(this.taskId) + if (state) { + if (Array.isArray(state.pendingSubtasks)) { + this.pendingSubtasks = state.pendingSubtasks + } + if (Array.isArray(state.completedSubtaskResults)) { + this.completedSubtaskResults = state.completedSubtaskResults + } + if (state.currentSubtaskToolCallId) { + this.currentSubtaskToolCallId = state.currentSubtaskToolCallId + } + if (Array.isArray(state.pendingOtherToolResults)) { + this.pendingOtherToolResults = state.pendingOtherToolResults as Anthropic.ToolResultBlockParam[] + } + } + } + + /** + * Clear subtask state from workspaceState after all subtasks are complete. + */ + public async clearSubtaskState(): Promise { + const provider = this.providerRef.deref() + if (provider) { + await provider.clearSubtaskState(this.taskId) + } + this.pendingSubtasks = [] + this.completedSubtaskResults = [] + this.currentSubtaskToolCallId = undefined + this.pendingOtherToolResults = [] + } + private async saveApiConversationHistory() { try { await saveApiMessages({ @@ -2046,6 +2202,25 @@ export class Task extends EventEmitter implements TaskLike { // Save the updated history await this.saveApiConversationHistory() + // Load any pending subtasks from the provider's in-memory storage. + // When multiple new_task tools are called in parallel, remaining subtasks are + // saved before each delegation. We load them here on resume. + this.loadPendingSubtasks() + + // Check if there are pending subtasks to execute sequentially. + // This happens when multiple new_task tools were called in parallel - + // they are queued and executed one at a time. + if (this.hasPendingSubtasks()) { + const provider = this.providerRef.deref() + if (provider) { + // Execute the next pending subtask + await this.executePendingSubtasks() + // Don't call initiateTaskLoop - the next subtask will run, + // and when it completes, this method will be called again. + return + } + } + // Continue task loop - pass empty array to signal no new user content needed // The initiateTaskLoop will handle this by skipping user message addition await this.initiateTaskLoop([]) @@ -3031,6 +3206,15 @@ export class Task extends EventEmitter implements TaskLike { await pWaitFor(() => this.userMessageContentReady) + // Execute pending subtasks after all tool blocks have been processed. + // This ensures the assistant message is already in history before delegating. + if (typeof this.hasPendingSubtasks === "function" && this.hasPendingSubtasks()) { + await this.executePendingSubtasks() + // executePendingSubtasks delegates and suspends this task. + // The parent will resume when all subtasks complete. + return false + } + // If the model did not tool use, then we need to tell it to // either use a tool or attempt_completion. const didToolUse = this.assistantMessageContent.some( diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index c5607d2a85..233d5d81e2 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import { Anthropic } from "@anthropic-ai/sdk" import { TodoItem } from "@roo-code/types" @@ -17,6 +18,32 @@ interface NewTaskParams { todos?: string } +/** Counts completed new_task tool blocks in the current assistant message. */ +function countNewTaskBlocks(task: Task): number { + if (!task.assistantMessageContent) { + return 0 + } + return task.assistantMessageContent.filter( + (block) => block.type === "tool_use" && (block as any).name === "new_task" && !(block as any).partial, + ).length +} + +/** Checks if there are any tool blocks AFTER the current streaming index that haven't been processed yet. */ +function hasRemainingToolBlocks(task: Task): boolean { + if (!task.assistantMessageContent) { + return false + } + // Check all blocks after the current streaming index (which is the new_task we're processing) + // If there are any non-partial tool blocks remaining, we need to queue this new_task + for (let i = task.currentStreamingContentIndex + 1; i < task.assistantMessageContent.length; i++) { + const block = task.assistantMessageContent[i] + if (block.type === "tool_use" && !(block as any).partial) { + return true + } + } + return false +} + export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const @@ -92,8 +119,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { task.consecutiveMistakeCount = 0 - // Un-escape one level of backslashes before '@' for hierarchical subtasks - // Un-escape one level: \\@ -> \@ (removes one backslash for hierarchical subtasks) + // Un-escape \\@ -> \@ for hierarchical subtasks const unescapedMessage = message.replace(/\\\\@/g, "\\@") // Verify the mode exists @@ -117,13 +143,45 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } - // Provider is guaranteed to be defined here due to earlier check. - if (task.enableCheckpoints) { task.checkpointSave(true) } - // Delegate parent and open child as sole active task + // Queue this new_task if there are: + // 1. Multiple new_task blocks (to execute sequentially), OR + // 2. Any remaining tool blocks after this one (so they can execute before delegation) + const newTaskBlockCount = countNewTaskBlocks(task) + const hasRemainingTools = hasRemainingToolBlocks(task) + + if (newTaskBlockCount > 1 || hasRemainingTools) { + task.pendingSubtasks.push({ + toolCallId: toolCallId ?? "", + message: unescapedMessage, + mode, + todoItems, + }) + return + } + + // Save other tool results (e.g., update_todo_list) that were called in the same turn. + // This prevents them from being lost or incorrectly ordered when the parent resumes. + const currentToolCallId = toolCallId ?? "" + const otherToolResults = task.userMessageContent.filter( + (block): block is Anthropic.ToolResultBlockParam => + block.type === "tool_result" && block.tool_use_id !== currentToolCallId, + ) + + if (otherToolResults.length > 0) { + task.pendingOtherToolResults = otherToolResults + } + + // Track this subtask and clear userMessageContent to prevent incorrect flushing + task.currentSubtaskToolCallId = currentToolCallId + task.userMessageContent = [] + + // Save state before delegation so it survives parent disposal + await task.savePendingSubtasks() + const child = await (provider as any).delegateParentAndOpenChild({ parentTaskId: task.taskId, message: unescapedMessage, @@ -131,7 +189,6 @@ export class NewTaskTool extends BaseTool<"new_task"> { mode, }) - // Reflect delegation in tool result (no pause/unpause, no wait) pushToolResult(`Delegated to child task ${child.taskId}`) return } catch (error) { diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 975f754ee5..0b9df1d6aa 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -98,6 +98,10 @@ const mockCline = { enableCheckpoints: false, checkpointSave: mockCheckpointSave, startSubtask: mockStartSubtask, + userMessageContent: [] as any[], + pendingOtherToolResults: [] as any[], + currentSubtaskToolCallId: undefined as string | undefined, + savePendingSubtasks: vi.fn().mockResolvedValue(undefined), providerRef: { deref: vi.fn(() => ({ getState: vi.fn(() => ({ customModes: [], mode: "ask" })), @@ -651,6 +655,10 @@ describe("newTaskTool delegation flow", () => { enableCheckpoints: false, checkpointSave: mockCheckpointSave, startSubtask: localStartSubtask, + userMessageContent: [] as any[], + pendingOtherToolResults: [] as any[], + currentSubtaskToolCallId: undefined as string | undefined, + savePendingSubtasks: vi.fn().mockResolvedValue(undefined), providerRef: { deref: vi.fn(() => providerSpy), }, @@ -697,3 +705,187 @@ describe("newTaskTool delegation flow", () => { expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task child-1")) }) }) + +describe("newTaskTool parallel execution", () => { + it("should queue subtasks when multiple new_task blocks are detected", async () => { + const providerSpy = { + getState: vi.fn().mockResolvedValue({ + mode: "ask", + experiments: {}, + }), + delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }), + handleModeSwitch: vi.fn(), + } as any + + const pendingSubtasks: any[] = [] + const localCline = { + ask: vi.fn(), + sayAndCreateMissingParamError: vi.fn(), + emit: vi.fn(), + recordToolError: vi.fn(), + consecutiveMistakeCount: 0, + isPaused: false, + pausedModeSlug: "ask", + taskId: "mock-parent-task-id", + enableCheckpoints: false, + checkpointSave: vi.fn(), + startSubtask: vi.fn(), + userMessageContent: [] as any[], + pendingOtherToolResults: [] as any[], + currentSubtaskToolCallId: undefined as string | undefined, + savePendingSubtasks: vi.fn().mockResolvedValue(undefined), + providerRef: { + deref: vi.fn(() => providerSpy), + }, + assistantMessageContent: [ + { + type: "tool_use", + name: "new_task", + id: "tool-1", + params: { mode: "code", message: "First task" }, + partial: false, + }, + { + type: "tool_use", + name: "new_task", + id: "tool-2", + params: { mode: "code", message: "Second task" }, + partial: false, + }, + ], + pendingSubtasks, + } + + const mockPushToolResult = vi.fn() + const mockAskApproval = vi.fn().mockResolvedValue(true) + + const block1: ToolUse = { + type: "tool_use", + id: "tool-1", + name: "new_task", + params: { + mode: "code", + message: "First task", + }, + partial: false, + } + + await newTaskTool.handle(localCline as any, block1 as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""), + toolProtocol: "xml", + toolCallId: "tool-1", + }) + + expect(pendingSubtasks.length).toBe(1) + expect(pendingSubtasks[0].mode).toBe("code") + expect(pendingSubtasks[0].message).toBe("First task") + expect(pendingSubtasks[0].toolCallId).toBe("tool-1") + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled() + + const block2: ToolUse = { + type: "tool_use", + id: "tool-2", + name: "new_task", + params: { + mode: "code", + message: "Second task", + }, + partial: false, + } + + await newTaskTool.handle(localCline as any, block2 as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""), + toolProtocol: "xml", + toolCallId: "tool-2", + }) + + expect(pendingSubtasks.length).toBe(2) + expect(pendingSubtasks[1].mode).toBe("code") + expect(pendingSubtasks[1].message).toBe("Second task") + expect(pendingSubtasks[1].toolCallId).toBe("tool-2") + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("should execute immediately when only one new_task block is present", async () => { + const providerSpy = { + getState: vi.fn().mockResolvedValue({ + mode: "ask", + experiments: {}, + }), + delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }), + handleModeSwitch: vi.fn(), + } as any + + const pendingSubtasks: any[] = [] + const localCline = { + ask: vi.fn(), + sayAndCreateMissingParamError: vi.fn(), + emit: vi.fn(), + recordToolError: vi.fn(), + consecutiveMistakeCount: 0, + isPaused: false, + pausedModeSlug: "ask", + taskId: "mock-parent-task-id", + enableCheckpoints: false, + checkpointSave: vi.fn(), + startSubtask: vi.fn(), + userMessageContent: [] as any[], + pendingOtherToolResults: [] as any[], + currentSubtaskToolCallId: undefined as string | undefined, + savePendingSubtasks: vi.fn().mockResolvedValue(undefined), + providerRef: { + deref: vi.fn(() => providerSpy), + }, + assistantMessageContent: [ + { + type: "tool_use", + name: "new_task", + id: "tool-1", + params: { mode: "code", message: "Single task" }, + partial: false, + }, + ], + pendingSubtasks, + } + + const mockPushToolResult = vi.fn() + const mockAskApproval = vi.fn().mockResolvedValue(true) + + const block: ToolUse = { + type: "tool_use", + id: "tool-1", + name: "new_task", + params: { + mode: "code", + message: "Single task", + }, + partial: false, + } + + await newTaskTool.handle(localCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""), + toolProtocol: "xml", + toolCallId: "tool-1", + }) + + expect(pendingSubtasks.length).toBe(0) + expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "mock-parent-task-id", + message: "Single task", + initialTodos: [], + mode: "code", + }) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1fb272604d..e0a6abb4c5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -118,6 +118,32 @@ interface PendingEditOperation { createdAt: number } +/** + * State for pending subtasks during parallel new_task execution. + * Stored in VSCode's workspaceState for persistence across extension restarts. + */ +export interface SubtaskState { + pendingSubtasks: Array<{ + toolCallId: string + message: string + mode: string + todoItems: TodoItem[] + }> + completedSubtaskResults: Array<{ + toolCallId: string + result: string + }> + currentSubtaskToolCallId?: string + /** Pending tool results from OTHER tools called in the same turn as new_task. + * These are saved before the first subtask executes so they can be combined + * with subtask results into a single user message when all complete. */ + pendingOtherToolResults?: Array<{ + type: "tool_result" + tool_use_id: string + content: string | Array<{ type: "text"; text: string } | { type: "image"; source: any }> + }> +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -146,6 +172,9 @@ export class ClineProvider private pendingOperations: Map = new Map() private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds + // Storage key prefix for subtask state in VSCode workspaceState + private static readonly SUBTASK_STATE_KEY_PREFIX = "subtaskState:" + private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null private cloudOrganizationsCacheTimestamp: number | null = null private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds @@ -550,6 +579,47 @@ export class ClineProvider this.log(`[clearAllPendingEditOperations] Cleared all pending operations`) } + // Subtask State Management + // These methods manage state for parallel new_task execution using VSCode's workspaceState. + // workspaceState persists data across extension restarts for the current workspace. + + /** + * Gets the subtask state key for a given task ID. + */ + private getSubtaskStateKey(taskId: string): string { + return `${ClineProvider.SUBTASK_STATE_KEY_PREFIX}${taskId}` + } + + /** + * Gets the subtask state for a given task ID from workspaceState. + */ + public getSubtaskState(taskId: string): SubtaskState | undefined { + const key = this.getSubtaskStateKey(taskId) + return this.context.workspaceState.get(key) + } + + /** + * Sets the subtask state for a given task ID in workspaceState. + */ + public async setSubtaskState(taskId: string, state: SubtaskState): Promise { + const key = this.getSubtaskStateKey(taskId) + await this.context.workspaceState.update(key, state) + this.log(`[setSubtaskState] Set subtask state for task ${taskId}`) + } + + /** + * Clears the subtask state for a given task ID from workspaceState. + * Should be called after all subtasks complete. + */ + public async clearSubtaskState(taskId: string): Promise { + const key = this.getSubtaskStateKey(taskId) + const exists = this.context.workspaceState.get(key) !== undefined + await this.context.workspaceState.update(key, undefined) + if (exists) { + this.log(`[clearSubtaskState] Cleared subtask state for task ${taskId}`) + } + } + /* VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc. - https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/ @@ -2990,24 +3060,26 @@ export class ClineProvider `[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`, ) } - // 2) Flush pending tool results to API history BEFORE disposing the parent. - // This is critical for native tool protocol: when tools are called before new_task, - // their tool_result blocks are in userMessageContent but not yet saved to API history. - // If we don't flush them, the parent's API conversation will be incomplete and - // cause 400 errors when resumed (missing tool_result for tool_use blocks). + // 2) DON'T flush pending tool results to history when we're in subtask execution flow. + // The executePendingSubtasks() method saves other tool results (like update_todo_list) + // to pendingOtherToolResults, which will be combined with subtask results into a + // SINGLE user message when all subtasks complete. If we flush here, we'd create + // a separate user message that breaks the conversation structure (tool results + // MUST follow the assistant message that called them in a single user message). // - // NOTE: We do NOT pass the assistant message here because the assistant message - // is already added to apiConversationHistory by the normal flow in - // recursivelyMakeClineRequests BEFORE tools start executing. We only need to - // flush the pending user message with tool_results. - try { - await parent.flushPendingToolResultsToHistory() - } catch (error) { - this.log( - `[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${ - error instanceof Error ? error.message : String(error) - }`, - ) + // Only flush if this is a direct delegation (no pending subtask state), which means + // the parent called new_task without the parallel subtask execution flow. + const hasSubtaskState = this.getSubtaskState(parentTaskId) !== undefined + if (!hasSubtaskState) { + try { + await parent.flushPendingToolResultsToHistory() + } catch (error) { + this.log( + `[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } } // 3) Enforce single-open invariant by closing/disposing the parent first @@ -3111,13 +3183,22 @@ export class ClineProvider parentApiMessages = [] } - // 2) Inject synthetic records: UI subtask_result and update API tool_result - const ts = Date.now() + // Load subtask state from workspaceState + const subtaskState = this.getSubtaskState(parentTaskId) + let pendingSubtasks: Array<{ toolCallId: string; message: string; mode: string; todoItems: any[] }> = + subtaskState?.pendingSubtasks ?? [] + let completedSubtaskResults: Array<{ toolCallId: string; result: string }> = + subtaskState?.completedSubtaskResults ?? [] + let currentSubtaskToolCallId: string | undefined = subtaskState?.currentSubtaskToolCallId + // Other tool results (like update_todo_list) that were called in the same turn as new_task + const pendingOtherToolResults: Array<{ type: string; tool_use_id: string; content: any }> = + (subtaskState?.pendingOtherToolResults as any) ?? [] - // Defensive: ensure arrays + const ts = Date.now() if (!Array.isArray(parentClineMessages)) parentClineMessages = [] if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + // Add the child's result to the UI messages const subtaskUiMessage: ClineMessage = { type: "say", say: "subtask_result", @@ -3127,67 +3208,98 @@ export class ClineProvider parentClineMessages.push(subtaskUiMessage) await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) - // Find the tool_use_id from the last assistant message's new_task tool_use - let toolUseId: string | undefined - for (let i = parentApiMessages.length - 1; i >= 0; i--) { - const msg = parentApiMessages[i] - if (msg.role === "assistant" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.name === "new_task") { - toolUseId = block.id - break + // Determine the toolUseId for this subtask result + // Priority: 1) currentSubtaskToolCallId from state, 2) fallback to finding the last one + let toolUseId: string | undefined = currentSubtaskToolCallId + + if (!toolUseId) { + // Fallback: find the tool_use_id from the last assistant message's new_task tool_use + for (let i = parentApiMessages.length - 1; i >= 0; i--) { + const msg = parentApiMessages[i] + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.name === "new_task") { + toolUseId = block.id + break + } } + if (toolUseId) break } - if (toolUseId) break } } - // The API expects: user → assistant (with tool_use) → user (with tool_result) - // We need to add a NEW user message with the tool_result AFTER the assistant's tool_use - // NOT add it to an existing user message + // Store this subtask's result if (toolUseId) { - // Check if the last message is already a user message with a tool_result for this tool_use_id - // (in case this is a retry or the history was already updated) + completedSubtaskResults.push({ + toolCallId: toolUseId, + result: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }) + } + + // Clear currentSubtaskToolCallId since this subtask is complete + currentSubtaskToolCallId = undefined + + // Check if there are more pending subtasks to execute + const hasMoreSubtasks = pendingSubtasks.length > 0 + + if (!hasMoreSubtasks && completedSubtaskResults.length > 0) { + // All subtasks complete - add ALL tool_results to the conversation now + // The API expects: user → assistant (with tool_use) → user (with tool_result) + + // Check if the last message is already a user message we can append to const lastMsg = parentApiMessages[parentApiMessages.length - 1] - let alreadyHasToolResult = false + let userMessageContent: any[] = [] + if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { - for (const block of lastMsg.content) { - if (block.type === "tool_result" && block.tool_use_id === toolUseId) { - // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - alreadyHasToolResult = true - break - } - } + // Filter out any existing tool_results for our subtask IDs (to avoid duplicates) + const allToolResultIds = new Set([ + ...completedSubtaskResults.map((r) => r.toolCallId), + ...pendingOtherToolResults.map((r) => r.tool_use_id), + ]) + userMessageContent = lastMsg.content.filter( + (block: any) => !(block.type === "tool_result" && allToolResultIds.has(block.tool_use_id)), + ) + // Remove the last message so we can replace it with an updated one + parentApiMessages.pop() } - // If no existing tool_result found, create a NEW user message with the tool_result - if (!alreadyHasToolResult) { - parentApiMessages.push({ - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }, - ], - ts, + // FIRST: Add other tool results (e.g., update_todo_list) that were called in the same turn + // These should come before subtask results to maintain the original tool call order + for (const toolResult of pendingOtherToolResults) { + userMessageContent.push({ + type: "tool_result" as const, + tool_use_id: toolResult.tool_use_id, + content: toolResult.content, }) } - } else { - // Fallback for XML protocol or when toolUseId couldn't be found: - // Add a text block (not ideal but maintains backward compatibility) + + // THEN: Add all completed subtask results as tool_result blocks + for (const result of completedSubtaskResults) { + userMessageContent.push({ + type: "tool_result" as const, + tool_use_id: result.toolCallId, + content: result.result, + }) + } + parentApiMessages.push({ role: "user", - content: [ - { - type: "text", - text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }, - ], + content: userMessageContent, ts, }) + + // Clear completed results since they're now in the conversation + completedSubtaskResults = [] + + // Clean up the subtask state from workspaceState + await this.clearSubtaskState(parentTaskId) + } else { + // More subtasks remain - save state for the next resumption + await this.setSubtaskState(parentTaskId, { + pendingSubtasks, + completedSubtaskResults, + currentSubtaskToolCallId, + }) } await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) @@ -3249,8 +3361,21 @@ export class ClineProvider // non-fatal } - // Auto-resume parent without ask("resume_task") - await parentInstance.resumeAfterDelegation() + // Load subtask state into the parent instance (synchronous - reads from provider's in-memory state) + parentInstance.loadPendingSubtasks() + + // Check if there are more pending subtasks to execute + if (parentInstance.hasPendingSubtasks()) { + // Execute the next pending subtask + // This will cause the parent to be "paused" again and a new child will run + this.log( + `[reopenParentFromDelegation] Parent ${parentTaskId} has ${parentInstance.pendingSubtasks.length} more subtasks, executing next one`, + ) + await parentInstance.executePendingSubtasks() + } else { + // No more pending subtasks - resume the parent with the API + await parentInstance.resumeAfterDelegation() + } } // 9) Emit TaskDelegationResumed (provider-level)