diff --git a/.changeset/rude-pans-throw.md b/.changeset/rude-pans-throw.md new file mode 100644 index 0000000000..8c728b1666 --- /dev/null +++ b/.changeset/rude-pans-throw.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Fix parent-child task relationships across extension reloads diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index c5be865731..542dde19a0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -572,7 +572,7 @@ export class Task extends EventEmitter implements TaskLike { // API Messages - private async getSavedApiConversationHistory(): Promise { + public async getSavedApiConversationHistory(): Promise { return readApiMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) } @@ -602,7 +602,7 @@ export class Task extends EventEmitter implements TaskLike { // Cline Messages - private async getSavedClineMessages(): Promise { + public async getSavedClineMessages(): Promise { return readTaskMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) } @@ -1289,7 +1289,14 @@ export class Task extends EventEmitter implements TaskLike { .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks. let askType: ClineAsk - if (lastClineMessage?.ask === "completion_result") { + + // Check for completion indicators in multiple ways + const isCompleted = + lastClineMessage?.ask === "completion_result" || + lastClineMessage?.say === "completion_result" || + this.clineMessages.some((m) => m.ask === "completion_result" || m.say === "completion_result") + + if (isCompleted) { askType = "resume_completed_task" } else { askType = "resume_task" @@ -1600,6 +1607,13 @@ export class Task extends EventEmitter implements TaskLike { const newTask = await provider.createTask(message, undefined, this, { initialTodos }) if (newTask) { + // Store the current task's mode before pausing + const currentTaskMode = await this.getTaskMode() + this.pausedModeSlug = currentTaskMode + provider.log( + `[startSubtask] Storing parent task mode '${currentTaskMode}' in pausedModeSlug before pausing`, + ) + this.isPaused = true // Pause parent. this.childTaskId = newTask.taskId @@ -1749,6 +1763,9 @@ export class Task extends EventEmitter implements TaskLike { if (currentMode !== this.pausedModeSlug) { // The mode has changed, we need to switch back to the paused mode. + provider.log( + `[subtasks] task ${this.taskId}.${this.instanceId} switching back to paused mode '${this.pausedModeSlug}' from current mode '${currentMode}'`, + ) await provider.handleModeSwitch(this.pausedModeSlug) // Delay to allow mode change to take effect before next tool is executed. @@ -1757,6 +1774,8 @@ export class Task extends EventEmitter implements TaskLike { provider.log( `[subtasks] task ${this.taskId}.${this.instanceId} has switched back to '${this.pausedModeSlug}' from '${currentMode}'`, ) + } else { + provider.log(`[subtasks] task ${this.taskId}.${this.instanceId} mode unchanged: '${currentMode}'`) } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f453b57dba..3023cc416c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -403,7 +403,12 @@ export class ClineProvider // Removes and destroys the top Cline instance (the current finished task), // activating the previous one (resuming the parent task). async removeClineFromStack() { + // Log stack trace to understand where this is being called from + const stackTrace = new Error().stack?.split("\n").slice(1, 4).join("\n") || "unknown" + this.log(`[removeClineFromStack] Called from: ${stackTrace}`) + if (this.clineStack.length === 0) { + this.log(`[removeClineFromStack] Stack is already empty`) return } @@ -411,6 +416,9 @@ export class ClineProvider let task = this.clineStack.pop() if (task) { + this.log( + `[removeClineFromStack] Removing task ${task.taskId}.${task.instanceId} from stack. Remaining stack size: ${this.clineStack.length}`, + ) task.emit(RooCodeEventName.TaskUnfocused) try { @@ -456,7 +464,7 @@ export class ClineProvider await this.removeClineFromStack() // Resume the last cline instance in the stack (if it exists - this is // the 'parent' calling task). - await this.getCurrentTask()?.completeSubtask(lastMessage) + await this.continueParentTask(lastMessage) } // Pending Edit Operations Management @@ -1479,15 +1487,434 @@ export class ClineProvider } async showTaskWithId(id: string) { + this.log(`[showTaskWithId] Loading task ${id}`) + this.log(`[showTaskWithId] Current task: ${this.getCurrentTask()?.taskId || "none"}`) + this.log(`[showTaskWithId] Current stack size: ${this.clineStack.length}`) + if (id !== this.getCurrentTask()?.taskId) { // Non-current task. const { historyItem } = await this.getTaskWithId(id) - await this.createTaskWithHistoryItem(historyItem) // Clears existing task. + this.log( + `[showTaskWithId] Loaded history item for ${id}, parentTaskId: ${historyItem.parentTaskId}, rootTaskId: ${historyItem.rootTaskId}`, + ) + + // Check if this is a completed subtask by examining its messages + let isCompletedSubtask = false + if (historyItem.parentTaskId || historyItem.rootTaskId) { + try { + const savedMessages = await this.getSavedTaskMessages(historyItem.id) + // A completed subtask typically has very few messages (just resume_task) + // and no active conversation. If it only has resume messages, it's likely completed. + const hasOnlyResumeMessages = + savedMessages.length <= 2 && + savedMessages.every((m) => m.ask === "resume_task" || m.ask === "resume_completed_task") + + // Also check if there's any completion indicator + const hasCompletionIndicator = savedMessages.some( + (m) => m.ask === "completion_result" || m.say === "completion_result", + ) + + isCompletedSubtask = hasOnlyResumeMessages || hasCompletionIndicator + this.log( + `[showTaskWithId] Subtask ${id} completion check: hasOnlyResumeMessages=${hasOnlyResumeMessages}, hasCompletionIndicator=${hasCompletionIndicator}, isCompleted=${isCompletedSubtask}`, + ) + } catch (error) { + this.log(`[showTaskWithId] Could not check completion status for subtask ${id}: ${error}`) + } + } + + // If this is a subtask, we need to reconstruct the entire task stack + if (historyItem.parentTaskId || historyItem.rootTaskId) { + if (isCompletedSubtask) { + this.log( + `[showTaskWithId] Task ${id} is a completed subtask, showing as standalone to avoid parent task disruption`, + ) + // Show completed subtasks as standalone tasks to avoid disrupting parent tasks + await this.createTaskWithHistoryItem(historyItem) + } else { + this.log(`[showTaskWithId] Task ${id} is an active subtask, reconstructing stack`) + await this.reconstructTaskStack(historyItem) + } + } else { + // For standalone tasks, use the normal flow + this.log(`[showTaskWithId] Task ${id} is standalone, using normal flow`) + await this.createTaskWithHistoryItem(historyItem) + } + } else { + this.log(`[showTaskWithId] Task ${id} is already current, no reconstruction needed`) } await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } + private async continueParentTask(lastMessage: string): Promise { + const parentTask = this.getCurrentTask() + if (parentTask) { + this.log(`[continueParentTask] Found parent task ${parentTask.taskId}, isPaused: ${parentTask.isPaused}`) + this.log(`[continueParentTask] Parent task isInitialized: ${parentTask.isInitialized}`) + + // Log mode information for debugging + const currentProviderMode = (await this.getState()).mode + const parentTaskMode = + typeof parentTask.getTaskMode === "function" + ? await parentTask.getTaskMode() + : (parentTask as any)._taskMode || parentTask.pausedModeSlug || "unknown" + this.log(`[continueParentTask] Current provider mode: ${currentProviderMode}`) + this.log(`[continueParentTask] Parent task mode: ${parentTaskMode}`) + this.log(`[continueParentTask] Parent task pausedModeSlug: ${parentTask.pausedModeSlug}`) + + try { + // Switch provider mode to match parent task's mode if they differ + if (currentProviderMode !== parentTaskMode && parentTaskMode !== "unknown") { + this.log( + `[continueParentTask] Provider mode (${currentProviderMode}) differs from parent task mode (${parentTaskMode})`, + ) + this.log(`[continueParentTask] Switching provider mode to match parent task: ${parentTaskMode}`) + + try { + await this.handleModeSwitch(parentTaskMode as any) + this.log(`[continueParentTask] Successfully switched provider mode to: ${parentTaskMode}`) + } catch (error) { + this.log( + `[continueParentTask] Failed to switch provider mode: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + // If the parent task is not initialized, we need to initialize it properly + if (!parentTask.isInitialized) { + this.log(`[continueParentTask] Initializing parent task from history`) + // Load the parent task's saved messages and API conversation + parentTask.clineMessages = await parentTask.getSavedClineMessages() + parentTask.apiConversationHistory = await parentTask.getSavedApiConversationHistory() + parentTask.isInitialized = true + this.log( + `[continueParentTask] Parent task initialized with ${parentTask.clineMessages.length} messages`, + ) + } + + // Complete the subtask on the existing parent task + // This will add the subtask result to the parent's conversation and unpause it + await parentTask.completeSubtask(lastMessage) + this.log(`[continueParentTask] Parent task ${parentTask.taskId} subtask completed`) + + // Check if the parent task needs to continue its execution + // If the parent task was created from history reconstruction, it may not have + // an active execution loop running, so we need to continue it manually + if (!parentTask.isPaused && parentTask.isInitialized) { + this.log(`[continueParentTask] Parent task is unpaused and initialized, continuing execution`) + + // Continue the parent task's execution with the subtask result + // The subtask result has already been added to the conversation by completeSubtask + // Now we need to continue the execution loop + const continueExecution = async () => { + try { + // Continue the task loop with an empty user content since the subtask result + // has already been added to the API conversation history + await parentTask.recursivelyMakeClineRequests([], false) + } catch (error) { + this.log( + `[continueParentTask] Error continuing parent task execution: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + // Start the continuation in the background to avoid blocking + continueExecution() + } + + // Update the webview to show the parent task + this.log(`[continueParentTask] Updating webview state`) + await this.postStateToWebview() + this.log(`[continueParentTask] Webview state updated`) + } catch (error) { + this.log( + `[continueParentTask] Error during parent task resumption: ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } + } else { + this.log(`[continueParentTask] No parent task found in stack`) + } + } + + /** + * Reconstructs the entire task stack for a subtask by loading and adding + * all parent tasks to the stack in the correct order, then adding the target subtask. + * This ensures that when the subtask finishes, control returns to the parent task. + */ + private async reconstructTaskStack(targetHistoryItem: HistoryItem): Promise { + this.log(`[reconstructTaskStack] Starting reconstruction for target task ${targetHistoryItem.id}`) + + // Store current stack info before clearing for recovery purposes + const currentStackInfo = this.clineStack.map((task) => ({ + taskId: task.taskId, + instanceId: task.instanceId, + isPaused: task.isPaused, + })) + this.log(`[reconstructTaskStack] Current stack before clearing: ${JSON.stringify(currentStackInfo)}`) + + // Store current stack for recovery purposes + const originalStack = [...this.clineStack] + + try { + // Build the task hierarchy from root to target BEFORE clearing the stack + // This prevents losing the current stack if hierarchy building fails + const taskHierarchy = await this.buildTaskHierarchy(targetHistoryItem) + this.log(`[reconstructTaskStack] Built hierarchy with ${taskHierarchy.length} tasks`) + + // Now clear the current stack since we know we can rebuild it + await this.removeClineFromStack() + + const createdTasks: Task[] = [] + + // Create all tasks in the hierarchy with proper parent/root references + for (let i = 0; i < taskHierarchy.length; i++) { + const historyItem = taskHierarchy[i] + const isTargetTask = i === taskHierarchy.length - 1 + + this.log( + `[reconstructTaskStack] Processing task ${i + 1}/${taskHierarchy.length}: ${historyItem.id} (isTarget: ${isTargetTask})`, + ) + + // Determine parent and root task references + const parentTask = i > 0 ? createdTasks[i - 1] : undefined + const rootTask = createdTasks[0] || undefined + + // Create the task with proper parent/root references + const task = await this.createTaskFromHistoryItem(historyItem, isTargetTask, parentTask, rootTask) + + // Pause parent tasks so only the target runs + if (!isTargetTask) { + task.isPaused = true + this.log(`[reconstructTaskStack] Added paused parent task ${task.taskId}`) + } else { + this.log(`[reconstructTaskStack] Added target task ${task.taskId} (will respect completion status)`) + } + + createdTasks.push(task) + await this.addClineToStack(task) + } + + // Establish parent-child relationships after all tasks are created + for (let i = 0; i < createdTasks.length - 1; i++) { + const parentTask = createdTasks[i] + const childTask = createdTasks[i + 1] + + // Set the childTaskId on the parent to point to the child + parentTask.childTaskId = childTask.taskId + this.log(`[reconstructTaskStack] Linked parent ${parentTask.taskId} to child ${childTask.taskId}`) + } + + this.log(`[reconstructTaskStack] Successfully reconstructed stack with ${createdTasks.length} tasks`) + + // Log final mode state after reconstruction + const finalProviderMode = (await this.getState()).mode + const targetTask = createdTasks[createdTasks.length - 1] + // Safety check for getTaskMode method (may not exist in tests) + const targetTaskMode = + typeof targetTask.getTaskMode === "function" + ? await targetTask.getTaskMode() + : (targetTask as any)._taskMode || "unknown" + this.log(`[reconstructTaskStack] Final provider mode after reconstruction: ${finalProviderMode}`) + this.log(`[reconstructTaskStack] Target task final mode: ${targetTaskMode}`) + + // Check if provider mode matches target task mode + if (finalProviderMode !== targetTaskMode) { + this.log( + `[reconstructTaskStack] WARNING: Provider mode (${finalProviderMode}) does not match target task mode (${targetTaskMode})`, + ) + this.log(`[reconstructTaskStack] Switching provider mode to match target task mode: ${targetTaskMode}`) + + // Switch provider mode to match the target task's mode + try { + await this.handleModeSwitch(targetTaskMode as any) + this.log(`[reconstructTaskStack] Successfully switched provider mode to: ${targetTaskMode}`) + } catch (error) { + this.log( + `[reconstructTaskStack] Failed to switch provider mode: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } catch (error) { + this.log( + `[reconstructTaskStack] ERROR during reconstruction: ${error instanceof Error ? error.message : String(error)}`, + ) + this.log(`[reconstructTaskStack] Stack state after error: ${this.clineStack.length} tasks`) + + // If reconstruction failed and we have no tasks in stack, this could be why parent tasks are getting deleted + if (this.clineStack.length === 0) { + this.log( + `[reconstructTaskStack] CRITICAL: Stack is empty after failed reconstruction - attempting recovery`, + ) + + // Attempt to restore the original stack to prevent parent task deletion + try { + for (const task of originalStack) { + if (!task.abort && !task.abandoned) { + this.clineStack.push(task) + this.log(`[reconstructTaskStack] Recovered task ${task.taskId} to stack`) + } + } + this.log(`[reconstructTaskStack] Recovery completed. Stack size: ${this.clineStack.length}`) + } catch (recoveryError) { + this.log( + `[reconstructTaskStack] Recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`, + ) + } + } + + throw error + } + } + + /** + * Builds the complete task hierarchy from root to target task. + * Returns an array of HistoryItems in execution order (root first, target last). + */ + private async buildTaskHierarchy(targetHistoryItem: HistoryItem): Promise { + const hierarchy: HistoryItem[] = [] + const visited = new Set() + + // Recursive function to build hierarchy + const addToHierarchy = async (historyItem: HistoryItem): Promise => { + // Prevent infinite loops + if (visited.has(historyItem.id)) { + return + } + visited.add(historyItem.id) + + // If this task has a parent, add the parent first + if (historyItem.parentTaskId) { + try { + const { historyItem: parentHistoryItem } = await this.getTaskWithId(historyItem.parentTaskId) + await addToHierarchy(parentHistoryItem) + } catch (error) { + this.log( + `[buildTaskHierarchy] Failed to load parent task ${historyItem.parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + // Add this task to the hierarchy + hierarchy.push(historyItem) + } + + await addToHierarchy(targetHistoryItem) + return hierarchy + } + + /** + * Creates a Task instance from a HistoryItem. + * Used for reconstructing the task stack. + */ + private async createTaskFromHistoryItem( + historyItem: HistoryItem, + shouldStart: boolean = false, + parentTask?: Task, + rootTask?: Task, + ): Promise { + // Check if this task is already completed by examining its saved messages + let isTaskCompleted = false + try { + const savedMessages = await this.getSavedTaskMessages(historyItem.id) + this.log(`[createTaskFromHistoryItem] Task ${historyItem.id} has ${savedMessages.length} saved messages`) + + // Log the last few messages to understand the task state + const lastFewMessages = savedMessages.slice(-5).map((m) => ({ + type: m.type, + say: m.say, + ask: m.ask, + text: m.text?.substring(0, 100) + (m.text?.length > 100 ? "..." : ""), + })) + this.log( + `[createTaskFromHistoryItem] Last 5 messages for task ${historyItem.id}: ${JSON.stringify(lastFewMessages, null, 2)}`, + ) + + const lastRelevantMessage = savedMessages + .slice() + .reverse() + .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) + + // Check multiple completion indicators + isTaskCompleted = + lastRelevantMessage?.ask === "completion_result" || + lastRelevantMessage?.say === "completion_result" || + savedMessages.some((m) => m.ask === "completion_result" || m.say === "completion_result") + + this.log( + `[createTaskFromHistoryItem] Last relevant message: ${JSON.stringify({ + type: lastRelevantMessage?.type, + say: lastRelevantMessage?.say, + ask: lastRelevantMessage?.ask, + })}`, + ) + + this.log( + `[createTaskFromHistoryItem] Task ${historyItem.id} completion status: ${isTaskCompleted ? "COMPLETED" : "NOT_COMPLETED"}`, + ) + + if (isTaskCompleted && shouldStart) { + this.log( + `[createTaskFromHistoryItem] WARNING: Attempting to start already completed task ${historyItem.id}`, + ) + } + } catch (error) { + this.log( + `[createTaskFromHistoryItem] Could not check completion status for task ${historyItem.id}: ${error}`, + ) + } + + const { + apiConfiguration, + diffEnabled: enableDiff, + enableCheckpoints, + fuzzyMatchThreshold, + experiments, + cloudUserInfo, + remoteControlEnabled, + } = await this.getState() + + // For completed tasks, don't start them - they should remain in finished state + const actualShouldStart = shouldStart && !isTaskCompleted + + this.log( + `[createTaskFromHistoryItem] Creating task ${historyItem.id}, shouldStart: ${shouldStart}, isCompleted: ${isTaskCompleted}, actualShouldStart: ${actualShouldStart}`, + ) + + const task = new Task({ + provider: this, + apiConfiguration, + enableDiff, + enableCheckpoints, + fuzzyMatchThreshold, + consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, + historyItem, + experiments, + parentTask, // Pass the actual parent Task object + rootTask, // Pass the actual root Task object + taskNumber: historyItem.number, + workspacePath: historyItem.workspace, + onCreated: this.taskCreationCallback, + enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), + startTask: actualShouldStart, // Only start if not completed + }) + + return task + } + + /** + * Helper method to get saved task messages for completion status checking + */ + private async getSavedTaskMessages(taskId: string): Promise { + try { + const { readTaskMessages } = await import("../task-persistence") + return await readTaskMessages({ taskId, globalStoragePath: this.context.globalStorageUri.fsPath }) + } catch (error) { + this.log(`[getSavedTaskMessages] Failed to read messages for task ${taskId}: ${error}`) + return [] + } + } + async exportTaskWithId(id: string) { const { historyItem, apiConversationHistory } = await this.getTaskWithId(id) await downloadTask(historyItem.ts, apiConversationHistory) diff --git a/src/core/webview/__tests__/ClineProvider.stack-reconstruction.spec.ts b/src/core/webview/__tests__/ClineProvider.stack-reconstruction.spec.ts new file mode 100644 index 0000000000..c881ccc2fd --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.stack-reconstruction.spec.ts @@ -0,0 +1,558 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" + +import { ClineProvider } from "../ClineProvider" +import { Task } from "../../task/Task" +import { ContextProxy } from "../../config/ContextProxy" +import { HistoryItem } from "@roo-code/types" + +// Mock vscode and dependencies +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ get: vi.fn(), update: vi.fn() })), + workspaceFolders: [], + onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), + }, + window: { + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), + }, + env: { machineId: "test", sessionId: "test", language: "en", appName: "VSCode", uriScheme: "vscode", uiKind: 1 }, + UIKind: { Desktop: 1 }, + ConfigurationTarget: { Global: 1 }, + ExtensionMode: { Test: 2 }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { setProvider: vi.fn(), captureTaskRestarted: vi.fn(), captureTaskCreated: vi.fn() }, + }, +})) + +vi.mock("../../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn(() => Promise.resolve({ registerClient: vi.fn() })), + unregisterProvider: vi.fn(), + }, +})) + +vi.mock("../../../services/marketplace") +vi.mock("../../../integrations/workspace/WorkspaceTracker") +vi.mock("../../config/CustomModesManager", () => ({ + CustomModesManager: vi + .fn() + .mockImplementation(() => ({ getCustomModes: vi.fn(() => Promise.resolve([])), dispose: vi.fn() })), +})) +vi.mock("../../config/ProviderSettingsManager", () => ({ + ProviderSettingsManager: vi.fn().mockImplementation(() => ({ + listConfig: vi.fn(() => Promise.resolve([])), + getModeConfigId: vi.fn(() => Promise.resolve(undefined)), + })), +})) +vi.mock("../../../utils/path", () => ({ getWorkspacePath: vi.fn(() => "/test/workspace") })) +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn(() => false), + instance: { isAuthenticated: vi.fn(() => false), on: vi.fn(), off: vi.fn() }, + }, + BridgeOrchestrator: { isEnabled: vi.fn(() => false) }, + getRooCodeApiUrl: vi.fn(() => "https://api.kilocode.ai"), +})) + +// Mock Task constructor to track calls +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation((options) => ({ + taskId: options.historyItem?.id || "mock-task-id", + instanceId: "mock-instance", + isPaused: false, + emit: vi.fn(), + parentTask: options.parentTask, + rootTask: options.rootTask, + })), +})) + +describe("Orchestrator Stack Reconstruction", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockContextProxy: ContextProxy + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + globalStorageUri: { fsPath: "/test/storage" }, + extension: { packageJSON: { version: "1.0.0" } }, + } as any + + mockOutputChannel = { appendLine: vi.fn() } as any + + mockContextProxy = { + extensionUri: { fsPath: "/test/extension" }, + extensionMode: vscode.ExtensionMode.Test, + getValues: vi.fn(() => ({})), + getValue: vi.fn(), + setValue: vi.fn(), + setValues: vi.fn(), + setProviderSettings: vi.fn(), + getProviderSettings: vi.fn(() => ({ apiProvider: "anthropic" })), + globalStorageUri: { fsPath: "/test/storage" }, + } as any + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", mockContextProxy) + }) + + describe("reconstructTaskStack", () => { + it("should reconstruct the complete task stack for orchestrator subtasks", async () => { + // Create a realistic orchestrator workflow hierarchy + const rootTaskHistoryItem: HistoryItem = { + id: "orchestrator-root", + rootTaskId: undefined, + parentTaskId: undefined, + number: 1, + ts: Date.now() - 3000, + task: "Build a web application", + tokensIn: 500, + tokensOut: 250, + totalCost: 0.05, + workspace: "/test/workspace", + mode: "orchestrator", + } + + const codeSubtaskHistoryItem: HistoryItem = { + id: "code-subtask", + rootTaskId: "orchestrator-root", + parentTaskId: "orchestrator-root", + number: 2, + ts: Date.now() - 2000, + task: "Implement authentication backend", + tokensIn: 300, + tokensOut: 150, + totalCost: 0.03, + workspace: "/test/workspace", + mode: "code", + } + + const debugSubtaskHistoryItem: HistoryItem = { + id: "debug-subtask", + rootTaskId: "orchestrator-root", + parentTaskId: "code-subtask", + number: 3, + ts: Date.now() - 1000, + task: "Debug authentication issues", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: "/test/workspace", + mode: "debug", + } + + // Mock getState + vi.spyOn(provider, "getState").mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 3 }, + diffEnabled: true, + enableCheckpoints: true, + fuzzyMatchThreshold: 1.0, + experiments: {}, + cloudUserInfo: null, + remoteControlEnabled: false, + } as any) + + // Mock getTaskWithId to return the appropriate history items + vi.spyOn(provider, "getTaskWithId").mockImplementation(async (id: string) => { + switch (id) { + case "orchestrator-root": + return { historyItem: rootTaskHistoryItem } as any + case "code-subtask": + return { historyItem: codeSubtaskHistoryItem } as any + case "debug-subtask": + return { historyItem: debugSubtaskHistoryItem } as any + default: + throw new Error(`Task not found: ${id}`) + } + }) + + // Mock getCurrentTask to return undefined (no current task) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + // Mock getSavedTaskMessages to return incomplete task messages (not completed) + ;(provider as any).getSavedTaskMessages = vi.fn().mockImplementation(async (taskId: string) => { + // Return messages that indicate the tasks are not completed + return [ + { type: "ask", ask: "command", text: "Some command" }, + { type: "say", say: "command_output", text: "Command output" }, + ] + }) + + // Mock removeClineFromStack + vi.spyOn(provider, "removeClineFromStack").mockResolvedValue() + + // Mock addClineToStack to track calls + const addClineToStackSpy = vi.spyOn(provider, "addClineToStack").mockResolvedValue() + + // Mock postMessageToWebview + vi.spyOn(provider, "postMessageToWebview").mockResolvedValue() + + // Test: Load the debug subtask which should reconstruct the entire stack + await provider.showTaskWithId("debug-subtask") + + // Verify that addClineToStack was called 3 times (root, code subtask, debug subtask) + expect(addClineToStackSpy).toHaveBeenCalledTimes(3) + + // Verify the order of tasks added to stack + const taskCalls = addClineToStackSpy.mock.calls + expect(taskCalls[0][0].taskId).toBe("orchestrator-root") // Root task first + expect(taskCalls[1][0].taskId).toBe("code-subtask") // Code subtask second + expect(taskCalls[2][0].taskId).toBe("debug-subtask") // Debug subtask last + + // Verify that parent tasks are paused + expect(taskCalls[0][0].isPaused).toBe(true) // Root task should be paused + expect(taskCalls[1][0].isPaused).toBe(true) // Code subtask should be paused + expect(taskCalls[2][0].isPaused).toBe(false) // Debug subtask should NOT be paused (it's the active one) + }) + + it("should handle simple parent-child relationship", async () => { + const parentTaskHistoryItem: HistoryItem = { + id: "parent-task", + rootTaskId: undefined, + parentTaskId: undefined, + number: 1, + ts: Date.now() - 2000, + task: "Parent task", + tokensIn: 400, + tokensOut: 200, + totalCost: 0.04, + workspace: "/test/workspace", + mode: "orchestrator", + } + + const childTaskHistoryItem: HistoryItem = { + id: "child-task", + rootTaskId: "parent-task", + parentTaskId: "parent-task", + number: 2, + ts: Date.now() - 1000, + task: "Child task", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: "/test/workspace", + mode: "code", + } + + // Mock getState + vi.spyOn(provider, "getState").mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 3 }, + diffEnabled: true, + enableCheckpoints: true, + fuzzyMatchThreshold: 1.0, + experiments: {}, + cloudUserInfo: null, + remoteControlEnabled: false, + } as any) + + // Mock getTaskWithId + vi.spyOn(provider, "getTaskWithId").mockImplementation(async (id: string) => { + switch (id) { + case "parent-task": + return { historyItem: parentTaskHistoryItem } as any + case "child-task": + return { historyItem: childTaskHistoryItem } as any + default: + throw new Error(`Task not found: ${id}`) + } + }) + + // Mock getCurrentTask to return undefined + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + // Mock getSavedTaskMessages to return incomplete task messages (not completed) + ;(provider as any).getSavedTaskMessages = vi.fn().mockImplementation(async (taskId: string) => { + // Return messages that indicate the tasks are not completed + return [ + { type: "ask", ask: "command", text: "Some command" }, + { type: "say", say: "command_output", text: "Command output" }, + ] + }) + + // Mock removeClineFromStack + vi.spyOn(provider, "removeClineFromStack").mockResolvedValue() + + // Mock addClineToStack + const addClineToStackSpy = vi.spyOn(provider, "addClineToStack").mockResolvedValue() + + // Mock postMessageToWebview + vi.spyOn(provider, "postMessageToWebview").mockResolvedValue() + + // Test: Load the child task + await provider.showTaskWithId("child-task") + + // Verify that addClineToStack was called 2 times (parent, then child) + expect(addClineToStackSpy).toHaveBeenCalledTimes(2) + + // Verify the order + const taskCalls = addClineToStackSpy.mock.calls + expect(taskCalls[0][0].taskId).toBe("parent-task") // Parent first + expect(taskCalls[1][0].taskId).toBe("child-task") // Child second + + // Verify that parent is paused, child is not + expect(taskCalls[0][0].isPaused).toBe(true) // Parent should be paused + expect(taskCalls[1][0].isPaused).toBe(false) // Child should NOT be paused + }) + + it("should handle standalone tasks normally", async () => { + const standaloneTaskHistoryItem: HistoryItem = { + id: "standalone-task", + rootTaskId: undefined, + parentTaskId: undefined, + number: 1, + ts: Date.now() - 1000, + task: "Standalone task", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: "/test/workspace", + mode: "code", + } + + // Mock getTaskWithId + vi.spyOn(provider, "getTaskWithId").mockResolvedValue({ + historyItem: standaloneTaskHistoryItem, + } as any) + + // Mock getCurrentTask to return undefined + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + // Mock createTaskWithHistoryItem + const createTaskSpy = vi.spyOn(provider, "createTaskWithHistoryItem").mockResolvedValue({} as any) + + // Mock postMessageToWebview + vi.spyOn(provider, "postMessageToWebview").mockResolvedValue() + + // Test: Load the standalone task + await provider.showTaskWithId("standalone-task") + + // Verify that createTaskWithHistoryItem was called (normal flow) + expect(createTaskSpy).toHaveBeenCalledWith(standaloneTaskHistoryItem) + + // Verify that stack reconstruction methods were not called + expect(provider.getTaskWithId).toHaveBeenCalledTimes(1) // Only for the main task + }) + }) + + describe("buildTaskHierarchy", () => { + it("should build correct hierarchy for nested subtasks", async () => { + // Create a 3-level hierarchy: root -> intermediate -> target + const rootTaskHistoryItem: HistoryItem = { + id: "root-task", + rootTaskId: undefined, + parentTaskId: undefined, + number: 1, + ts: Date.now() - 3000, + task: "Root orchestrator task", + tokensIn: 500, + tokensOut: 250, + totalCost: 0.05, + workspace: "/test/workspace", + mode: "orchestrator", + } + + const intermediateTaskHistoryItem: HistoryItem = { + id: "intermediate-task", + rootTaskId: "root-task", + parentTaskId: "root-task", + number: 2, + ts: Date.now() - 2000, + task: "Intermediate task", + tokensIn: 300, + tokensOut: 150, + totalCost: 0.03, + workspace: "/test/workspace", + mode: "code", + } + + const targetTaskHistoryItem: HistoryItem = { + id: "target-task", + rootTaskId: "root-task", + parentTaskId: "intermediate-task", + number: 3, + ts: Date.now() - 1000, + task: "Target subtask", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: "/test/workspace", + mode: "debug", + } + + // Mock getTaskWithId + vi.spyOn(provider, "getTaskWithId").mockImplementation(async (id: string) => { + switch (id) { + case "root-task": + return { historyItem: rootTaskHistoryItem } as any + case "intermediate-task": + return { historyItem: intermediateTaskHistoryItem } as any + case "target-task": + return { historyItem: targetTaskHistoryItem } as any + default: + throw new Error(`Task not found: ${id}`) + } + }) + + // Call the private method using type assertion + const hierarchy = await (provider as any).buildTaskHierarchy(targetTaskHistoryItem) + + // Verify the hierarchy is built correctly + expect(hierarchy).toHaveLength(3) + expect(hierarchy[0].id).toBe("root-task") // Root first + expect(hierarchy[1].id).toBe("intermediate-task") // Intermediate second + expect(hierarchy[2].id).toBe("target-task") // Target last + }) + + it("should handle circular references gracefully", async () => { + // Create circular reference (should not happen in practice, but test for robustness) + const task1HistoryItem: HistoryItem = { + id: "task1", + rootTaskId: "task2", + parentTaskId: "task2", + number: 1, + ts: Date.now() - 2000, + task: "Task 1", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: "/test/workspace", + mode: "code", + } + + const task2HistoryItem: HistoryItem = { + id: "task2", + rootTaskId: "task1", + parentTaskId: "task1", + number: 2, + ts: Date.now() - 1000, + task: "Task 2", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: "/test/workspace", + mode: "debug", + } + + // Mock getTaskWithId + vi.spyOn(provider, "getTaskWithId").mockImplementation(async (id: string) => { + switch (id) { + case "task1": + return { historyItem: task1HistoryItem } as any + case "task2": + return { historyItem: task2HistoryItem } as any + default: + throw new Error(`Task not found: ${id}`) + } + }) + + // Call the private method - should not hang due to circular reference + const hierarchy = await (provider as any).buildTaskHierarchy(task1HistoryItem) + + // Should include both tasks but not hang due to circular reference protection + expect(hierarchy).toHaveLength(2) + expect(hierarchy[0].id).toBe("task2") // Parent loaded first + expect(hierarchy[1].id).toBe("task1") // Target loaded second + }) + }) + + describe("end-to-end orchestrator workflow", () => { + it("should properly reconstruct stack so subtask can return to parent", async () => { + // Simulate the complete orchestrator workflow + const orchestratorHistoryItem: HistoryItem = { + id: "orchestrator-main", + rootTaskId: undefined, + parentTaskId: undefined, + number: 1, + ts: Date.now() - 2000, + task: "Main orchestrator task", + tokensIn: 400, + tokensOut: 200, + totalCost: 0.04, + workspace: "/test/workspace", + mode: "orchestrator", + } + + const subtaskHistoryItem: HistoryItem = { + id: "code-subtask", + rootTaskId: "orchestrator-main", + parentTaskId: "orchestrator-main", + number: 2, + ts: Date.now() - 1000, + task: "Code implementation subtask", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: "/test/workspace", + mode: "code", + } + + // Mock getState + vi.spyOn(provider, "getState").mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 3 }, + diffEnabled: true, + enableCheckpoints: true, + fuzzyMatchThreshold: 1.0, + experiments: {}, + cloudUserInfo: null, + remoteControlEnabled: false, + } as any) + + // Mock getTaskWithId + vi.spyOn(provider, "getTaskWithId").mockImplementation(async (id: string) => { + switch (id) { + case "orchestrator-main": + return { historyItem: orchestratorHistoryItem } as any + case "code-subtask": + return { historyItem: subtaskHistoryItem } as any + default: + throw new Error(`Task not found: ${id}`) + } + }) + + // Mock getCurrentTask to return undefined + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + // Mock getSavedTaskMessages to return incomplete task messages (not completed) + ;(provider as any).getSavedTaskMessages = vi.fn().mockImplementation(async (taskId: string) => { + // Return messages that indicate the tasks are not completed + return [ + { type: "ask", ask: "command", text: "Some command" }, + { type: "say", say: "command_output", text: "Command output" }, + ] + }) + + // Mock removeClineFromStack + vi.spyOn(provider, "removeClineFromStack").mockResolvedValue() + + // Mock addClineToStack to track the stack reconstruction + const addClineToStackSpy = vi.spyOn(provider, "addClineToStack").mockResolvedValue() + + // Mock postMessageToWebview + vi.spyOn(provider, "postMessageToWebview").mockResolvedValue() + + // Test: Load the subtask from history + await provider.showTaskWithId("code-subtask") + + // Verify that the stack was reconstructed correctly + expect(addClineToStackSpy).toHaveBeenCalledTimes(2) + + // Verify the order: parent first, then child + const taskCalls = addClineToStackSpy.mock.calls + expect(taskCalls[0][0].taskId).toBe("orchestrator-main") // Parent first + expect(taskCalls[1][0].taskId).toBe("code-subtask") // Child second + + // Verify that the parent is paused so the child can run + expect(taskCalls[0][0].isPaused).toBe(true) // Parent should be paused + expect(taskCalls[1][0].isPaused).toBe(false) // Child should be active + + // This setup ensures that when the code-subtask finishes and calls + // finishSubTask(), the orchestrator-main task will be resumed + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.taskReconstruction.spec.ts b/src/core/webview/__tests__/ClineProvider.taskReconstruction.spec.ts new file mode 100644 index 0000000000..119f9dcd2f --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.taskReconstruction.spec.ts @@ -0,0 +1,492 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" +import { ClineProvider } from "../ClineProvider" +import { Task } from "../../task/Task" +import { ContextProxy } from "../../config/ContextProxy" +import { HistoryItem } from "@roo-code/types" + +// Mock dependencies +vi.mock("vscode", () => ({ + ExtensionMode: { + Development: 1, + Production: 2, + Test: 3, + }, + Uri: { + file: vi.fn((path: string) => ({ fsPath: path, toString: () => path })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + workspaceFolders: [], + createFileSystemWatcher: vi.fn().mockReturnValue({ + onDidCreate: vi.fn(), + onDidChange: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }), + onDidChangeConfiguration: vi.fn(), + }, + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(), + createTextEditorDecorationType: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + }, + commands: { + executeCommand: vi.fn(), + }, + env: { + machineId: "test-machine-id", + sessionId: "test-session-id", + language: "en", + uriScheme: "vscode", + appName: "Visual Studio Code", + }, + version: "1.0.0", + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, +})) +vi.mock("../../task/Task") +vi.mock("../../../utils/fs") +vi.mock("../../../utils/git") +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn().mockReturnValue("/test/workspace"), +})) +vi.mock("../../config/ProviderSettingsManager", () => ({ + ProviderSettingsManager: vi.fn().mockImplementation(() => ({ + syncCloudProfiles: vi.fn(), + listConfig: vi.fn().mockResolvedValue([]), + getModeConfigId: vi.fn(), + getProfile: vi.fn(), + setModeConfig: vi.fn(), + activateProfile: vi.fn(), + saveConfig: vi.fn(), + resetAllConfigs: vi.fn(), + })), +})) +vi.mock("../../config/CustomModesManager", () => ({ + CustomModesManager: vi.fn().mockImplementation(() => ({ + getCustomModes: vi.fn().mockResolvedValue([]), + resetCustomModes: vi.fn(), + dispose: vi.fn(), + })), +})) +vi.mock("../../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn().mockResolvedValue({ + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + }), + unregisterProvider: vi.fn(), + }, +})) +vi.mock("../../../services/marketplace", () => ({ + MarketplaceManager: vi.fn().mockImplementation(() => ({ + getMarketplaceItems: vi.fn().mockResolvedValue({ organizationMcps: [], marketplaceItems: [], errors: [] }), + getInstallationMetadata: vi.fn().mockResolvedValue({ project: {}, global: {} }), + cleanup: vi.fn(), + })), +})) +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + setProvider: vi.fn(), + captureCodeActionUsed: vi.fn(), + captureModeSwitch: vi.fn(), + captureTaskRestarted: vi.fn(), + captureTaskCreated: vi.fn(), + captureConversationMessage: vi.fn(), + captureLlmCompletion: vi.fn(), + captureTaskCompleted: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(false), + instance: { + isAuthenticated: vi.fn().mockReturnValue(false), + getUserInfo: vi.fn().mockReturnValue(null), + canShareTask: vi.fn().mockReturnValue(false), + getOrganizationSettings: vi.fn().mockReturnValue(null), + isTaskSyncEnabled: vi.fn().mockReturnValue(false), + getAllowList: vi.fn().mockReturnValue("*"), + }, + isEnabled: vi.fn().mockReturnValue(false), + }, + BridgeOrchestrator: { + isEnabled: vi.fn().mockReturnValue(false), + getInstance: vi.fn().mockReturnValue(null), + subscribeToTask: vi.fn(), + unsubscribeFromTask: vi.fn(), + connectOrDisconnect: vi.fn(), + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://api.roo-code.com"), +})) + +describe("ClineProvider Task Reconstruction", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockContextProxy: ContextProxy + + beforeEach(() => { + // Setup mocks + mockContext = { + globalStorageUri: { fsPath: "/mock/storage" }, + extension: { packageJSON: { version: "1.0.0" } }, + } as any + + mockOutputChannel = { + appendLine: vi.fn(), + } as any + + mockContextProxy = { + extensionUri: vscode.Uri.file("/mock/extension"), + extensionMode: vscode.ExtensionMode.Development, + getValues: vi.fn().mockReturnValue({}), + getValue: vi.fn(), + setValue: vi.fn(), + setValues: vi.fn(), + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "anthropic" }), + setProviderSettings: vi.fn(), + resetAllState: vi.fn(), + } as any + + // Mock vscode.workspace + vi.mocked(vscode.workspace).getConfiguration = vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }) + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", mockContextProxy) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("reconstructTaskStack", () => { + it("should not restart finished subtasks", async () => { + // Mock a completed subtask + const completedSubtaskHistory: HistoryItem = { + id: "completed-subtask-id", + ts: Date.now(), + task: "Test completed subtask", + parentTaskId: "parent-task-id", + rootTaskId: "root-task-id", + number: 2, + workspace: "/test/workspace", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + } + + const parentTaskHistory: HistoryItem = { + id: "parent-task-id", + ts: Date.now() - 1000, + task: "Test parent task", + number: 1, + workspace: "/test/workspace", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + } + + // Mock saved messages for completed task + const completedTaskMessages = [ + { type: "say", say: "text", text: "Starting task..." }, + { type: "ask", ask: "completion_result", text: "Task completed successfully!" }, + ] + + const parentTaskMessages = [{ type: "say", say: "text", text: "Parent task running..." }] + + // Mock the getSavedTaskMessages method + ;(provider as any).getSavedTaskMessages = vi.fn().mockImplementation((taskId: string) => { + if (taskId === "completed-subtask-id") { + return Promise.resolve(completedTaskMessages) + } else if (taskId === "parent-task-id") { + return Promise.resolve(parentTaskMessages) + } + return Promise.resolve([]) + }) + + // Mock getTaskWithId + ;(provider as any).getTaskWithId = vi.fn().mockImplementation((id: string) => { + if (id === "completed-subtask-id") { + return Promise.resolve({ historyItem: completedSubtaskHistory }) + } else if (id === "parent-task-id") { + return Promise.resolve({ historyItem: parentTaskHistory }) + } + throw new Error("Task not found") + }) + + // Mock getState + ;(provider as any).getState = vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic" }, + diffEnabled: true, + enableCheckpoints: true, + fuzzyMatchThreshold: 1.0, + experiments: {}, + cloudUserInfo: null, + remoteControlEnabled: false, + }) + + // Mock Task constructor + const mockTaskConstructor = vi.mocked(Task) + mockTaskConstructor.mockImplementation( + (options: any) => + ({ + taskId: options.historyItem.id, + instanceId: "mock-instance", + isPaused: false, + parentTask: options.parentTask, + rootTask: options.rootTask, + emit: vi.fn(), + getSavedClineMessages: vi.fn().mockResolvedValue([]), + getSavedApiConversationHistory: vi.fn().mockResolvedValue([]), + abortTask: vi.fn(), + dispose: vi.fn(), + }) as any, + ) + + // Mock addClineToStack + ;(provider as any).addClineToStack = vi.fn() + + // Call reconstructTaskStack + await (provider as any).reconstructTaskStack(completedSubtaskHistory) + + // Verify that the completed subtask was created with startTask: false + const taskConstructorCalls = mockTaskConstructor.mock.calls + expect(taskConstructorCalls).toHaveLength(2) // Parent + completed subtask + + // Find the completed subtask call + const completedSubtaskCall = taskConstructorCalls.find( + (call) => call[0]?.historyItem?.id === "completed-subtask-id", + ) + expect(completedSubtaskCall).toBeDefined() + if (completedSubtaskCall) { + expect(completedSubtaskCall[0].startTask).toBe(false) // Should not start completed task + } + + // Find the parent task call + const parentTaskCall = taskConstructorCalls.find((call) => call[0]?.historyItem?.id === "parent-task-id") + expect(parentTaskCall).toBeDefined() + if (parentTaskCall) { + expect(parentTaskCall[0].startTask).toBe(false) // Parent should not start either + } + }) + + it("should recover original stack if reconstruction fails", async () => { + const targetHistory: HistoryItem = { + id: "target-task-id", + ts: Date.now(), + task: "Test target task", + parentTaskId: "parent-task-id", + number: 2, + workspace: "/test/workspace", + tokensIn: 150, + tokensOut: 75, + totalCost: 0.015, + } + + // Create mock tasks for original stack + const originalTask1 = { + taskId: "original-1", + instanceId: "instance-1", + isPaused: false, + abort: false, + abandoned: false, + } as any + + const originalTask2 = { + taskId: "original-2", + instanceId: "instance-2", + isPaused: true, + abort: false, + abandoned: false, + } as any + + // Set up original stack + ;(provider as any).clineStack = [originalTask1, originalTask2] + + // Mock buildTaskHierarchy to throw an error + ;(provider as any).buildTaskHierarchy = vi.fn().mockRejectedValue(new Error("Failed to build hierarchy")) + + // Mock removeClineFromStack to clear the stack + ;(provider as any).removeClineFromStack = vi.fn().mockImplementation(() => { + ;(provider as any).clineStack = [] + return Promise.resolve() + }) + + // Attempt reconstruction (should fail and recover) + await expect((provider as any).reconstructTaskStack(targetHistory)).rejects.toThrow( + "Failed to build hierarchy", + ) + + // Verify that the original stack was recovered + expect((provider as any).clineStack).toHaveLength(2) + expect((provider as any).clineStack[0].taskId).toBe("original-1") + expect((provider as any).clineStack[1].taskId).toBe("original-2") + }) + + it("should handle completed tasks correctly in createTaskFromHistoryItem", async () => { + const completedTaskHistory: HistoryItem = { + id: "completed-task-id", + ts: Date.now(), + task: "Test completed task", + number: 1, + workspace: "/test/workspace", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + } + + // Mock saved messages for completed task + const completedTaskMessages = [ + { type: "say", say: "text", text: "Starting task..." }, + { type: "ask", ask: "completion_result", text: "Task completed successfully!" }, + ] + + // Mock the getSavedTaskMessages method + ;(provider as any).getSavedTaskMessages = vi.fn().mockResolvedValue(completedTaskMessages) + + // Mock getState + ;(provider as any).getState = vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic" }, + diffEnabled: true, + enableCheckpoints: true, + fuzzyMatchThreshold: 1.0, + experiments: {}, + cloudUserInfo: null, + remoteControlEnabled: false, + }) + + // Mock Task constructor + const mockTaskConstructor = vi.mocked(Task) + mockTaskConstructor.mockImplementation( + (options: any) => + ({ + taskId: options.historyItem.id, + instanceId: "mock-instance", + startTask: options.startTask, + }) as any, + ) + + // Call createTaskFromHistoryItem with shouldStart: true + const task = await (provider as any).createTaskFromHistoryItem(completedTaskHistory, true) + + // Verify that the task was created with startTask: false (because it's completed) + expect(mockTaskConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + startTask: false, // Should be false for completed tasks + }), + ) + }) + + it("should start non-completed tasks normally", async () => { + const incompleteTaskHistory: HistoryItem = { + id: "incomplete-task-id", + ts: Date.now(), + task: "Test incomplete task", + number: 1, + workspace: "/test/workspace", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + } + + // Mock saved messages for incomplete task (no completion_result) + const incompleteTaskMessages = [ + { type: "say", say: "text", text: "Starting task..." }, + { type: "ask", ask: "tool_use", text: "Using some tool..." }, + ] + + // Mock the getSavedTaskMessages method + ;(provider as any).getSavedTaskMessages = vi.fn().mockResolvedValue(incompleteTaskMessages) + + // Mock getState + ;(provider as any).getState = vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic" }, + diffEnabled: true, + enableCheckpoints: true, + fuzzyMatchThreshold: 1.0, + experiments: {}, + cloudUserInfo: null, + remoteControlEnabled: false, + }) + + // Mock Task constructor + const mockTaskConstructor = vi.mocked(Task) + mockTaskConstructor.mockImplementation( + (options: any) => + ({ + taskId: options.historyItem.id, + instanceId: "mock-instance", + startTask: options.startTask, + }) as any, + ) + + // Call createTaskFromHistoryItem with shouldStart: true + const task = await (provider as any).createTaskFromHistoryItem(incompleteTaskHistory, true) + + // Verify that the task was created with startTask: true (because it's not completed) + expect(mockTaskConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + startTask: true, // Should be true for incomplete tasks + }), + ) + }) + }) + + describe("removeClineFromStack", () => { + it("should log stack information when removing tasks", async () => { + const mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + emit: vi.fn(), + abortTask: vi.fn(), + abort: false, + abandoned: false, + } as any + + ;(provider as any).clineStack = [mockTask] + ;(provider as any).taskEventListeners = new WeakMap() + + const logSpy = vi.spyOn(provider, "log") + + await (provider as any).removeClineFromStack() + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Removing task test-task-id.test-instance from stack"), + ) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Remaining stack size: 0")) + }) + + it("should handle empty stack gracefully", async () => { + ;(provider as any).clineStack = [] + + const logSpy = vi.spyOn(provider, "log") + + await (provider as any).removeClineFromStack() + + expect(logSpy).toHaveBeenCalledWith("[removeClineFromStack] Stack is already empty") + }) + }) +})