mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Publish subtask events (#7626)
This commit is contained in:
parent
bcb71dbebf
commit
43ff486d42
12 changed files with 253 additions and 222 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue