Publish subtask events (#7626)

This commit is contained in:
Chris Estreich 2025-09-03 04:05:54 -07:00 committed by GitHub
parent bcb71dbebf
commit 43ff486d42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 253 additions and 222 deletions

View file

@ -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 },

View file

@ -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) => {

View file

@ -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",

View file

@ -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,

View file

@ -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(),

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"))

View file

@ -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) {

View file

@ -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(
() =>

View file

@ -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(