diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index d3238d39e3..e9b5872142 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -33,6 +33,19 @@ vi.mock("../core/task-persistence", () => ({ readApiMessages: vi.fn().mockResolvedValue([]), saveApiMessages: vi.fn().mockResolvedValue(undefined), saveTaskMessages: vi.fn().mockResolvedValue(undefined), + getPendingSubtasks: vi.fn().mockReturnValue([]), + // appendToolResult should actually add the tool_result to the messages + appendToolResult: vi.fn().mockImplementation((msgs, toolUseId, result) => { + const newMsgs = [...msgs] + newMsgs.push({ + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: result }], + ts: Date.now(), + }) + return newMsgs + }), + getOtherToolResults: vi.fn().mockReturnValue([]), + hasPendingSubtasksInHistory: vi.fn().mockReturnValue(false), })) import { ClineProvider } from "../core/webview/ClineProvider" @@ -69,7 +82,6 @@ describe("History resume delegation - parent metadata transitions", () => { taskId: "parent-1", skipPrevResponseIdOnce: false, resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), - loadPendingSubtasks: vi.fn(), hasPendingSubtasks: vi.fn().mockReturnValue(false), }) @@ -81,9 +93,6 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack, createTaskWithHistoryItem, updateTaskHistory, - getSubtaskState: vi.fn().mockReturnValue(undefined), - setSubtaskState: vi.fn().mockResolvedValue(undefined), - clearSubtaskState: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider // Mock persistence reads to return empty arrays @@ -148,13 +157,9 @@ describe("History resume delegation - parent metadata transitions", () => { resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), - loadPendingSubtasks: vi.fn(), hasPendingSubtasks: vi.fn().mockReturnValue(false), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - getSubtaskState: vi.fn().mockReturnValue(undefined), - setSubtaskState: vi.fn().mockResolvedValue(undefined), - clearSubtaskState: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider // Start with existing messages in history @@ -219,13 +224,9 @@ describe("History resume delegation - parent metadata transitions", () => { resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), - loadPendingSubtasks: vi.fn(), hasPendingSubtasks: vi.fn().mockReturnValue(false), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - getSubtaskState: vi.fn().mockReturnValue(undefined), - setSubtaskState: vi.fn().mockResolvedValue(undefined), - clearSubtaskState: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider // Include an assistant message with new_task tool_use to exercise the tool_result path @@ -295,7 +296,6 @@ describe("History resume delegation - parent metadata transitions", () => { }), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), - loadPendingSubtasks: vi.fn(), hasPendingSubtasks: vi.fn().mockReturnValue(false), } @@ -319,9 +319,6 @@ describe("History resume delegation - parent metadata transitions", () => { removeClineFromStack: vi.fn().mockResolvedValue(undefined), createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), updateTaskHistory: vi.fn().mockResolvedValue([]), - getSubtaskState: vi.fn().mockReturnValue(undefined), - setSubtaskState: vi.fn().mockResolvedValue(undefined), - clearSubtaskState: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -363,13 +360,9 @@ describe("History resume delegation - parent metadata transitions", () => { resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), - loadPendingSubtasks: vi.fn(), hasPendingSubtasks: vi.fn().mockReturnValue(false), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - getSubtaskState: vi.fn().mockReturnValue(undefined), - setSubtaskState: vi.fn().mockResolvedValue(undefined), - clearSubtaskState: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -418,13 +411,9 @@ describe("History resume delegation - parent metadata transitions", () => { resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), - loadPendingSubtasks: vi.fn(), hasPendingSubtasks: vi.fn().mockReturnValue(false), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - getSubtaskState: vi.fn().mockReturnValue(undefined), - setSubtaskState: vi.fn().mockResolvedValue(undefined), - clearSubtaskState: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -466,13 +455,9 @@ describe("History resume delegation - parent metadata transitions", () => { resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), - loadPendingSubtasks: vi.fn(), hasPendingSubtasks: vi.fn().mockReturnValue(false), }), updateTaskHistory: vi.fn().mockResolvedValue([]), - getSubtaskState: vi.fn().mockReturnValue(undefined), - setSubtaskState: vi.fn().mockResolvedValue(undefined), - clearSubtaskState: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider // Mock read failures or empty returns diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts index f649a29799..88e1ddbde2 100644 --- a/src/__tests__/nested-delegation-resume.spec.ts +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -47,6 +47,19 @@ vi.mock("../core/task-persistence", () => ({ readApiMessages: vi.fn().mockResolvedValue([]), saveApiMessages: vi.fn().mockResolvedValue(undefined), saveTaskMessages: vi.fn().mockResolvedValue(undefined), + getPendingSubtasks: vi.fn().mockReturnValue([]), + // appendToolResult should actually add the tool_result to the messages + appendToolResult: vi.fn().mockImplementation((msgs, toolUseId, result) => { + const newMsgs = [...msgs] + newMsgs.push({ + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: result }], + ts: Date.now(), + }) + return newMsgs + }), + getOtherToolResults: vi.fn().mockReturnValue([]), + hasPendingSubtasksInHistory: vi.fn().mockReturnValue(false), })) import { attemptCompletionTool } from "../core/tools/AttemptCompletionTool" @@ -57,7 +70,7 @@ import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task describe("Nested delegation resume (A → B → C)", () => { beforeEach(() => { - vi.restoreAllMocks() + vi.clearAllMocks() }) it("C completes → reopens B; then B completes → reopens A; emits correct events; no resume_task asks", async () => { @@ -128,7 +141,6 @@ describe("Nested delegation resume (A → B → C)", () => { resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), overwriteClineMessages: vi.fn().mockResolvedValue(undefined), overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), - loadPendingSubtasks: vi.fn(), hasPendingSubtasks: vi.fn().mockReturnValue(false), } }) @@ -158,9 +170,6 @@ describe("Nested delegation resume (A → B → C)", () => { removeClineFromStack, createTaskWithHistoryItem, updateTaskHistory, - getSubtaskState: vi.fn().mockReturnValue(undefined), - setSubtaskState: vi.fn().mockResolvedValue(undefined), - clearSubtaskState: vi.fn().mockResolvedValue(undefined), // Wire through provider method so attemptCompletionTool can call it reopenParentFromDelegation: vi.fn(async (params: any) => { return await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, params) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 601b0bc94a..ccee6c6188 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -2,6 +2,18 @@ import { describe, it, expect, vi } from "vitest" import { RooCodeEventName } from "@roo-code/types" + +// Mock task-persistence to avoid undefined apiMessages error +vi.mock("../core/task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + getPendingSubtasks: vi.fn().mockReturnValue([]), + appendToolResult: vi.fn().mockImplementation((msgs) => msgs), + getOtherToolResults: vi.fn().mockReturnValue([]), + hasPendingSubtasksInHistory: vi.fn().mockReturnValue(false), +})) + import { ClineProvider } from "../core/webview/ClineProvider" describe("ClineProvider.delegateParentAndOpenChild()", () => { @@ -47,7 +59,6 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { updateTaskHistory, handleModeSwitch, log: vi.fn(), - getSubtaskState: vi.fn().mockReturnValue(undefined), } as unknown as ClineProvider const params = { diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index c8656002bd..bbe8c20009 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,3 +1,15 @@ export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" +export { + type PendingSubtask, + getPendingSubtasks, + getPendingSubtasksFromContent, + getFirstPendingSubtaskId, + hasPendingSubtasksInHistory, + appendToolResult, + getOtherToolResults, + areAllSubtasksComplete, + getCompletedSubtaskCount, + getTotalSubtaskCount, +} from "./subtaskState" diff --git a/src/core/task-persistence/subtaskState.ts b/src/core/task-persistence/subtaskState.ts new file mode 100644 index 0000000000..a9385f6e63 --- /dev/null +++ b/src/core/task-persistence/subtaskState.ts @@ -0,0 +1,305 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import { TodoItem } from "@roo-code/types" +import { parseMarkdownChecklist } from "../tools/UpdateTodoListTool" + +import { type ApiMessage } from "./apiMessages" + +/** + * Represents a pending subtask derived from the API conversation history. + */ +export interface PendingSubtask { + toolCallId: string + message: string + mode: string + todoItems: TodoItem[] +} + +/** + * Helper to extract pending subtasks from assistant content and existing tool results. + * Used internally by both getPendingSubtasks and getPendingSubtasksFromContent. + */ +function extractPendingSubtasks( + assistantContent: Array, + completedToolIds: Set, +): PendingSubtask[] { + const newTaskToolUses = assistantContent.filter( + (block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.name === "new_task", + ) + + return newTaskToolUses + .filter((toolUse) => !completedToolIds.has(toolUse.id)) + .map((toolUse) => { + const input = toolUse.input as Record + let todoItems: TodoItem[] = [] + + if (input.todos && typeof input.todos === "string") { + try { + todoItems = parseMarkdownChecklist(input.todos) + } catch { + todoItems = [] + } + } + + return { + toolCallId: toolUse.id, + message: (input.message as string) || "", + mode: (input.mode as string) || "", + todoItems, + } + }) +} + +/** + * Gets pending new_task tool calls that don't have tool_results yet. + * This derives the pending subtasks by comparing tool_use blocks (with name: "new_task") + * in the last assistant message against tool_result blocks in the last user message. + * + * @param apiMessages - The API conversation history + * @returns Array of pending subtasks with their parameters + */ +export function getPendingSubtasks(apiMessages: ApiMessage[]): PendingSubtask[] { + if (apiMessages.length < 2) return [] + + const lastAssistant = apiMessages[apiMessages.length - 2] + const lastUser = apiMessages[apiMessages.length - 1] + + if (lastAssistant?.role !== "assistant" || lastUser?.role !== "user") return [] + + const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [] + const userContent = Array.isArray(lastUser.content) ? lastUser.content : [] + const completedIds = new Set( + userContent + .filter((block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result") + .map((block) => block.tool_use_id), + ) + + return extractPendingSubtasks(assistantContent, completedIds) +} + +/** + * Gets pending new_task tool calls by examining both API history and in-memory state. + * This handles two scenarios: + * + * 1. DURING streaming: user message not in history yet, tool_results in pendingToolResults + * - apiMessages ends with assistant message + * - pendingToolResults has executed tool results + * + * 2. AFTER delegation resume: user message already in history with tool_results + * - apiMessages ends with user message (containing tool_result) + * - pendingToolResults is empty + * + * @param apiMessages - The API conversation history + * @param pendingToolResults - In-memory tool_result blocks not yet saved to history + * @returns Array of pending subtasks with their parameters + */ +export function getPendingSubtasksFromContent( + apiMessages: ApiMessage[], + pendingToolResults: Array, +): PendingSubtask[] { + if (apiMessages.length < 1) return [] + + // Find the last assistant message + let lastAssistant: ApiMessage | undefined + let lastAssistantIndex = -1 + for (let i = apiMessages.length - 1; i >= 0; i--) { + if (apiMessages[i].role === "assistant") { + lastAssistant = apiMessages[i] + lastAssistantIndex = i + break + } + } + + if (!lastAssistant) return [] + + const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [] + + // Collect completed IDs from multiple sources: + // 1. In-memory pendingToolResults (during streaming) + // 2. Any user message that comes AFTER the last assistant message (after delegation resume) + const completedIds = new Set() + + // Source 1: In-memory tool results + for (const block of pendingToolResults) { + if (block.type === "tool_result") { + completedIds.add(block.tool_use_id) + } + } + + // Source 2: User messages after the last assistant message + for (let i = lastAssistantIndex + 1; i < apiMessages.length; i++) { + const msg = apiMessages[i] + if (msg.role === "user" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_result") { + completedIds.add((block as Anthropic.ToolResultBlockParam).tool_use_id) + } + } + } + } + + return extractPendingSubtasks(assistantContent, completedIds) +} + +/** + * Gets the tool_use ID of the first pending new_task subtask. + * Used to determine which subtask is currently executing. + * + * @param apiMessages - The API conversation history + * @returns The tool_use ID of the first pending subtask, or undefined if none + */ +export function getFirstPendingSubtaskId(apiMessages: ApiMessage[]): string | undefined { + const pending = getPendingSubtasks(apiMessages) + return pending.length > 0 ? pending[0].toolCallId : undefined +} + +/** + * Checks if there are any pending new_task subtasks. + * + * @param apiMessages - The API conversation history + * @returns True if there are pending subtasks + */ +export function hasPendingSubtasksInHistory(apiMessages: ApiMessage[]): boolean { + return getPendingSubtasks(apiMessages).length > 0 +} + +/** + * Appends a tool_result to the last user message in the API conversation history. + * If the last message is not a user message, creates a new user message. + * + * This function modifies the input array in place and returns it. + * + * @param apiMessages - The API conversation history (modified in place) + * @param toolUseId - The tool_use_id to reference in the tool_result + * @param result - The result content for the tool_result + * @returns The modified apiMessages array + */ +export function appendToolResult(apiMessages: ApiMessage[], toolUseId: string, result: string): ApiMessage[] { + if (apiMessages.length === 0) { + apiMessages.push({ + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: result }], + ts: Date.now(), + }) + return apiMessages + } + + const lastMsg = apiMessages[apiMessages.length - 1] + + if (lastMsg?.role !== "user") { + apiMessages.push({ + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: result }], + ts: Date.now(), + }) + } else { + if (!Array.isArray(lastMsg.content)) { + lastMsg.content = lastMsg.content ? [{ type: "text", text: lastMsg.content }] : [] + } + ;(lastMsg.content as Anthropic.ToolResultBlockParam[]).push({ + type: "tool_result", + tool_use_id: toolUseId, + content: result, + }) + } + + return apiMessages +} + +/** + * Gets tool_result blocks from the last user message that are NOT for new_task tool calls. + * These are "other" tool results (like update_todo_list) that were called in the same turn. + * + * @param apiMessages - The API conversation history + * @returns Array of tool_result blocks for non-new_task tools + */ +export function getOtherToolResults(apiMessages: ApiMessage[]): Anthropic.ToolResultBlockParam[] { + if (apiMessages.length < 2) return [] + + const lastAssistant = apiMessages[apiMessages.length - 2] + const lastUser = apiMessages[apiMessages.length - 1] + + if (lastAssistant?.role !== "assistant" || lastUser?.role !== "user") return [] + + const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [] + const newTaskToolIds = new Set( + assistantContent + .filter( + (block): block is Anthropic.Messages.ToolUseBlock => + block.type === "tool_use" && block.name === "new_task", + ) + .map((block) => block.id), + ) + + const userContent = Array.isArray(lastUser.content) ? lastUser.content : [] + return userContent.filter( + (block): block is Anthropic.ToolResultBlockParam => + block.type === "tool_result" && !newTaskToolIds.has(block.tool_use_id), + ) +} + +/** + * Checks if all new_task tool calls have corresponding tool_results. + * This indicates all subtasks have completed. + * + * @param apiMessages - The API conversation history + * @returns True if all new_task tool calls have tool_results + */ +export function areAllSubtasksComplete(apiMessages: ApiMessage[]): boolean { + return getPendingSubtasks(apiMessages).length === 0 +} + +/** + * Gets the count of completed new_task subtasks (those with tool_results). + * + * @param apiMessages - The API conversation history + * @returns Number of completed subtasks + */ +export function getCompletedSubtaskCount(apiMessages: ApiMessage[]): number { + if (apiMessages.length < 2) return 0 + + const lastAssistant = apiMessages[apiMessages.length - 2] + const lastUser = apiMessages[apiMessages.length - 1] + + if (lastAssistant?.role !== "assistant" || lastUser?.role !== "user") return 0 + + const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [] + const newTaskToolIds = new Set( + assistantContent + .filter( + (block): block is Anthropic.Messages.ToolUseBlock => + block.type === "tool_use" && block.name === "new_task", + ) + .map((block) => block.id), + ) + + const userContent = Array.isArray(lastUser.content) ? lastUser.content : [] + return userContent.filter( + (block): block is Anthropic.ToolResultBlockParam => + block.type === "tool_result" && newTaskToolIds.has(block.tool_use_id), + ).length +} + +/** + * Gets the total count of new_task tool calls in the last assistant message. + * + * @param apiMessages - The API conversation history + * @returns Total number of new_task tool calls + */ +export function getTotalSubtaskCount(apiMessages: ApiMessage[]): number { + if (apiMessages.length < 1) return 0 + + let lastAssistant: ApiMessage | undefined + for (let i = apiMessages.length - 1; i >= 0; i--) { + if (apiMessages[i].role === "assistant") { + lastAssistant = apiMessages[i] + break + } + } + + if (!lastAssistant) return 0 + + const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [] + return assistantContent.filter( + (block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.name === "new_task", + ).length +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b95d1f3149..722e6941b3 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -109,6 +109,9 @@ import { readTaskMessages, saveTaskMessages, taskMetadata, + getPendingSubtasks, + getPendingSubtasksFromContent, + getOtherToolResults, } from "../task-persistence" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" @@ -161,27 +164,6 @@ 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 @@ -842,9 +824,15 @@ export class Task extends EventEmitter implements TaskLike { this.userMessageContent = [] } - /** Execute pending subtasks sequentially. Returns empty array since delegation suspends the parent. */ + /** + * Execute pending subtasks sequentially, deriving pending subtasks from api_conversation_history + * and in-memory userMessageContent. + * Returns empty array since delegation suspends the parent. + */ public async executePendingSubtasks(): Promise> { - if (this.pendingSubtasks.length === 0) { + // Derive pending subtasks - use the in-memory version since user message may not be in history yet + const pendingSubtasks = getPendingSubtasksFromContent(this.apiConversationHistory, this.userMessageContent) + if (pendingSubtasks.length === 0) { return [] } @@ -853,35 +841,31 @@ export class Task extends EventEmitter implements TaskLike { 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) + // Before delegating first subtask, flush other tool results to api_conversation_history. + // We need to create a user message with ALL tool_results including a placeholder for this new_task. + // This ensures the API conversation is valid (every tool_use has a tool_result). + + // Get all new_task tool IDs from the last assistant message + const newTaskToolIds = new Set(pendingSubtasks.map((s) => s.toolCallId)) + + // Find non-new_task tool results in userMessageContent + const nonNewTaskToolResults = this.userMessageContent.filter( + (block): block is Anthropic.ToolResultBlockParam => + block.type === "tool_result" && !newTaskToolIds.has(block.tool_use_id), + ) + + // Flush all pending tool results (for non-new_task tools) to history + if (nonNewTaskToolResults.length > 0) { + await this.flushPendingToolResultsToHistory() + } else { + // Clear userMessageContent since we're about to delegate + this.userMessageContent = [] } - // 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 + // Get the first pending subtask to execute + const currentSubtask = pendingSubtasks[0] 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, @@ -890,91 +874,21 @@ export class Task extends EventEmitter implements TaskLike { }) } 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() + console.error(`[Task#executePendingSubtasks] Failed to execute subtask: ${errorMessage}`) } return [] } + /** + * Check if there are pending subtasks by examining api_conversation_history + * and in-memory userMessageContent. + * Pending = new_task tool_use blocks without corresponding tool_result blocks. + */ 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 = [] + // Use the in-memory version since user message may not be in history yet + // This is called after streaming completes but before the user message is saved + return getPendingSubtasksFromContent(this.apiConversationHistory, this.userMessageContent).length > 0 } private async saveApiConversationHistory() { @@ -2202,11 +2116,6 @@ 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. diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index 77ef97223b..5015cb3ec0 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -147,44 +147,25 @@ export class NewTaskTool extends BaseTool<"new_task"> { task.checkpointSave(true) } - // Queue this new_task if using native tool protocol AND 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) + // For native tool protocol with multiple new_task blocks or remaining tools: + // Don't execute immediately - let the tool result accumulate in userMessageContent. + // After all tools process, executePendingSubtasks() will derive pending tasks from + // api_conversation_history (comparing tool_use vs tool_result blocks). // NOTE: XML protocol processes tools one at a time, so this condition is always false for XML. - // We add an explicit check for clarity and defensive safety. const isNativeToolProtocol = toolProtocol === "native" const newTaskBlockCount = countNewTaskBlocks(task) const hasRemainingTools = hasRemainingToolBlocks(task) if (isNativeToolProtocol && (newTaskBlockCount > 1 || hasRemainingTools)) { - task.pendingSubtasks.push({ - toolCallId: toolCallId ?? "", - message: unescapedMessage, - mode, - todoItems, - }) + // Don't push to pendingSubtasks - the info is already in the assistant message's tool_use block. + // Just return without executing, and executePendingSubtasks() will handle it later. 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 + // For single new_task call or XML protocol, delegate immediately. + // Clear userMessageContent to prevent incorrect flushing during delegation. 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, diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 0b4fbb9614..6270702b05 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -99,9 +99,6 @@ const mockCline = { 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" })), @@ -656,9 +653,6 @@ describe("newTaskTool delegation flow", () => { 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), }, @@ -707,7 +701,10 @@ describe("newTaskTool delegation flow", () => { }) describe("newTaskTool parallel execution", () => { - it("should queue subtasks when multiple new_task blocks are detected", async () => { + it("should defer delegation when multiple new_task blocks are detected (native protocol)", async () => { + // With the new approach, when multiple new_task blocks exist in native protocol, + // the tool just returns without executing - the subtask info is in the assistant message's + // tool_use blocks and will be derived by executePendingSubtasks() later. const providerSpy = { getState: vi.fn().mockResolvedValue({ mode: "ask", @@ -717,7 +714,6 @@ describe("newTaskTool parallel execution", () => { handleModeSwitch: vi.fn(), } as any - const pendingSubtasks: any[] = [] const localCline = { ask: vi.fn(), sayAndCreateMissingParamError: vi.fn(), @@ -731,9 +727,6 @@ describe("newTaskTool parallel execution", () => { 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), }, @@ -753,7 +746,7 @@ describe("newTaskTool parallel execution", () => { partial: false, }, ], - pendingSubtasks, + currentStreamingContentIndex: 0, } const mockPushToolResult = vi.fn() @@ -779,38 +772,10 @@ describe("newTaskTool parallel execution", () => { 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: "native", // Native protocol is required for parallel tool execution - 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") + // With new approach: tool returns without delegating when multiple new_task blocks exist + // No pushToolResult call (deferred) expect(mockPushToolResult).not.toHaveBeenCalled() + // No delegation yet (deferred to executePendingSubtasks) expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled() }) @@ -824,7 +789,6 @@ describe("newTaskTool parallel execution", () => { handleModeSwitch: vi.fn(), } as any - const pendingSubtasks: any[] = [] const localCline = { ask: vi.fn(), sayAndCreateMissingParamError: vi.fn(), @@ -838,9 +802,6 @@ describe("newTaskTool parallel execution", () => { 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), }, @@ -853,7 +814,7 @@ describe("newTaskTool parallel execution", () => { partial: false, }, ], - pendingSubtasks, + currentStreamingContentIndex: 0, } const mockPushToolResult = vi.fn() @@ -879,7 +840,6 @@ describe("newTaskTool parallel execution", () => { toolCallId: "tool-1", }) - expect(pendingSubtasks.length).toBe(0) expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({ parentTaskId: "mock-parent-task-id", message: "Single task", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e0a6abb4c5..7f5d931ae8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -93,7 +93,15 @@ import { getSystemPromptFilePath } from "../prompts/sections/custom-system-promp import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" -import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" +import { + readApiMessages, + saveApiMessages, + saveTaskMessages, + getPendingSubtasks, + appendToolResult, + getOtherToolResults, + hasPendingSubtasksInHistory, +} from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -118,32 +126,6 @@ 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 @@ -172,9 +154,6 @@ 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 @@ -579,47 +558,6 @@ 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/ @@ -3061,16 +2999,16 @@ export class ClineProvider ) } // 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 + // The executePendingSubtasks() method handles saving other tool results that were + // called in the same turn as new_task. These 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). // - // Only flush if this is a direct delegation (no pending subtask state), which means + // Only flush if this is a direct delegation (no pending subtasks in history), which means // the parent called new_task without the parallel subtask execution flow. - const hasSubtaskState = this.getSubtaskState(parentTaskId) !== undefined - if (!hasSubtaskState) { + const hasParallelSubtasks = hasPendingSubtasksInHistory(parent.apiConversationHistory) + if (!hasParallelSubtasks) { try { await parent.flushPendingToolResultsToHistory() } catch (error) { @@ -3151,6 +3089,12 @@ export class ClineProvider /** * Reopen parent task from delegation with write-back and events. + * + * This method derives pending subtask state directly from api_conversation_history.json + * instead of using workspaceState. The state is determined by comparing: + * - tool_use blocks with name: "new_task" in the last assistant message + * - tool_result blocks in the last user message + * - Pending = tool_use.id NOT in tool_results */ public async reopenParentFromDelegation(params: { parentTaskId: string @@ -3183,17 +3127,6 @@ export class ClineProvider parentApiMessages = [] } - // 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) ?? [] - const ts = Date.now() if (!Array.isArray(parentClineMessages)) parentClineMessages = [] if (!Array.isArray(parentApiMessages)) parentApiMessages = [] @@ -3208,12 +3141,26 @@ export class ClineProvider parentClineMessages.push(subtaskUiMessage) await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) - // Determine the toolUseId for this subtask result - // Priority: 1) currentSubtaskToolCallId from state, 2) fallback to finding the last one - let toolUseId: string | undefined = currentSubtaskToolCallId + // Derive pending subtasks from the api_conversation_history + // The getPendingSubtasks function compares tool_use blocks vs tool_result blocks + const pendingSubtasksFromHistory = getPendingSubtasks(parentApiMessages) - if (!toolUseId) { - // Fallback: find the tool_use_id from the last assistant message's new_task tool_use + // Get other tool results (non-new_task tools) from the last assistant message + // that haven't been added to history yet + const otherToolResults = getOtherToolResults(parentApiMessages) + + // Find the toolUseId for the child that just completed + // First, check if there's a pending subtask matching the child's details + // Since we execute subtasks in order, the first pending one should be the one that just completed + let toolUseId: string | undefined + + // Look through assistant message to find the new_task tool_use that matches + // We need to find which one was being executed - look for the first pending one + if (pendingSubtasksFromHistory.length > 0) { + // The first pending subtask is the one we just completed + toolUseId = pendingSubtasksFromHistory[0].toolCallId + } else { + // Fallback: find the last new_task tool_use in the assistant message for (let i = parentApiMessages.length - 1; i >= 0; i--) { const msg = parentApiMessages[i] if (msg.role === "assistant" && Array.isArray(msg.content)) { @@ -3228,83 +3175,41 @@ export class ClineProvider } } - // Store this subtask's result + // Append the completed subtask's tool_result to the conversation history if (toolUseId) { - completedSubtaskResults.push({ - toolCallId: toolUseId, - result: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }) + const resultContent = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + parentApiMessages = appendToolResult(parentApiMessages, toolUseId, resultContent) } - // Clear currentSubtaskToolCallId since this subtask is complete - currentSubtaskToolCallId = undefined + // Check if there are more pending subtasks to execute after adding this result + const remainingPendingSubtasks = getPendingSubtasks(parentApiMessages) + const hasMoreSubtasks = remainingPendingSubtasks.length > 0 - // 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 + // If no more subtasks and we have other tool results that need to be flushed, + // add them to the conversation (they come before subtask results) + if (!hasMoreSubtasks && otherToolResults.length > 0) { + // The other tool results should be added to the last user message const lastMsg = parentApiMessages[parentApiMessages.length - 1] - let userMessageContent: any[] = [] - if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { - // 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)), + // Check if these results are already in the message + const existingToolResultIds = new Set( + lastMsg.content.filter((b: any) => b.type === "tool_result").map((b: any) => b.tool_use_id), ) - // Remove the last message so we can replace it with an updated one - parentApiMessages.pop() + + // Add missing other tool results at the beginning (before subtask results) + const newContent = [...otherToolResults.filter((r) => !existingToolResultIds.has(r.tool_use_id))] + + if (newContent.length > 0) { + // Insert other tool results at the beginning of the content array + // to maintain original tool call order + lastMsg.content = [...newContent, ...lastMsg.content] + } } - - // 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, - }) - } - - // 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: 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 }) - // 3) Update child metadata to "completed" status + // 2) Update child metadata to "completed" status try { const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) await this.updateTaskHistory({ @@ -3319,7 +3224,7 @@ export class ClineProvider ) } - // 4) Update parent metadata and persist BEFORE emitting completion event + // 3) Update parent metadata and persist BEFORE emitting completion event const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) const updatedHistory: typeof historyItem = { ...historyItem, @@ -3331,24 +3236,24 @@ export class ClineProvider } await this.updateTaskHistory(updatedHistory) - // 5) Emit TaskDelegationCompleted (provider-level) + // 4) Emit TaskDelegationCompleted (provider-level) try { this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) } catch { // non-fatal } - // 6) Close child instance if still open (single-open-task invariant) + // 5) Close child instance if still open (single-open-task invariant) const current = this.getCurrentTask() if (current?.taskId === childTaskId) { await this.removeClineFromStack() } - // 7) Reopen the parent from history as the sole active task (restores saved mode) + // 6) Reopen the parent from history as the sole active task (restores saved mode) // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) - // 8) Inject restored histories into the in-memory instance before resuming + // 7) Inject restored histories into the in-memory instance before resuming if (parentInstance) { try { await parentInstance.overwriteClineMessages(parentClineMessages) @@ -3361,15 +3266,13 @@ export class ClineProvider // non-fatal } - // 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()) { + // Check if there are more pending subtasks to execute (derived from history) + const pendingFromHistory = getPendingSubtasks(parentApiMessages) + if (pendingFromHistory.length > 0) { // 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`, + `[reopenParentFromDelegation] Parent ${parentTaskId} has ${pendingFromHistory.length} more subtasks, executing next one`, ) await parentInstance.executePendingSubtasks() } else { @@ -3378,7 +3281,7 @@ export class ClineProvider } } - // 9) Emit TaskDelegationResumed (provider-level) + // 8) Emit TaskDelegationResumed (provider-level) try { this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) } catch {