diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 4aeb87168c2..e1c1ec93c61 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -64,7 +64,7 @@ export const registerCommands = (options: RegisterCommandOptions) => { const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions) => { return { "roo-cline.plusButtonClicked": async () => { - await provider.clearTask() + await provider.removeClineFromStack() await provider.postStateToWebview() await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) }, diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 773303c2462..0c8129811b3 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -94,6 +94,17 @@ export type ClineOptions = { export class Cline { readonly taskId: string + private taskNumber: number + // a flag that indicated if this Cline instance is a subtask (on finish return control to parent task) + private isSubTask: boolean = false + // a flag that indicated if this Cline instance is paused (waiting for provider to resume it after subtask completion) + private isPaused: boolean = false + // this is the parent task work mode when it launched the subtask to be used when it is restored (so the last used mode by parent task will also be restored) + private pausedModeSlug: string = defaultModeSlug + // if this is a subtask then this member holds a pointer to the parent task that launched it + private parentTask: Cline | undefined = undefined + // if this is a subtask then this member holds a pointer to the top parent task that launched it + private rootTask: Cline | undefined = undefined readonly apiConfiguration: ApiConfiguration api: ApiHandler private terminalManager: TerminalManager @@ -158,7 +169,7 @@ export class Cline { } this.taskId = historyItem ? historyItem.id : crypto.randomUUID() - + this.taskNumber = -1 this.apiConfiguration = apiConfiguration this.api = buildApiHandler(apiConfiguration) this.terminalManager = new TerminalManager() @@ -202,6 +213,46 @@ export class Cline { return [instance, promise] } + // a helper function to set the private member isSubTask to true + // and by that set this Cline instance to be a subtask (on finish return control to parent task) + setSubTask() { + this.isSubTask = true + } + + // sets the task number (sequencial number of this task from all the subtask ran from this main task stack) + setTaskNumber(taskNumber: number) { + this.taskNumber = taskNumber + } + + // gets the task number, the sequencial number of this task from all the subtask ran from this main task stack + getTaskNumber() { + return this.taskNumber + } + + // this method returns the cline instance that is the parent task that launched this subtask (assuming this cline is a subtask) + // if undefined is returned, then there is no parent task and this is not a subtask or connection has been severed + getParentTask(): Cline | undefined { + return this.parentTask + } + + // this method sets a cline instance that is the parent task that called this task (assuming this cline is a subtask) + // if undefined is set, then the connection is broken and the parent is no longer saved in the subtask member + setParentTask(parentToSet: Cline | undefined) { + this.parentTask = parentToSet + } + + // this method returns the cline instance that is the root task (top most parent) that eventually launched this subtask (assuming this cline is a subtask) + // if undefined is returned, then there is no root task and this is not a subtask or connection has been severed + getRootTask(): Cline | undefined { + return this.rootTask + } + + // this method sets a cline instance that is the root task (top most patrnt) that called this task (assuming this cline is a subtask) + // if undefined is set, then the connection is broken and the root is no longer saved in the subtask member + setRootTask(rootToSet: Cline | undefined) { + this.rootTask = rootToSet + } + // Add method to update diffStrategy async updateDiffStrategy(experimentalDiffStrategy?: boolean) { // If not provided, get from current state @@ -308,6 +359,7 @@ export class Cline { await this.providerRef.deref()?.updateTaskHistory({ id: this.taskId, + number: this.taskNumber, ts: lastRelevantMessage.ts, task: taskMessage.text ?? "", tokensIn: apiMetrics.totalTokensIn, @@ -332,7 +384,7 @@ export class Cline { ): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> { // If this Cline instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of Cline now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set Cline = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.) if (this.abort) { - throw new Error("Roo Code instance aborted") + throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#1)`) } let askTs: number if (partial !== undefined) { @@ -350,7 +402,7 @@ export class Cline { await this.providerRef .deref() ?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) - throw new Error("Current ask promise was ignored 1") + throw new Error("Current ask promise was ignored (#1)") } else { // this is a new partial message, so add it with partial state // this.askResponse = undefined @@ -360,7 +412,7 @@ export class Cline { this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial }) await this.providerRef.deref()?.postStateToWebview() - throw new Error("Current ask promise was ignored 2") + throw new Error("Current ask promise was ignored (#2)") } } else { // partial=false means its a complete version of a previously partial message @@ -434,7 +486,7 @@ export class Cline { checkpoint?: Record, ): Promise { if (this.abort) { - throw new Error("Roo Code instance aborted") + throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#2)`) } if (partial !== undefined) { @@ -522,6 +574,32 @@ export class Cline { ]) } + async resumePausedTask(lastMessage?: string) { + // release this Cline instance from paused state + this.isPaused = false + + // 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("text", `${lastMessage ?? "Please continue to the next task."}`) + + await this.addToApiConversationHistory({ + role: "user", + content: [ + { + type: "text", + text: `[new_task completed] Result: ${lastMessage ?? "Please continue to the next task."}`, + }, + ], + }) + } catch (error) { + this.providerRef + .deref() + ?.log(`Error failed to add reply from subtast into conversation of parent task, error: ${error}`) + throw error + } + } + private async resumeTaskFromHistory() { const modifiedClineMessages = await this.getSavedClineMessages() @@ -1105,7 +1183,7 @@ export class Cline { async presentAssistantMessage() { if (this.abort) { - throw new Error("Roo Code instance aborted") + throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#3)`) } if (this.presentAssistantMessageLocked) { @@ -2565,10 +2643,7 @@ export class Cline { } // Switch the mode using shared handler - const provider = this.providerRef.deref() - if (provider) { - await provider.handleModeSwitch(mode_slug) - } + await this.providerRef.deref()?.handleModeSwitch(mode_slug) pushToolResult( `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ targetMode.name @@ -2630,19 +2705,25 @@ export class Cline { break } + // before switching roo mode (currently a global settings), save the current mode so we can + // resume the parent task (this Cline instance) later with the same mode + const currentMode = + (await this.providerRef.deref()?.getState())?.mode ?? defaultModeSlug + this.pausedModeSlug = currentMode + // Switch mode first, then create new task instance - const provider = this.providerRef.deref() - if (provider) { - await provider.handleModeSwitch(mode) - await provider.initClineWithTask(message) - pushToolResult( - `Successfully created new task in ${targetMode.name} mode with message: ${message}`, - ) - } else { - pushToolResult( - formatResponse.toolError("Failed to create new task: provider not available"), - ) - } + await this.providerRef.deref()?.handleModeSwitch(mode) + // wait for mode to actually switch in UI and in State + await delay(500) // delay to allow mode change to take effect before next tool is executed + this.providerRef + .deref() + ?.log(`[subtasks] Task: ${this.taskNumber} creating new task in '${mode}' mode`) + await this.providerRef.deref()?.initClineWithSubTask(message) + pushToolResult( + `Successfully created new task in ${targetMode.name} mode with message: ${message}`, + ) + // set the isPaused flag to true so the parent task can wait for the sub-task to finish + this.isPaused = true break } } catch (error) { @@ -2698,6 +2779,15 @@ export class Cline { undefined, false, ) + + if (this.isSubTask) { + // tell the provider to remove the current subtask and resume the previous task in the stack (it might decide to run the command) + await this.providerRef + .deref() + ?.finishSubTask(`new_task finished successfully! ${lastMessage?.text}`) + break + } + await this.ask( "command", removeClosingTag("command", command), @@ -2729,6 +2819,13 @@ export class Cline { if (lastMessage && lastMessage.ask !== "command") { // havent sent a command message yet so first send completion_result then command await this.say("completion_result", result, undefined, false) + if (this.isSubTask) { + // tell the provider to remove the current subtask and resume the previous task in the stack + await this.providerRef + .deref() + ?.finishSubTask(`Task complete: ${lastMessage?.text}`) + break + } } // complete command message @@ -2746,6 +2843,13 @@ export class Cline { commandResult = execCommandResult } else { await this.say("completion_result", result, undefined, false) + if (this.isSubTask) { + // tell the provider to remove the current subtask and resume the previous task in the stack + await this.providerRef + .deref() + ?.finishSubTask(`Task complete: ${lastMessage?.text}`) + break + } } // we already sent completion_result says, an empty string asks relinquishes control over button and field @@ -2821,12 +2925,26 @@ export class Cline { } } + // this function checks if this Cline instance is set to pause state and wait for being resumed, + // this is used when a sub-task is launched and the parent task is waiting for it to finish + async waitForResume() { + // wait until isPaused is false + await new Promise((resolve) => { + const interval = setInterval(() => { + if (!this.isPaused) { + clearInterval(interval) + resolve() + } + }, 1000) // TBD: the 1 sec should be added to the settings, also should add a timeout to prevent infinit wait + }) + } + async recursivelyMakeClineRequests( userContent: UserContent, includeFileDetails: boolean = false, ): Promise { if (this.abort) { - throw new Error("Roo Code instance aborted") + throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#4)`) } if (this.consecutiveMistakeCount >= 3) { @@ -2853,6 +2971,27 @@ export class Cline { // get previous api req's index to check token usage and determine if we need to truncate conversation history const previousApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started") + // in this Cline request loop, we need to check if this cline (Task) instance has been asked to wait + // for a sub-task (it has launched) to finish before continuing + if (this.isPaused) { + this.providerRef.deref()?.log(`[subtasks] Task: ${this.taskNumber} has paused`) + await this.waitForResume() + this.providerRef.deref()?.log(`[subtasks] Task: ${this.taskNumber} has resumed`) + // waiting for resume is done, resume the task mode + const currentMode = (await this.providerRef.deref()?.getState())?.mode ?? defaultModeSlug + if (currentMode !== this.pausedModeSlug) { + // the mode has changed, we need to switch back to the paused mode + await this.providerRef.deref()?.handleModeSwitch(this.pausedModeSlug) + // wait for mode to actually switch in UI and in State + await delay(500) // delay to allow mode change to take effect before next tool is executed + this.providerRef + .deref() + ?.log( + `[subtasks] Task: ${this.taskNumber} has switched back to mode: '${this.pausedModeSlug}' from mode: '${currentMode}'`, + ) + } + } + // getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds // for the best UX we show a placeholder api_req_started message with a loading spinner as this happens await this.say( @@ -3042,7 +3181,7 @@ export class Cline { // need to call here in case the stream was aborted if (this.abort || this.abandoned) { - throw new Error("Roo Code instance aborted") + throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#5)`) } this.didCompleteReadingStream = true diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 9910896ebb9..c7fa5fb527d 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -237,6 +237,7 @@ describe("Cline", () => { return [ { id: "123", + number: 0, ts: Date.now(), task: "historical task", tokensIn: 100, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 017371e5587..a078503b355 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -64,13 +64,14 @@ export class ClineProvider implements vscode.WebviewViewProvider { private disposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel private isViewLaunched = false - private cline?: Cline + private clineStack: Cline[] = [] private workspaceTracker?: WorkspaceTracker protected mcpHub?: McpHub // Change from private to protected private latestAnnouncementId = "feb-27-2025-automatic-checkpoints" // update to some unique identifier when we add a new announcement private contextProxy: ContextProxy configManager: ConfigManager customModesManager: CustomModesManager + private lastTaskNumber = -1 constructor( readonly context: vscode.ExtensionContext, @@ -95,6 +96,146 @@ export class ClineProvider implements vscode.WebviewViewProvider { }) } + // Adds a new Cline instance to clineStack, marking the start of a new task. + // The instance is pushed to the top of the stack (LIFO order). + // When the task is completed, the top instance is removed, reactivating the previous task. + async addClineToStack(cline: Cline) { + try { + if (!cline) { + throw new Error("Error invalid Cline instance provided.") + } + + // Ensure lastTaskNumber is a valid number + if (typeof this.lastTaskNumber !== "number") { + this.lastTaskNumber = -1 + } + + const taskNumber = cline.getTaskNumber() + + if (taskNumber === -1) { + this.lastTaskNumber += 1 + cline.setTaskNumber(this.lastTaskNumber) + } else if (taskNumber > this.lastTaskNumber) { + this.lastTaskNumber = taskNumber + } + + // set this cline task parent cline (the task that launched it), and the root cline (the top most task that eventually launched it) + if (this.clineStack.length >= 1) { + cline.setParentTask(this.getCurrentCline()) + cline.setRootTask(this.clineStack[0]) + } + + // add this cline instance into the stack that represents the order of all the called tasks + this.clineStack.push(cline) + + // Ensure getState() resolves correctly + const state = await this.getState() + if (!state || typeof state.mode !== "string") { + throw new Error("Error failed to retrieve current mode from state.") + } + + this.log(`[subtasks] Task: ${cline.getTaskNumber()} started at '${state.mode}' mode`) + } catch (error) { + this.log(`Error in addClineToStack: ${error.message}`) + throw error + } + } + + // Removes and destroys the top Cline instance (the current finished task), activating the previous one (resuming the parent task). + async removeClineFromStack() { + try { + if (!Array.isArray(this.clineStack)) { + throw new Error("Error clineStack is not an array.") + } + + if (this.clineStack.length === 0) { + this.log("[subtasks] No active tasks to remove.") + } else { + // pop the top Cline instance from the stack + var clineToBeRemoved = this.clineStack.pop() + if (clineToBeRemoved) { + const removedTaskNumber = clineToBeRemoved.getTaskNumber() + + try { + // abort the running task and set isAbandoned to true so all running promises will exit as well + await clineToBeRemoved.abortTask(true) + } catch (abortError) { + this.log(`Error failed aborting task ${removedTaskNumber}: ${abortError.message}`) + } + + // make sure no reference kept, once promises end it will be garbage collected + clineToBeRemoved = undefined + this.log(`[subtasks] Task: ${removedTaskNumber} stopped`) + } + + // if the stack is empty, reset the last task number + if (this.clineStack.length === 0) { + this.lastTaskNumber = -1 + } + } + } catch (error) { + this.log(`Error in removeClineFromStack: ${error.message}`) + throw error + } + } + + // remove the cline object with the received clineId, and all the cline objects bove it in the stack + // for each cline object removed, pop it from the stack, abort the task and set it to undefined + async removeClineWithIdFromStack(clineId: string) { + try { + if (typeof clineId !== "string" || !clineId.trim()) { + throw new Error("Error Invalid clineId provided.") + } + + const index = this.clineStack.findIndex((c) => c.taskId === clineId) + + if (index === -1) { + this.log(`[subtasks] No task found with ID: ${clineId}`) + return + } + + for (let i = this.clineStack.length - 1; i >= index; i--) { + try { + await this.removeClineFromStack() + } catch (removalError) { + this.log(`Error removing task at stack index ${i}: ${removalError.message}`) + } + } + } catch (error) { + this.log(`Error in removeClineWithIdFromStack: ${error.message}`) + throw error + } + } + + // returns the current cline object in the stack (the top one) + // if the stack is empty, returns undefined + getCurrentCline(): Cline | undefined { + if (this.clineStack.length === 0) { + return undefined + } + return this.clineStack[this.clineStack.length - 1] + } + + // returns the current clineStack length (how many cline objects are in the stack) + getClineStackSize(): number { + return this.clineStack.length + } + + // remove the current task/cline instance (at the top of the stack), ao this task is finished + // and resume the previous task/cline instance (if it exists) + // this is used when a sub task is finished and the parent task needs to be resumed + async finishSubTask(lastMessage?: string) { + try { + // remove the last cline instance from the stack (this is the finished sub task) + await this.removeClineFromStack() + // resume the last cline instance in the stack (if it exists - this is the 'parnt' calling task) + this.getCurrentCline()?.resumePausedTask(lastMessage) + } catch (error) { + this.log(`Error in finishSubTask: ${error.message}`) + throw error + } + } + /* VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc. - https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/ @@ -102,7 +243,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { */ async dispose() { this.outputChannel.appendLine("Disposing ClineProvider...") - await this.clearTask() + await this.removeClineFromStack() this.outputChannel.appendLine("Cleared task") if (this.view && "dispose" in this.view) { this.view.dispose() @@ -155,7 +296,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { return false } - if (visibleProvider.cline) { + // check if there is a cline instance in the stack (if this provider has an active task) + if (visibleProvider.getCurrentCline()) { return true } @@ -186,7 +328,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { return } - if (visibleProvider.cline && command.endsWith("InCurrentTask")) { + if (visibleProvider.getCurrentCline() && command.endsWith("InCurrentTask")) { await visibleProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", @@ -222,7 +364,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { return } - if (visibleProvider.cline && command.endsWith("InCurrentTask")) { + if (visibleProvider.getCurrentCline() && command.endsWith("InCurrentTask")) { await visibleProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", @@ -320,14 +462,21 @@ export class ClineProvider implements vscode.WebviewViewProvider { ) // if the extension is starting a new session, clear previous task state - this.clearTask() + await this.removeClineFromStack() this.outputChannel.appendLine("Webview view resolved") } - public async initClineWithTask(task?: string, images?: string[]) { - await this.clearTask() + // a wrapper that inits a new Cline instance (Task) ans setting it as a sub task of the current task + public async initClineWithSubTask(task?: string, images?: string[]) { + await this.initClineWithTask(task, images) + this.getCurrentCline()?.setSubTask() + } + // when initializing a new task, (not from history but from a tool command new_task) there is no need to remove the previouse task + // since the new task is a sub task of the previous one, and when it finishes it is removed from the stack and the caller is resumed + // in this way we can have a chain of tasks, each one being a sub task of the previous one until the main task is finished + public async initClineWithTask(task?: string, images?: string[]) { const { apiConfiguration, customModePrompts, @@ -343,7 +492,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { const modePrompt = customModePrompts?.[mode] as PromptComponent const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n") - this.cline = new Cline({ + const newCline = new Cline({ provider: this, apiConfiguration, customInstructions: effectiveInstructions, @@ -355,10 +504,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { images, experiments, }) + await this.addClineToStack(newCline) } public async initClineWithHistoryItem(historyItem: HistoryItem) { - await this.clearTask() + await this.removeClineFromStack() const { apiConfiguration, @@ -379,7 +529,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { // task data on disk; the current setting could be different than // the setting at the time the task was created. - this.cline = new Cline({ + const newCline = new Cline({ provider: this, apiConfiguration, customInstructions: effectiveInstructions, @@ -390,6 +540,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { historyItem, experiments, }) + // get this cline task number id from the history item and set it to newCline + newCline.setTaskNumber(historyItem.number) + await this.addClineToStack(newCline) } public async postMessageToWebview(message: ExtensionMessage) { @@ -796,11 +949,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.postStateToWebview() break case "askResponse": - this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images) + this.getCurrentCline()?.handleWebviewAskResponse( + message.askResponse!, + message.text, + message.images, + ) break case "clearTask": - // newTask will start a new task with a given task text, while clear task resets the current session and allows for a new task to be started - await this.clearTask() + // clear task resets the current session and allows for a new task to be started, if this session is a subtask - it allows the parent task to be resumed + await this.finishSubTask( + `new_task finished with an error!, it was stopped and canceled by the user.`, + ) await this.postStateToWebview() break case "didShowAnnouncement": @@ -812,7 +971,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.postMessageToWebview({ type: "selectedImages", images }) break case "exportCurrentTask": - const currentTaskId = this.cline?.taskId + const currentTaskId = this.getCurrentCline()?.taskId if (currentTaskId) { this.exportTaskWithId(currentTaskId) } @@ -919,7 +1078,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { const result = checkoutDiffPayloadSchema.safeParse(message.payload) if (result.success) { - await this.cline?.checkpointDiff(result.data) + await this.getCurrentCline()?.checkpointDiff(result.data) } break @@ -930,13 +1089,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.cancelTask() try { - await pWaitFor(() => this.cline?.isInitialized === true, { timeout: 3_000 }) + await pWaitFor(() => this.getCurrentCline()?.isInitialized === true, { timeout: 3_000 }) } catch (error) { vscode.window.showErrorMessage("Timed out when attempting to restore checkpoint.") } try { - await this.cline?.checkpointRestore(result.data) + await this.getCurrentCline()?.checkpointRestore(result.data) } catch (error) { vscode.window.showErrorMessage("Failed to restore checkpoint.") } @@ -1178,42 +1337,43 @@ export class ClineProvider implements vscode.WebviewViewProvider { ) if ( (answer === "Just this message" || answer === "This and all subsequent messages") && - this.cline && + this.getCurrentCline() && typeof message.value === "number" && message.value ) { const timeCutoff = message.value - 1000 // 1 second buffer before the message to delete - const messageIndex = this.cline.clineMessages.findIndex( - (msg) => msg.ts && msg.ts >= timeCutoff, - ) - const apiConversationHistoryIndex = this.cline.apiConversationHistory.findIndex( + const messageIndex = this.getCurrentCline()!.clineMessages.findIndex( (msg) => msg.ts && msg.ts >= timeCutoff, ) + const apiConversationHistoryIndex = + this.getCurrentCline()?.apiConversationHistory.findIndex( + (msg) => msg.ts && msg.ts >= timeCutoff, + ) if (messageIndex !== -1) { - const { historyItem } = await this.getTaskWithId(this.cline.taskId) + const { historyItem } = await this.getTaskWithId(this.getCurrentCline()!.taskId) if (answer === "Just this message") { // Find the next user message first - const nextUserMessage = this.cline.clineMessages - .slice(messageIndex + 1) + const nextUserMessage = this.getCurrentCline()! + .clineMessages.slice(messageIndex + 1) .find((msg) => msg.type === "say" && msg.say === "user_feedback") // Handle UI messages if (nextUserMessage) { // Find absolute index of next user message - const nextUserMessageIndex = this.cline.clineMessages.findIndex( + const nextUserMessageIndex = this.getCurrentCline()!.clineMessages.findIndex( (msg) => msg === nextUserMessage, ) // Keep messages before current message and after next user message - await this.cline.overwriteClineMessages([ - ...this.cline.clineMessages.slice(0, messageIndex), - ...this.cline.clineMessages.slice(nextUserMessageIndex), + await this.getCurrentCline()!.overwriteClineMessages([ + ...this.getCurrentCline()!.clineMessages.slice(0, messageIndex), + ...this.getCurrentCline()!.clineMessages.slice(nextUserMessageIndex), ]) } else { // If no next user message, keep only messages before current message - await this.cline.overwriteClineMessages( - this.cline.clineMessages.slice(0, messageIndex), + await this.getCurrentCline()!.overwriteClineMessages( + this.getCurrentCline()!.clineMessages.slice(0, messageIndex), ) } @@ -1221,30 +1381,36 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (apiConversationHistoryIndex !== -1) { if (nextUserMessage && nextUserMessage.ts) { // Keep messages before current API message and after next user message - await this.cline.overwriteApiConversationHistory([ - ...this.cline.apiConversationHistory.slice( + await this.getCurrentCline()!.overwriteApiConversationHistory([ + ...this.getCurrentCline()!.apiConversationHistory.slice( 0, apiConversationHistoryIndex, ), - ...this.cline.apiConversationHistory.filter( + ...this.getCurrentCline()!.apiConversationHistory.filter( (msg) => msg.ts && msg.ts >= nextUserMessage.ts, ), ]) } else { // If no next user message, keep only messages before current API message - await this.cline.overwriteApiConversationHistory( - this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex), + await this.getCurrentCline()!.overwriteApiConversationHistory( + this.getCurrentCline()!.apiConversationHistory.slice( + 0, + apiConversationHistoryIndex, + ), ) } } } else if (answer === "This and all subsequent messages") { // Delete this message and all that follow - await this.cline.overwriteClineMessages( - this.cline.clineMessages.slice(0, messageIndex), + await this.getCurrentCline()!.overwriteClineMessages( + this.getCurrentCline()!.clineMessages.slice(0, messageIndex), ) if (apiConversationHistoryIndex !== -1) { - await this.cline.overwriteApiConversationHistory( - this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex), + await this.getCurrentCline()!.overwriteApiConversationHistory( + this.getCurrentCline()!.apiConversationHistory.slice( + 0, + apiConversationHistoryIndex, + ), ) } } @@ -1520,8 +1686,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("experiments", updatedExperiments) // Update diffStrategy in current Cline instance if it exists - if (message.values[EXPERIMENT_IDS.DIFF_STRATEGY] !== undefined && this.cline) { - await this.cline.updateDiffStrategy( + if (message.values[EXPERIMENT_IDS.DIFF_STRATEGY] !== undefined && this.getCurrentCline()) { + await this.getCurrentCline()!.updateDiffStrategy( Experiments.isEnabled(updatedExperiments, EXPERIMENT_IDS.DIFF_STRATEGY), ) } @@ -1691,25 +1857,25 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Use the new setValues method to handle routing values to secrets or global state await this.contextProxy.setValues(apiConfiguration) - if (this.cline) { - this.cline.api = buildApiHandler(apiConfiguration) + if (this.getCurrentCline()) { + this.getCurrentCline()!.api = buildApiHandler(apiConfiguration) } } async cancelTask() { - if (this.cline) { - const { historyItem } = await this.getTaskWithId(this.cline.taskId) - this.cline.abortTask() + if (this.getCurrentCline()) { + const { historyItem } = await this.getTaskWithId(this.getCurrentCline()!.taskId) + this.getCurrentCline()!.abortTask() await pWaitFor( () => - this.cline === undefined || - this.cline.isStreaming === false || - this.cline.didFinishAbortingStream || + this.getCurrentCline()! === undefined || + this.getCurrentCline()!.isStreaming === false || + this.getCurrentCline()!.didFinishAbortingStream || // If only the first chunk is processed, then there's no // need to wait for graceful abort (closes edits, browser, // etc). - this.cline.isWaitingForFirstChunk, + this.getCurrentCline()!.isWaitingForFirstChunk, { timeout: 3_000, }, @@ -1717,11 +1883,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { console.error("Failed to abort task") }) - if (this.cline) { + if (this.getCurrentCline()) { // 'abandoned' will prevent this Cline instance from affecting // future Cline instances. This may happen if its hanging on a // streaming request. - this.cline.abandoned = true + this.getCurrentCline()!.abandoned = true } // Clears task again, so we need to abortTask manually above. @@ -1732,8 +1898,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { async updateCustomInstructions(instructions?: string) { // User may be clearing the field await this.updateGlobalState("customInstructions", instructions || undefined) - if (this.cline) { - this.cline.customInstructions = instructions || undefined + if (this.getCurrentCline()) { + this.getCurrentCline()!.customInstructions = instructions || undefined } await this.postStateToWebview() } @@ -1799,8 +1965,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { }) await this.postStateToWebview() - if (this.cline) { - this.cline.api = buildApiHandler({ apiProvider: openrouter, openRouterApiKey: apiKey }) + if (this.getCurrentCline()) { + this.getCurrentCline()!.api = buildApiHandler({ apiProvider: openrouter, openRouterApiKey: apiKey }) } // await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome } @@ -1829,8 +1995,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { glamaApiKey: apiKey, }) await this.postStateToWebview() - if (this.cline) { - this.cline.api = buildApiHandler({ + if (this.getCurrentCline()) { + this.getCurrentCline()!.api = buildApiHandler({ apiProvider: glama, glamaApiKey: apiKey, }) @@ -1872,7 +2038,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async showTaskWithId(id: string) { - if (id !== this.cline?.taskId) { + if (id !== this.getCurrentCline()?.taskId) { // non-current task const { historyItem } = await this.getTaskWithId(id) await this.initClineWithHistoryItem(historyItem) // clears existing task @@ -1885,51 +2051,47 @@ export class ClineProvider implements vscode.WebviewViewProvider { await downloadTask(historyItem.ts, apiConversationHistory) } + // this function deletes a task from task hidtory, and deletes it's checkpoints and delete the task folder async deleteTaskWithId(id: string) { - if (id === this.cline?.taskId) { - await this.clearTask() + // get the task directory full path + const { taskDirPath } = await this.getTaskWithId(id) + + // remove task from stack if it's the current task + if (id === this.getCurrentCline()?.taskId) { + // if we found the taskid to delete - call finish to abort this task and allow a new task to be started, + // if we are deleting a subtask and parent task is still waiting for subtask to finish - it allows the parent to resume (this case should neve exist) + await this.finishSubTask(`Task failure: It was stopped and deleted by the user.`) } - const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id) - + // delete task from the task history state await this.deleteTaskFromState(id) - // Delete the task files. - const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath) - - if (apiConversationHistoryFileExists) { - await fs.unlink(apiConversationHistoryFilePath) - } - - const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath) - - if (uiMessagesFileExists) { - await fs.unlink(uiMessagesFilePath) - } - - const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json") + // check if checkpoints are enabled + const { enableCheckpoints } = await this.getState() + // get the base directory of the project + const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) - if (await fileExistsAtPath(legacyMessagesFilePath)) { - await fs.unlink(legacyMessagesFilePath) - } - - // Delete checkpoints directory. + // Delete checkpoints branch. // TODO: Also delete the workspace branch if it exists. - const checkpointsDir = path.join(taskDirPath, "checkpoints") - - if (await fileExistsAtPath(checkpointsDir)) { - try { - await fs.rm(checkpointsDir, { recursive: true, force: true }) - console.log(`[deleteTaskWithId${id}] removed checkpoints repo`) - } catch (error) { - console.error( - `[deleteTaskWithId${id}] failed to remove checkpoints repo: ${error instanceof Error ? error.message : String(error)}`, - ) + if (enableCheckpoints && baseDir) { + const branchSummary = await simpleGit(baseDir) + .branch(["-D", `roo-code-checkpoints-${id}`]) + .catch(() => undefined) + + if (branchSummary) { + console.log(`[deleteTaskWithId${id}] deleted checkpoints branch`) } } - // Succeeds if the dir is empty. - await fs.rmdir(taskDirPath) + // delete the entire task directory including checkpoints and all content + try { + await fs.rm(taskDirPath, { recursive: true, force: true }) + console.log(`[deleteTaskWithId${id}] removed task directory`) + } catch (error) { + console.error( + `[deleteTaskWithId${id}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`, + ) + } } async deleteTaskFromState(id: string) { @@ -2002,10 +2164,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, uriScheme: vscode.env.uriScheme, - currentTaskItem: this.cline?.taskId - ? (taskHistory || []).find((item: HistoryItem) => item.id === this.cline?.taskId) + currentTaskItem: this.getCurrentCline()?.taskId + ? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId) : undefined, - clineMessages: this.cline?.clineMessages || [], + clineMessages: this.getCurrentCline()?.clineMessages || [], taskHistory: (taskHistory || []) .filter((item: HistoryItem) => item.ts && item.task) .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), @@ -2043,11 +2205,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } - async clearTask() { - this.cline?.abortTask() - this.cline = undefined // removes reference to it, so once promises end it will be garbage collected - } - // Caching mechanism to keep track of webview messages + API conversation history per provider instance /* @@ -2282,10 +2439,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.configManager.resetAllConfigs() await this.customModesManager.resetCustomModes() - if (this.cline) { - this.cline.abortTask() - this.cline = undefined - } + await this.removeClineFromStack() await this.postStateToWebview() await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } @@ -2294,6 +2448,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { public log(message: string) { this.outputChannel.appendLine(message) + console.log(message) } // integration tests @@ -2303,7 +2458,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { } get messages() { - return this.cline?.clineMessages || [] + return this.getCurrentCline()?.clineMessages || [] } // Add public getter diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 3d76da91831..0f530e8ab95 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -9,6 +9,7 @@ import { GlobalStateKey, SecretKey } from "../../../shared/globalState" import { setSoundEnabled } from "../../../utils/sound" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" +import { Cline } from "../../Cline" // Mock setup must come before imports jest.mock("../../prompts/sections/custom-instructions") @@ -238,12 +239,17 @@ jest.mock("../../Cline", () => ({ .fn() .mockImplementation( (provider, apiConfiguration, customInstructions, diffEnabled, fuzzyMatchThreshold, task, taskId) => ({ + api: undefined, abortTask: jest.fn(), handleWebviewAskResponse: jest.fn(), clineMessages: [], apiConversationHistory: [], overwriteClineMessages: jest.fn(), overwriteApiConversationHistory: jest.fn(), + getTaskNumber: jest.fn().mockReturnValue(0), + setTaskNumber: jest.fn(), + setParentTask: jest.fn(), + setRootTask: jest.fn(), taskId: taskId || "test-task-id", }), ), @@ -457,15 +463,46 @@ describe("ClineProvider", () => { }) test("clearTask aborts current task", async () => { - const mockAbortTask = jest.fn() - // @ts-ignore - accessing private property for testing - provider.cline = { abortTask: mockAbortTask } + // Setup Cline instance with auto-mock from the top of the file + const { Cline } = require("../../Cline") // Get the mocked class + const mockCline = new Cline() // Create a new mocked instance - await provider.clearTask() + // add the mock object to the stack + await provider.addClineToStack(mockCline) - expect(mockAbortTask).toHaveBeenCalled() - // @ts-ignore - accessing private property for testing - expect(provider.cline).toBeUndefined() + // get the stack size before the abort call + const stackSizeBeforeAbort = provider.getClineStackSize() + + // call the removeClineFromStack method so it will call the current cline abort and remove it from the stack + await provider.removeClineFromStack() + + // get the stack size after the abort call + const stackSizeAfterAbort = provider.getClineStackSize() + + // check if the abort method was called + expect(mockCline.abortTask).toHaveBeenCalled() + + // check if the stack size was decreased + expect(stackSizeBeforeAbort - stackSizeAfterAbort).toBe(1) + }) + + test("addClineToStack adds multiple Cline instances to the stack", async () => { + // Setup Cline instance with auto-mock from the top of the file + const { Cline } = require("../../Cline") // Get the mocked class + const mockCline1 = new Cline() // Create a new mocked instance + const mockCline2 = new Cline() // Create a new mocked instance + Object.defineProperty(mockCline1, "taskId", { value: "test-task-id-1", writable: true }) + Object.defineProperty(mockCline2, "taskId", { value: "test-task-id-2", writable: true }) + + // add Cline instances to the stack + await provider.addClineToStack(mockCline1) + await provider.addClineToStack(mockCline2) + + // verify cline instances were added to the stack + expect(provider.getClineStackSize()).toBe(2) + + // verify current cline instance is the last one added + expect(provider.getCurrentCline()).toBe(mockCline2) }) test("getState returns correct initial state", async () => { @@ -878,18 +915,12 @@ describe("ClineProvider", () => { const mockApiHistory = [{ ts: 1000 }, { ts: 2000 }, { ts: 3000 }, { ts: 4000 }, { ts: 5000 }, { ts: 6000 }] - // Setup Cline instance with mock data - const mockCline = { - clineMessages: mockMessages, - apiConversationHistory: mockApiHistory, - overwriteClineMessages: jest.fn(), - overwriteApiConversationHistory: jest.fn(), - taskId: "test-task-id", - abortTask: jest.fn(), - handleWebviewAskResponse: jest.fn(), - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline + // Setup Cline instance with auto-mock from the top of the file + const { Cline } = require("../../Cline") // Get the mocked class + const mockCline = new Cline() // Create a new mocked instance + mockCline.clineMessages = mockMessages // Set test-specific messages + mockCline.apiConversationHistory = mockApiHistory // Set API history + await provider.addClineToStack(mockCline) // Add the mocked instance to the stack // Mock getTaskWithId ;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({ @@ -931,18 +962,12 @@ describe("ClineProvider", () => { const mockApiHistory = [{ ts: 1000 }, { ts: 2000 }, { ts: 3000 }, { ts: 4000 }] - // Setup Cline instance with mock data - const mockCline = { - clineMessages: mockMessages, - apiConversationHistory: mockApiHistory, - overwriteClineMessages: jest.fn(), - overwriteApiConversationHistory: jest.fn(), - taskId: "test-task-id", - abortTask: jest.fn(), - handleWebviewAskResponse: jest.fn(), - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline + // Setup Cline instance with auto-mock from the top of the file + const { Cline } = require("../../Cline") // Get the mocked class + const mockCline = new Cline() // Create a new mocked instance + mockCline.clineMessages = mockMessages + mockCline.apiConversationHistory = mockApiHistory + await provider.addClineToStack(mockCline) // Mock getTaskWithId ;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({ @@ -964,15 +989,12 @@ describe("ClineProvider", () => { // Mock user selecting "Cancel" ;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue("Cancel") - const mockCline = { - clineMessages: [{ ts: 1000 }, { ts: 2000 }], - apiConversationHistory: [{ ts: 1000 }, { ts: 2000 }], - overwriteClineMessages: jest.fn(), - overwriteApiConversationHistory: jest.fn(), - taskId: "test-task-id", - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline + // Setup Cline instance with auto-mock from the top of the file + const { Cline } = require("../../Cline") // Get the mocked class + const mockCline = new Cline() // Create a new mocked instance + mockCline.clineMessages = [{ ts: 1000 }, { ts: 2000 }] + mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }] + await provider.addClineToStack(mockCline) // Trigger message deletion const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] @@ -1472,13 +1494,10 @@ describe("ClineProvider", () => { .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), } as any - // Setup mock Cline instance - const mockCline = { - api: undefined, - abortTask: jest.fn(), - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline + // Setup Cline instance with auto-mock from the top of the file + const { Cline } = require("../../Cline") // Get the mocked class + const mockCline = new Cline() // Create a new mocked instance + await provider.addClineToStack(mockCline) const testApiConfig = { apiProvider: "anthropic" as const, diff --git a/src/exports/index.ts b/src/exports/index.ts index a0680b04829..e4b17da4844 100644 --- a/src/exports/index.ts +++ b/src/exports/index.ts @@ -15,7 +15,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi startNewTask: async (task?: string, images?: string[]) => { outputChannel.appendLine("Starting new task") - await sidebarProvider.clearTask() + await sidebarProvider.removeClineFromStack() await sidebarProvider.postStateToWebview() await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) await sidebarProvider.postMessageToWebview({ diff --git a/src/shared/HistoryItem.ts b/src/shared/HistoryItem.ts index ef242cb9679..e6e2c09ed2b 100644 --- a/src/shared/HistoryItem.ts +++ b/src/shared/HistoryItem.ts @@ -1,5 +1,6 @@ export type HistoryItem = { id: string + number: number ts: number task: string tokensIn: number diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index c469345fa11..1f30a5a13fb 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -175,7 +175,10 @@ const TaskHeader: React.FC = ({ flexGrow: 1, minWidth: 0, // This allows the div to shrink below its content size }}> - Task{!isTaskExpanded && ":"} + + Task ({currentTaskItem?.number === 0 ? "Main" : currentTaskItem?.number}) + {!isTaskExpanded && ":"} + {!isTaskExpanded && ( {highlightMentions(task.text, false)} )} diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index f81d8ddacff..a86082a2671 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -35,6 +35,12 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => { {formatDate(item.ts)} + + ({item.number === 0 ? "Main" : item.number}) +
({ const mockTaskHistory = [ { id: "1", + number: 0, task: "Test task 1", ts: new Date("2022-02-16T00:00:00").getTime(), tokensIn: 100, @@ -31,6 +32,7 @@ const mockTaskHistory = [ }, { id: "2", + number: 0, task: "Test task 2", ts: new Date("2022-02-17T00:00:00").getTime(), tokensIn: 200,