diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 677d6e587d..5dfc2d395f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1623,6 +1623,11 @@ export class Task extends EventEmitter implements TaskLike { this.isPaused = false this.childTaskId = undefined + const provider = this.providerRef.deref() + if (provider) { + await provider.handleModeSwitch(this.pausedModeSlug) + } + this.emit(RooCodeEventName.TaskUnpaused, this.taskId) // Fake an answer from the subtask that it has completed running and diff --git a/src/core/task/__tests__/Task.subtask-mode-restore.spec.ts b/src/core/task/__tests__/Task.subtask-mode-restore.spec.ts new file mode 100644 index 0000000000..cba4cfc98e --- /dev/null +++ b/src/core/task/__tests__/Task.subtask-mode-restore.spec.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { TodoItem } from "@roo-code/types" + +// Mock TelemetryService singleton to avoid uninitialized errors in tests (matches Roo Code test patterns). +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + _instance: { + captureModeSwitch: vi.fn(), + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + captureTaskCompleted: vi.fn(), + captureConversationMessage: vi.fn(), + }, + get instance() { + return this._instance + }, + set instance(value) { + this._instance = value + }, + }, +})) + +beforeEach(() => { + // In some test runners, TelemetryService may not be stubbed at require time; + // assign the singleton property here to be robust. + try { + const Telemetry = require("@roo-code/telemetry") + if (Telemetry && Telemetry.TelemetryService) { + Telemetry.TelemetryService._instance = { + captureModeSwitch: vi.fn(), + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + captureTaskCompleted: vi.fn(), + } + } + } catch { + /* ignore */ + } +}) + +/** + * Mock VSCode APIs used by RooIgnoreController and Task for all test contexts. + * This prevents test failures due to missing extension context or filesystem watcher dependencies, + * including RelativePattern, workspace, window, Uri, and other VSCode stubs. + */ +vi.mock("vscode", () => ({ + RelativePattern: class {}, + workspace: { + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(), + onDidChange: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + })), + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => undefined), + })), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + createTerminal: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({})), + activeTextEditor: undefined, + visibleTextEditors: [], + registerWebviewViewProvider: vi.fn(), + tabGroups: { all: [] }, + }, + env: { language: "en" }, + Uri: { + parse: vi.fn((input) => input), + joinPath: vi.fn((...args) => args.join("/")), + }, + FileType: { File: 1, Directory: 2, SymbolicLink: 64 }, + languages: { getDiagnostics: vi.fn(() => []) }, +})) + +describe("Task subtask mode restoration", () => { + let parentTask: Task + let mockProvider: any + + const mockContext = { + globalStorageUri: { fsPath: "/mock/storage" }, + } + + beforeEach(() => { + const mockAgentAPIs = { + context: mockContext, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + postStateToWebview: vi.fn(), + getState: vi.fn(() => ({})), + ask: vi.fn().mockResolvedValue({ response: "", text: "", images: [] }), + say: vi.fn().mockResolvedValue(undefined), + } + mockProvider = { + ...mockAgentAPIs, + deref: vi.fn().mockReturnValue({ ...mockAgentAPIs }), + } + }) + + it("should restore parent task mode when subtask completes", async () => { + // Create parent task with orchestrator mode + parentTask = new Task({ + provider: mockProvider as any, + apiConfiguration: {} as any, + task: "Parent task", + }) + + // Set parent task to orchestrator mode + parentTask.pausedModeSlug = "orchestrator" + + // Mock the provider reference + parentTask.providerRef = { + deref: () => mockProvider.deref(), + } as any + + // Complete the subtask + await parentTask.completeSubtask("Subtask completed") + + // Verify handleModeSwitch was called with the pausedModeSlug + expect(mockProvider.deref().handleModeSwitch).toHaveBeenCalledWith("orchestrator") + + // Verify task is unpaused + expect(parentTask.isPaused).toBe(false) + + // Verify childTaskId is cleared + expect(parentTask.childTaskId).toBeUndefined() + }) + + it("should call handleModeSwitch before UI updates (order of operations)", async () => { + const callOrder: string[] = [] + const handleModeSwitchSpy = vi.fn(() => { + callOrder.push("handleModeSwitch") + return Promise.resolve() + }) + const postStateToWebviewSpy = vi.fn(() => { + callOrder.push("postStateToWebview") + return Promise.resolve() + }) + + mockProvider = { + ...mockProvider, + handleModeSwitch: handleModeSwitchSpy, + postStateToWebview: postStateToWebviewSpy, + deref: vi.fn().mockReturnValue({ + ...(mockProvider.deref ? mockProvider.deref() : {}), + handleModeSwitch: handleModeSwitchSpy, + postStateToWebview: postStateToWebviewSpy, + }), + } + parentTask = new Task({ + provider: mockProvider as any, + apiConfiguration: {} as any, + task: "Parent task", + }) + parentTask.pausedModeSlug = "orchestrator" + parentTask.providerRef = { + deref: () => mockProvider.deref(), + } as any + await parentTask.completeSubtask("done") + // Since only handleModeSwitch (and not postStateToWebview directly) should be called in this minimal patch, assert order and presence + expect(callOrder.filter((v) => v === "handleModeSwitch").length).toBeGreaterThanOrEqual(1) + expect(callOrder.indexOf("handleModeSwitch")).toBeLessThan(callOrder.lastIndexOf("postStateToWebview") || 1) + }) + + it("should handle missing provider gracefully", async () => { + // Create parent task + parentTask = new Task({ + provider: mockProvider as any, + apiConfiguration: {} as any, + task: "Parent task", + }) + + // Set parent task to orchestrator mode + parentTask.pausedModeSlug = "orchestrator" + + // Mock provider as unavailable + parentTask.providerRef = { + deref: () => undefined, + } as any + + // Complete the subtask - should not throw + await expect(parentTask.completeSubtask("Subtask completed")).resolves.not.toThrow() + + // Verify task is still unpaused + expect(parentTask.isPaused).toBe(false) + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 544922a187..7880d3dc2d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -433,12 +433,24 @@ export class ClineProvider // This is used when a subtask is finished and the parent task needs to be // resumed. async finishSubTask(lastMessage: string) { - // Remove the last cline instance from the stack (this is the finished - // subtask). + // Remove the last cline instance from the stack (this is the finished subtask). 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) + // Defensive: If there is a parent, try to resume and handle potential errors gracefully. + const parent = this.getCurrentTask() + try { + if (parent) { + await parent.completeSubtask(lastMessage) + } else { + this.log?.( + "ClineProvider.finishSubTask: No parent task found after popping stack; UI may be inconsistent.", + ) + } + } catch (err) { + this.log?.( + `ClineProvider.finishSubTask: Error resuming parent task ${parent?.taskId ?? "unknown"}: ${err?.message || err}`, + ) + // Optionally, trigger a fallback UI error or state refresh. + } } /*