Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1623,6 +1623,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.isPaused = false
this.childTaskId = undefined

const provider = this.providerRef.deref()
if (provider) {
await provider.handleModeSwitch(this.pausedModeSlug)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good defensive programming with the null check! However, I noticed that handleModeSwitch() in ClineProvider already calls postStateToWebview() internally. Since finishSubTask() also calls handleModeSwitch() and then explicitly calls postStateToWebview() again (line 444 in ClineProvider), could this cause duplicate UI updates? Would it be cleaner to rely on just one of these calls?

}

this.emit(RooCodeEventName.TaskUnpaused, this.taskId)

// Fake an answer from the subtask that it has completed running and
Expand Down
192 changes: 192 additions & 0 deletions src/core/task/__tests__/Task.subtask-mode-restore.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great test coverage for the core functionality! Consider adding a test case that verifies the order of operations - specifically that handleModeSwitch() is called before any UI updates. This would help ensure the mode is properly set before the UI refreshes.


// 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)
})
})
22 changes: 17 additions & 5 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of optional chaining on 'this.log' (e.g. this.log?.(...)) is unnecessary since log() is always defined; removing the '?' would improve clarity.

"ClineProvider.finishSubTask: No parent task found after popping stack; UI may be inconsistent.",
Copy link

Copilot AI Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message could be more actionable. Consider adding guidance on what the user should do when this state occurs, such as 'Consider restarting the session if the UI becomes unresponsive.'

Suggested change
"ClineProvider.finishSubTask: No parent task found after popping stack; UI may be inconsistent.",
"ClineProvider.finishSubTask: No parent task found after popping stack; UI may be inconsistent. Consider restarting the session if the UI becomes unresponsive.",

Copilot uses AI. Check for mistakes.
)
}
} 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.
}
}

/*
Expand Down
Loading