Chris Estreich 4 месяцев назад
Родитель
Сommit
43ff486d42

+ 5 - 0
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 },

+ 4 - 2
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) => {

+ 1 - 1
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",

+ 22 - 0
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,

+ 2 - 0
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(),

+ 8 - 4
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<typeof taskMetadataSchema>
 
 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

+ 12 - 6
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

+ 95 - 57
src/core/task/Task.ts

@@ -142,6 +142,10 @@ export interface TaskOptions extends CreateTaskOptions {
 
 export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
 		return formatResponse.toolError(formatResponse.missingToolParameterError(paramName))
 	}
 
-	// Start / Abort / Resume
+	// Lifecycle
+	// Start / Resume / Abort / Dispose
 
 	private async startTask(task?: string, images?: string[]): Promise<void> {
 		if (this.enableBridge) {
@@ -1172,33 +1181,6 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<void>((resolve) => {
 			this.pauseInterval = setInterval(() => {
 				if (!this.isPaused) {
@@ -1583,6 +1592,35 @@ export class Task extends EventEmitter<TaskEvents> 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<void> {
@@ -1669,7 +1707,7 @@ export class Task extends EventEmitter<TaskEvents> 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
 

+ 46 - 88
src/core/tools/__tests__/newTaskTool.spec.ts

@@ -68,12 +68,13 @@ const mockAskApproval = vi.fn<AskApproval>()
 const mockHandleError = vi.fn<HandleError>()
 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<MockClineInstance>>()
-	.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<MockClineInstance>>()
+	.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"))

+ 30 - 43
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<boolean>("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) {

+ 25 - 15
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<void> {
-		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(
 			() =>

+ 3 - 6
src/extension/api.ts

@@ -211,13 +211,10 @@ export class API extends EventEmitter<RooCodeEvents> 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(