diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index 9e142d1df2..735d6c16b1 100644 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ b/packages/cloud/src/bridge/ExtensionChannel.ts @@ -175,6 +175,9 @@ export class ExtensionChannel extends BaseChannel< { from: RooCodeEventName.TaskInteractive, to: ExtensionBridgeEventName.TaskInteractive }, { from: RooCodeEventName.TaskResumable, to: ExtensionBridgeEventName.TaskResumable }, { from: RooCodeEventName.TaskIdle, to: ExtensionBridgeEventName.TaskIdle }, + { from: RooCodeEventName.TaskPaused, to: ExtensionBridgeEventName.TaskPaused }, + { from: RooCodeEventName.TaskUnpaused, to: ExtensionBridgeEventName.TaskUnpaused }, + { from: RooCodeEventName.TaskSpawned, to: ExtensionBridgeEventName.TaskSpawned }, { from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage }, ] as const @@ -223,6 +226,8 @@ export class ExtensionChannel extends BaseChannel< taskStatus: task.taskStatus, taskAsk: task?.taskAsk, queuedMessages: task.queuedMessages, + parentTaskId: task.parentTaskId, + childTaskId: task.childTaskId, ...task.metadata, } : { taskId: "", taskStatus: TaskStatus.None }, diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts index 511137bf1e..f4d09e51a5 100644 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts @@ -101,6 +101,9 @@ describe("ExtensionChannel", () => { RooCodeEventName.TaskInteractive, RooCodeEventName.TaskResumable, RooCodeEventName.TaskIdle, + RooCodeEventName.TaskPaused, + RooCodeEventName.TaskUnpaused, + RooCodeEventName.TaskSpawned, RooCodeEventName.TaskUserMessage, ] @@ -231,8 +234,7 @@ describe("ExtensionChannel", () => { } // Listeners should still be the same count (not accumulated) - const expectedEventCount = 11 // Number of events we listen to (including TaskUserMessage) - expect(eventListeners.size).toBe(expectedEventCount) + expect(eventListeners.size).toBe(14) // Each event should have exactly 1 listener eventListeners.forEach((listeners) => { diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 9fe0e70493..11da65550c 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.67.0", + "version": "1.69.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 418863e804..ed5bb4e187 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -361,6 +361,8 @@ const extensionTaskSchema = z.object({ taskStatus: z.nativeEnum(TaskStatus), taskAsk: clineMessageSchema.optional(), queuedMessages: z.array(queuedMessageSchema).optional(), + parentTaskId: z.string().optional(), + childTaskId: z.string().optional(), ...taskMetadataSchema.shape, }) @@ -404,6 +406,10 @@ export enum ExtensionBridgeEventName { TaskResumable = RooCodeEventName.TaskResumable, TaskIdle = RooCodeEventName.TaskIdle, + TaskPaused = RooCodeEventName.TaskPaused, + TaskUnpaused = RooCodeEventName.TaskUnpaused, + TaskSpawned = RooCodeEventName.TaskSpawned, + TaskUserMessage = RooCodeEventName.TaskUserMessage, ModeChanged = RooCodeEventName.ModeChanged, @@ -466,6 +472,22 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ timestamp: z.number(), }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskPaused), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskUnpaused), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskSpawned), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ type: z.literal(ExtensionBridgeEventName.TaskUserMessage), instance: extensionInstanceSchema, diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index ace134566e..395ec5986f 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -6,6 +6,8 @@ import { z } from "zod" export const historyItemSchema = z.object({ id: z.string(), + rootTaskId: z.string().optional(), + parentTaskId: z.string().optional(), number: z.number(), ts: z.number(), task: z.string(), diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 9eb201bd83..6b88ca5729 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -68,6 +68,9 @@ export type TaskProviderEvents = { [RooCodeEventName.TaskInteractive]: [taskId: string] [RooCodeEventName.TaskResumable]: [taskId: string] [RooCodeEventName.TaskIdle]: [taskId: string] + + [RooCodeEventName.TaskPaused]: [taskId: string] + [RooCodeEventName.TaskUnpaused]: [taskId: string] [RooCodeEventName.TaskSpawned]: [taskId: string] [RooCodeEventName.TaskUserMessage]: [taskId: string] @@ -106,9 +109,10 @@ export type TaskMetadata = z.infer export interface TaskLike { readonly taskId: string - readonly rootTask?: TaskLike + readonly rootTaskId?: string + readonly parentTaskId?: string + readonly childTaskId?: string readonly metadata: TaskMetadata - readonly taskStatus: TaskStatus readonly taskAsk: ClineMessage | undefined readonly queuedMessages: QueuedMessage[] @@ -135,8 +139,8 @@ export type TaskEvents = { [RooCodeEventName.TaskIdle]: [taskId: string] // Subtask Lifecycle - [RooCodeEventName.TaskPaused]: [] - [RooCodeEventName.TaskUnpaused]: [] + [RooCodeEventName.TaskPaused]: [taskId: string] + [RooCodeEventName.TaskUnpaused]: [taskId: string] [RooCodeEventName.TaskSpawned]: [taskId: string] // Task Execution diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 7b93b5c14a..f6b9575be3 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -13,23 +13,27 @@ import { t } from "../../i18n" const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 }) export type TaskMetadataOptions = { - messages: ClineMessage[] taskId: string + rootTaskId?: string + parentTaskId?: string taskNumber: number + messages: ClineMessage[] globalStoragePath: string workspace: string mode?: string } export async function taskMetadata({ - messages, - taskId, + taskId: id, + rootTaskId, + parentTaskId, taskNumber, + messages, globalStoragePath, workspace, mode, }: TaskMetadataOptions) { - const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) + const taskDir = await getTaskDirectoryPath(globalStoragePath, id) // Determine message availability upfront const hasMessages = messages && messages.length > 0 @@ -79,9 +83,11 @@ export async function taskMetadata({ } } - // Create historyItem once with pre-calculated values + // Create historyItem once with pre-calculated values. const historyItem: HistoryItem = { - id: taskId, + id, + rootTaskId, + parentTaskId, number: taskNumber, ts: timestamp, task: hasMessages diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1c4d9ec6c7..9259bae761 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -142,6 +142,10 @@ export interface TaskOptions extends CreateTaskOptions { export class Task extends EventEmitter implements TaskLike { readonly taskId: string + readonly rootTaskId?: string + readonly parentTaskId?: string + childTaskId?: string + readonly instanceId: string readonly metadata: TaskMetadata @@ -313,6 +317,9 @@ export class Task extends EventEmitter implements TaskLike { } this.taskId = historyItem ? historyItem.id : crypto.randomUUID() + this.rootTaskId = historyItem ? historyItem.rootTaskId : rootTask?.taskId + this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId + this.childTaskId = undefined this.metadata = { task: historyItem ? historyItem.task : task, @@ -350,7 +357,6 @@ export class Task extends EventEmitter implements TaskLike { this.enableCheckpoints = enableCheckpoints this.enableBridge = enableBridge - this.rootTask = rootTask this.parentTask = parentTask this.taskNumber = taskNumber @@ -653,12 +659,14 @@ export class Task extends EventEmitter implements TaskLike { }) const { historyItem, tokenUsage } = await taskMetadata({ - messages: this.clineMessages, taskId: this.taskId, + rootTaskId: this.rootTaskId, + parentTaskId: this.parentTaskId, taskNumber: this.taskNumber, + messages: this.clineMessages, globalStoragePath: this.globalStoragePath, workspace: this.cwd, - mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode + mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. }) this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage) @@ -1129,7 +1137,8 @@ export class Task extends EventEmitter implements TaskLike { return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) } - // Start / Abort / Resume + // Lifecycle + // Start / Resume / Abort / Dispose private async startTask(task?: string, images?: string[]): Promise { if (this.enableBridge) { @@ -1172,33 +1181,6 @@ export class Task extends EventEmitter implements TaskLike { ]) } - public async resumePausedTask(lastMessage: string) { - this.isPaused = false - this.emit(RooCodeEventName.TaskUnpaused) - - // Fake an answer from the subtask that it has completed running and - // this is the result of what it has done add the message to the chat - // history and to the webview ui. - try { - await this.say("subtask_result", lastMessage) - - await this.addToApiConversationHistory({ - role: "user", - content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }], - }) - - // Set skipPrevResponseIdOnce to ensure the next API call sends the full conversation - // including the subtask result, not just from before the subtask was created - this.skipPrevResponseIdOnce = true - } catch (error) { - this.providerRef - .deref() - ?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`) - - throw error - } - } - private async resumeTaskFromHistory() { if (this.enableBridge) { try { @@ -1212,19 +1194,21 @@ export class Task extends EventEmitter implements TaskLike { const modifiedClineMessages = await this.getSavedClineMessages() - // Check for any stored GPT-5 response IDs in the message history + // Check for any stored GPT-5 response IDs in the message history. const gpt5Messages = modifiedClineMessages.filter( (m): m is ClineMessage & ClineMessageWithMetadata => m.type === "say" && m.say === "text" && !!(m as ClineMessageWithMetadata).metadata?.gpt5?.previous_response_id, ) + if (gpt5Messages.length > 0) { const lastGpt5Message = gpt5Messages[gpt5Messages.length - 1] - // The lastGpt5Message contains the previous_response_id that can be used for continuity + // The lastGpt5Message contains the previous_response_id that can be + // used for continuity. } - // Remove any resume messages that may have been added before + // Remove any resume messages that may have been added before. const lastRelevantMessageIndex = findLastIndex( modifiedClineMessages, (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), @@ -1457,6 +1441,32 @@ export class Task extends EventEmitter implements TaskLike { await this.initiateTaskLoop(newUserContent) } + public async abortTask(isAbandoned = false) { + // Aborting task + + // Will stop any autonomously running promises. + if (isAbandoned) { + this.abandoned = true + } + + this.abort = true + this.emit(RooCodeEventName.TaskAborted) + + try { + this.dispose() // Call the centralized dispose method + } catch (error) { + console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) + // Don't rethrow - we want abort to always succeed + } + // Save the countdown message in the automatic retry or other content. + try { + // Save the countdown message in the automatic retry or other content. + await this.saveClineMessages() + } catch (error) { + console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) + } + } + public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) @@ -1541,37 +1551,36 @@ export class Task extends EventEmitter implements TaskLike { } } - public async abortTask(isAbandoned = false) { - // Aborting task + // Subtasks + // Spawn / Wait / Complete - // Will stop any autonomously running promises. - if (isAbandoned) { - this.abandoned = true + public async startSubtask(message: string, initialTodos: TodoItem[], mode: string) { + const provider = this.providerRef.deref() + + if (!provider) { + throw new Error("Provider not available") } - this.abort = true - this.emit(RooCodeEventName.TaskAborted) + const newTask = await provider.createTask(message, undefined, this, { initialTodos }) - try { - this.dispose() // Call the centralized dispose method - } catch (error) { - console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) - // Don't rethrow - we want abort to always succeed - } - // Save the countdown message in the automatic retry or other content. - try { - // Save the countdown message in the automatic retry or other content. - await this.saveClineMessages() - } catch (error) { - console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) + if (newTask) { + this.isPaused = true // Pause parent. + this.childTaskId = newTask.taskId + + await provider.handleModeSwitch(mode) // Set child's mode. + await delay(500) // Allow mode change to take effect. + + this.emit(RooCodeEventName.TaskPaused, this.taskId) + this.emit(RooCodeEventName.TaskSpawned, newTask.taskId) } + + return newTask } // Used when a sub-task is launched and the parent task is waiting for it to // finish. - // TBD: The 1s should be added to the settings, also should add a timeout to - // prevent infinite waiting. - public async waitForResume() { + // TBD: Add a timeout to prevent infinite waiting. + public async waitForSubtask() { await new Promise((resolve) => { this.pauseInterval = setInterval(() => { if (!this.isPaused) { @@ -1583,6 +1592,35 @@ export class Task extends EventEmitter implements TaskLike { }) } + public async completeSubtask(lastMessage: string) { + this.isPaused = false + this.childTaskId = undefined + + this.emit(RooCodeEventName.TaskUnpaused, this.taskId) + + // Fake an answer from the subtask that it has completed running and + // this is the result of what it has done add the message to the chat + // history and to the webview ui. + try { + await this.say("subtask_result", lastMessage) + + await this.addToApiConversationHistory({ + role: "user", + content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }], + }) + + // Set skipPrevResponseIdOnce to ensure the next API call sends the full conversation + // including the subtask result, not just from before the subtask was created + this.skipPrevResponseIdOnce = true + } catch (error) { + this.providerRef + .deref() + ?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`) + + throw error + } + } + // Task Loop private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise { @@ -1669,7 +1707,7 @@ export class Task extends EventEmitter implements TaskLike { if (this.isPaused && provider) { provider.log(`[subtasks] paused ${this.taskId}.${this.instanceId}`) - await this.waitForResume() + await this.waitForSubtask() provider.log(`[subtasks] resumed ${this.taskId}.${this.instanceId}`) const currentMode = (await provider.getState())?.mode ?? defaultModeSlug diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 3559188e98..a95efcd94f 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -68,12 +68,13 @@ const mockAskApproval = vi.fn() const mockHandleError = vi.fn() const mockPushToolResult = vi.fn() const mockRemoveClosingTag = vi.fn((_name: string, value: string | undefined) => value ?? "") -const mockCreateTask = vi - .fn<(text?: string, images?: string[], parentTask?: any, options?: any) => Promise>() - .mockResolvedValue({ taskId: "mock-subtask-id" }) const mockEmit = vi.fn() const mockRecordToolError = vi.fn() const mockSayAndCreateMissingParamError = vi.fn() +const mockStartSubtask = vi + .fn<(message: string, todoItems: any[], mode: string) => Promise>() + .mockResolvedValue({ taskId: "mock-subtask-id" }) +const mockCheckpointSave = vi.fn() // Mock the Cline instance and its methods/properties const mockCline = { @@ -85,11 +86,13 @@ const mockCline = { isPaused: false, pausedModeSlug: "ask", taskId: "mock-parent-task-id", + enableCheckpoints: false, + checkpointSave: mockCheckpointSave, + startSubtask: mockStartSubtask, providerRef: { deref: vi.fn(() => ({ getState: vi.fn(() => ({ customModes: [], mode: "ask" })), handleModeSwitch: vi.fn(), - createTask: mockCreateTask, })), }, } @@ -144,23 +147,17 @@ describe("newTaskTool", () => { // Verify askApproval was called expect(mockAskApproval).toHaveBeenCalled() - // Verify the message passed to createTask reflects the code's behavior in unit tests - expect(mockCreateTask).toHaveBeenCalledWith( + // Verify the message passed to startSubtask reflects the code's behavior in unit tests + expect(mockStartSubtask).toHaveBeenCalledWith( "Review this: \\@file1.txt and also \\\\\\@file2.txt", // Unit Test Expectation: \\@ -> \@, \\\\@ -> \\\\@ - undefined, - mockCline, - expect.objectContaining({ - initialTodos: expect.arrayContaining([ - expect.objectContaining({ content: "First task" }), - expect.objectContaining({ content: "Second task" }), - ]), - }), + expect.arrayContaining([ + expect.objectContaining({ content: "First task" }), + expect.objectContaining({ content: "Second task" }), + ]), + "code", ) // Verify side effects - expect(mockCline.emit).toHaveBeenCalledWith("taskSpawned", "mock-subtask-id") - expect(mockCline.isPaused).toBe(true) - expect(mockCline.emit).toHaveBeenCalledWith("taskPaused") expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) }) @@ -185,13 +182,10 @@ describe("newTaskTool", () => { mockRemoveClosingTag, ) - expect(mockCreateTask).toHaveBeenCalledWith( + expect(mockStartSubtask).toHaveBeenCalledWith( "This is already unescaped: \\@file1.txt", // Expected: \@ remains \@ - undefined, - mockCline, - expect.objectContaining({ - initialTodos: expect.any(Array), - }), + expect.any(Array), + "code", ) }) @@ -216,13 +210,10 @@ describe("newTaskTool", () => { mockRemoveClosingTag, ) - expect(mockCreateTask).toHaveBeenCalledWith( + expect(mockStartSubtask).toHaveBeenCalledWith( "A normal mention @file1.txt", // Expected: @ remains @ - undefined, - mockCline, - expect.objectContaining({ - initialTodos: expect.any(Array), - }), + expect.any(Array), + "code", ) }) @@ -247,13 +238,10 @@ describe("newTaskTool", () => { mockRemoveClosingTag, ) - expect(mockCreateTask).toHaveBeenCalledWith( + expect(mockStartSubtask).toHaveBeenCalledWith( "Mix: @file0.txt, \\@file1.txt, \\@file2.txt, \\\\\\@file3.txt", // Unit Test Expectation: @->@, \@->\@, \\@->\@, \\\\@->\\\\@ - undefined, - mockCline, - expect.objectContaining({ - initialTodos: expect.any(Array), - }), + expect.any(Array), + "code", ) }) @@ -284,14 +272,7 @@ describe("newTaskTool", () => { expect(mockCline.recordToolError).not.toHaveBeenCalledWith("new_task") // Should create task with empty todos array - expect(mockCreateTask).toHaveBeenCalledWith( - "Test message", - undefined, - mockCline, - expect.objectContaining({ - initialTodos: [], - }), - ) + expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) @@ -319,16 +300,13 @@ describe("newTaskTool", () => { ) // Should parse and include todos when provided - expect(mockCreateTask).toHaveBeenCalledWith( + expect(mockStartSubtask).toHaveBeenCalledWith( "Test message with todos", - undefined, - mockCline, - expect.objectContaining({ - initialTodos: expect.arrayContaining([ - expect.objectContaining({ content: "First task" }), - expect.objectContaining({ content: "Second task" }), - ]), - }), + expect.arrayContaining([ + expect.objectContaining({ content: "First task" }), + expect.objectContaining({ content: "Second task" }), + ]), + "code", ) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) @@ -407,17 +385,14 @@ describe("newTaskTool", () => { mockRemoveClosingTag, ) - expect(mockCreateTask).toHaveBeenCalledWith( + expect(mockStartSubtask).toHaveBeenCalledWith( "Test message", - undefined, - mockCline, - expect.objectContaining({ - initialTodos: expect.arrayContaining([ - expect.objectContaining({ content: "Pending task", status: "pending" }), - expect.objectContaining({ content: "Completed task", status: "completed" }), - expect.objectContaining({ content: "In progress task", status: "in_progress" }), - ]), - }), + expect.arrayContaining([ + expect.objectContaining({ content: "Pending task", status: "pending" }), + expect.objectContaining({ content: "Completed task", status: "completed" }), + expect.objectContaining({ content: "In progress task", status: "in_progress" }), + ]), + "code", ) }) @@ -455,14 +430,7 @@ describe("newTaskTool", () => { expect(mockCline.recordToolError).not.toHaveBeenCalledWith("new_task") // Should create task with empty todos array - expect(mockCreateTask).toHaveBeenCalledWith( - "Test message", - undefined, - mockCline, - expect.objectContaining({ - initialTodos: [], - }), - ) + expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) @@ -501,7 +469,7 @@ describe("newTaskTool", () => { expect(mockCline.recordToolError).toHaveBeenCalledWith("new_task") // Should NOT create task - expect(mockCreateTask).not.toHaveBeenCalled() + expect(mockStartSubtask).not.toHaveBeenCalled() expect(mockPushToolResult).not.toHaveBeenCalledWith( expect.stringContaining("Successfully created new task"), ) @@ -539,16 +507,13 @@ describe("newTaskTool", () => { expect(mockCline.consecutiveMistakeCount).toBe(0) // Should create task with parsed todos - expect(mockCreateTask).toHaveBeenCalledWith( + expect(mockStartSubtask).toHaveBeenCalledWith( "Test message", - undefined, - mockCline, - expect.objectContaining({ - initialTodos: expect.arrayContaining([ - expect.objectContaining({ content: "First task" }), - expect.objectContaining({ content: "Second task" }), - ]), - }), + expect.arrayContaining([ + expect.objectContaining({ content: "First task" }), + expect.objectContaining({ content: "Second task" }), + ]), + "code", ) // Should complete successfully @@ -587,14 +552,7 @@ describe("newTaskTool", () => { expect(mockCline.consecutiveMistakeCount).toBe(0) // Should create task with empty todos array - expect(mockCreateTask).toHaveBeenCalledWith( - "Test message", - undefined, - mockCline, - expect.objectContaining({ - initialTodos: [], - }), - ) + expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts index 6b650cb94e..aeb0c8393b 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/newTaskTool.ts @@ -1,7 +1,6 @@ -import delay from "delay" import * as vscode from "vscode" -import { RooCodeEventName, TodoItem } from "@roo-code/types" +import { TodoItem } from "@roo-code/types" import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { Task } from "../task/Task" @@ -12,7 +11,7 @@ import { parseMarkdownChecklist } from "./updateTodoListTool" import { Package } from "../../shared/package" export async function newTaskTool( - cline: Task, + task: Task, block: ToolUse, askApproval: AskApproval, handleError: HandleError, @@ -32,30 +31,32 @@ export async function newTaskTool( todos: removeClosingTag("todos", todos), }) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) return } else { - // Validate required parameters + // Validate required parameters. if (!mode) { - cline.consecutiveMistakeCount++ - cline.recordToolError("new_task") - pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "mode")) + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + pushToolResult(await task.sayAndCreateMissingParamError("new_task", "mode")) return } if (!message) { - cline.consecutiveMistakeCount++ - cline.recordToolError("new_task") - pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "message")) + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + pushToolResult(await task.sayAndCreateMissingParamError("new_task", "message")) return } - // Get the VSCode setting for requiring todos - const provider = cline.providerRef.deref() + // Get the VSCode setting for requiring todos. + const provider = task.providerRef.deref() + if (!provider) { pushToolResult(formatResponse.toolError("Provider reference lost")) return } + const state = await provider.getState() // Use Package.name (dynamic at build time) as the VSCode configuration namespace. @@ -64,12 +65,12 @@ export async function newTaskTool( .getConfiguration(Package.name) .get("newTaskRequireTodos", false) - // Check if todos are required based on VSCode setting - // Note: undefined means not provided, empty string is valid + // Check if todos are required based on VSCode setting. + // Note: `undefined` means not provided, empty string is valid. if (requireTodos && todos === undefined) { - cline.consecutiveMistakeCount++ - cline.recordToolError("new_task") - pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "todos")) + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + pushToolResult(await task.sayAndCreateMissingParamError("new_task", "todos")) return } @@ -79,14 +80,15 @@ export async function newTaskTool( try { todoItems = parseMarkdownChecklist(todos) } catch (error) { - cline.consecutiveMistakeCount++ - cline.recordToolError("new_task") + task.consecutiveMistakeCount++ + task.recordToolError("new_task") pushToolResult(formatResponse.toolError("Invalid todos format: must be a markdown checklist")) return } } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 + // Un-escape one level of backslashes before '@' for hierarchical subtasks // Un-escape one level: \\@ -> \@ (removes one backslash for hierarchical subtasks) const unescapedMessage = message.replace(/\\\\@/g, "\\@") @@ -112,41 +114,26 @@ export async function newTaskTool( return } - // Provider is guaranteed to be defined here due to earlier check + // Provider is guaranteed to be defined here due to earlier check. - if (cline.enableCheckpoints) { - cline.checkpointSave(true) + if (task.enableCheckpoints) { + task.checkpointSave(true) } // Preserve the current mode so we can resume with it later. - cline.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug + task.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug - // Create new task instance first (this preserves parent's current mode in its history) - const newCline = await provider.createTask(unescapedMessage, undefined, cline, { - initialTodos: todoItems, - }) - if (!newCline) { + const newTask = await task.startSubtask(unescapedMessage, todoItems, mode) + + if (!newTask) { pushToolResult(t("tools:newTask.errors.policy_restriction")) return } - // Now switch the newly created task to the desired mode - await provider.handleModeSwitch(mode) - - // Delay to allow mode change to take effect - await delay(500) - - cline.emit(RooCodeEventName.TaskSpawned, newCline.taskId) - pushToolResult( `Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage} and ${todoItems.length} todo items`, ) - // Set the isPaused flag to true so the parent - // task can wait for the sub-task to finish. - cline.isPaused = true - cline.emit(RooCodeEventName.TaskPaused) - return } } catch (error) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5a6a6ce2d0..15d5fa51a3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -185,6 +185,9 @@ export class ClineProvider const onTaskResumable = (taskId: string) => this.emit(RooCodeEventName.TaskResumable, taskId) const onTaskIdle = (taskId: string) => this.emit(RooCodeEventName.TaskIdle, taskId) const onTaskUserMessage = (taskId: string) => this.emit(RooCodeEventName.TaskUserMessage, taskId) + const onTaskPaused = (taskId: string) => this.emit(RooCodeEventName.TaskPaused, taskId) + const onTaskUnpaused = (taskId: string) => this.emit(RooCodeEventName.TaskUnpaused, taskId) + const onTaskSpawned = (taskId: string) => this.emit(RooCodeEventName.TaskSpawned, taskId) // Attach the listeners. instance.on(RooCodeEventName.TaskStarted, onTaskStarted) @@ -197,6 +200,9 @@ export class ClineProvider instance.on(RooCodeEventName.TaskResumable, onTaskResumable) instance.on(RooCodeEventName.TaskIdle, onTaskIdle) instance.on(RooCodeEventName.TaskUserMessage, onTaskUserMessage) + instance.on(RooCodeEventName.TaskPaused, onTaskPaused) + instance.on(RooCodeEventName.TaskUnpaused, onTaskUnpaused) + instance.on(RooCodeEventName.TaskSpawned, onTaskSpawned) // Store the cleanup functions for later removal. this.taskEventListeners.set(instance, [ @@ -210,6 +216,9 @@ export class ClineProvider () => instance.off(RooCodeEventName.TaskResumable, onTaskResumable), () => instance.off(RooCodeEventName.TaskIdle, onTaskIdle), () => instance.off(RooCodeEventName.TaskUserMessage, onTaskUserMessage), + () => instance.off(RooCodeEventName.TaskPaused, onTaskPaused), + () => instance.off(RooCodeEventName.TaskUnpaused, onTaskUnpaused), + () => instance.off(RooCodeEventName.TaskSpawned, onTaskSpawned), ]) } @@ -424,7 +433,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()?.resumePausedTask(lastMessage) + await this.getCurrentTask()?.completeSubtask(lastMessage) } /* @@ -986,16 +995,16 @@ export class ClineProvider * @param newMode The mode to switch to */ public async handleModeSwitch(newMode: Mode) { - const cline = this.getCurrentTask() + const task = this.getCurrentTask() - if (cline) { - TelemetryService.instance.captureModeSwitch(cline.taskId, newMode) - cline.emit(RooCodeEventName.TaskModeSwitched, cline.taskId, newMode) + if (task) { + TelemetryService.instance.captureModeSwitch(task.taskId, newMode) + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) try { // Update the task history with the new mode first. const history = this.getGlobalState("taskHistory") ?? [] - const taskHistoryItem = history.find((item) => item.id === cline.taskId) + const taskHistoryItem = history.find((item) => item.id === task.taskId) if (taskHistoryItem) { taskHistoryItem.mode = newMode @@ -1003,11 +1012,11 @@ export class ClineProvider } // Only update the task's mode after successful persistence. - ;(cline as any)._taskMode = newMode + ;(task as any)._taskMode = newMode } catch (error) { // If persistence fails, log the error but don't update the in-memory state. this.log( - `Failed to persist mode switch for task ${cline.taskId}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to persist mode switch for task ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`, ) // Optionally, we could emit an event to notify about the failure. @@ -2329,20 +2338,21 @@ export class ClineProvider } public async cancelTask(): Promise { - const cline = this.getCurrentTask() + const task = this.getCurrentTask() - if (!cline) { + if (!task) { return } - console.log(`[cancelTask] cancelling task ${cline.taskId}.${cline.instanceId}`) + console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) + + const { historyItem } = await this.getTaskWithId(task.taskId) - const { historyItem } = await this.getTaskWithId(cline.taskId) // Preserve parent and root task information for history item. - const rootTask = cline.rootTask - const parentTask = cline.parentTask + const rootTask = task.rootTask + const parentTask = task.parentTask - cline.abortTask() + task.abortTask() await pWaitFor( () => diff --git a/src/extension/api.ts b/src/extension/api.ts index 8af6d99052..86f2f47aa6 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -211,13 +211,10 @@ export class API extends EventEmitter implements RooCodeAPI { }) task.on(RooCodeEventName.TaskCompleted, async (_, tokenUsage, toolUsage) => { - let isSubtask = false + this.emit(RooCodeEventName.TaskCompleted, task.taskId, tokenUsage, toolUsage, { + isSubtask: !!task.parentTaskId, + }) - if (typeof task.rootTask !== "undefined") { - isSubtask = true - } - - this.emit(RooCodeEventName.TaskCompleted, task.taskId, tokenUsage, toolUsage, { isSubtask: isSubtask }) this.taskMap.delete(task.taskId) await this.fileLog(