From c3c3874593bbd00e6411c566d0875c700994a061 Mon Sep 17 00:00:00 2001 From: ShayBC Date: Sat, 22 Feb 2025 23:52:13 +0200 Subject: [PATCH 01/23] subtasks alpha version (still in development) --- src/activate/registerCommands.ts | 2 +- src/core/Cline.ts | 79 ++++++- src/core/webview/ClineProvider.ts | 211 ++++++++++++------ .../webview/__tests__/ClineProvider.test.ts | 2 +- src/exports/index.ts | 2 +- 5 files changed, 218 insertions(+), 78 deletions(-) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 69e257e7a51..79f57b25095 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -20,7 +20,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 9c2977a2669..134961129cc 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -75,6 +75,10 @@ type UserContent = Array< export class Cline { readonly taskId: string + // 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 api: ApiHandler private terminalManager: TerminalManager private urlContentFetcher: UrlContentFetcher @@ -160,6 +164,12 @@ export class Cline { } } + // 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 + } + // Add method to update diffStrategy async updateDiffStrategy(experimentalDiffStrategy?: boolean) { // If not provided, get from current state @@ -480,6 +490,43 @@ export class Cline { ]) } + async resumePausedTask() { + // release this Cline instance from paused state + this.isPaused = false + + // Clear any existing ask state and simulate a completed ask response + // this.askResponse = "messageResponse"; + // this.askResponseText = "Sub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task."; + // this.askResponseImages = undefined; + // this.lastMessageTs = Date.now(); + + // This adds the completion message to conversation history + await this.say( + "text", + "Sub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task.", + ) + + // this.userMessageContent.push({ + // type: "text", + // text: `${"Result:\\n\\nSub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task."}`, + // }) + + try { + // Resume parent task + await this.ask("resume_task") + } catch (error) { + if (error.message === "Current ask promise was ignored") { + // ignore the ignored promise, since it was performed by launching a subtask and it probably took more then 1 sec, + // also set the didAlreadyUseTool flag to indicate that the tool was already used, and there is no need to relaunch it + this.didAlreadyUseTool = true + } else { + // Handle error appropriately + console.error("Failed to resume task:", error) + throw error + } + } + } + private async resumeTaskFromHistory() { const modifiedClineMessages = await this.getSavedClineMessages() @@ -2553,10 +2600,12 @@ export class Cline { const provider = this.providerRef.deref() if (provider) { await provider.handleModeSwitch(mode) - await provider.initClineWithTask(message) + await provider.initClineWithSubTask(message) pushToolResult( `Successfully created new task in ${targetMode.name} mode with message: ${message}`, ) + // pasue the current task and start the new task + this.isPaused = true } else { pushToolResult( formatResponse.toolError("Failed to create new task: provider not available"), @@ -2648,6 +2697,10 @@ 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 + this.providerRef.deref()?.finishSubTask() + } } // complete command message @@ -2665,6 +2718,10 @@ 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 + this.providerRef.deref()?.finishSubTask() + } } // we already sent completion_result says, an empty string asks relinquishes control over button and field @@ -2740,6 +2797,20 @@ 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, @@ -2779,6 +2850,12 @@ export class Cline { await this.checkpointSave({ isFirst: true }) } + // 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) { + await this.waitForResume() + } + // 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( diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 05faa138342..d690efa9dd5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -147,7 +147,7 @@ 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 = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement @@ -176,6 +176,55 @@ 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. + addClineToStack(cline: Cline): void { + this.clineStack.push(cline) + } + + // Removes and destroys the top Cline instance (the current finished task), activating the previous one (resuming the parent task). + async removeClineFromStack() { + // pop the top Cline instance from the stack + var clineToBeRemoved = this.clineStack.pop() + if (clineToBeRemoved) { + await clineToBeRemoved.abortTask() + // make sure no reference kept, once promises end it will be garbage collected + clineToBeRemoved = undefined + } + } + + // 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) { + const index = this.clineStack.findIndex((c) => c.taskId === clineId) + if (index === -1) { + return + } + for (let i = this.clineStack.length - 1; i >= index; i--) { + this.removeClineFromStack() + } + } + + // 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] + } + + // 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() { + // 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() + } + /* 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/ @@ -183,7 +232,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() @@ -236,7 +285,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 } @@ -267,7 +317,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", @@ -303,7 +353,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", @@ -392,13 +442,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") } + // 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[]) { - await this.clearTask() const { apiConfiguration, customModePrompts, @@ -413,7 +471,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( this, apiConfiguration, effectiveInstructions, @@ -425,10 +483,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { undefined, experiments, ) + this.addClineToStack(newCline) } public async initClineWithHistoryItem(historyItem: HistoryItem) { - await this.clearTask() + await this.removeClineFromStack() const { apiConfiguration, @@ -444,7 +503,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( this, apiConfiguration, effectiveInstructions, @@ -456,6 +515,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { historyItem, experiments, ) + this.addClineToStack(newCline) } public async postMessageToWebview(message: ExtensionMessage) { @@ -810,11 +870,15 @@ 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() + await this.removeClineFromStack() await this.postStateToWebview() break case "didShowAnnouncement": @@ -826,7 +890,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) } @@ -892,7 +956,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 @@ -903,13 +967,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.") } @@ -1145,42 +1209,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), ) } @@ -1188,30 +1253,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, + ), ) } } @@ -1481,8 +1552,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), ) } @@ -1724,25 +1795,25 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("requestyModelInfo", requestyModelInfo), this.updateGlobalState("modelTemperature", modelTemperature), ]) - 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, }, @@ -1750,11 +1821,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. @@ -1765,8 +1836,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() } @@ -1980,8 +2051,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("apiProvider", openrouter) await this.storeSecret("openRouterApiKey", apiKey) 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 } @@ -2012,8 +2083,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("apiProvider", glama) await this.storeSecret("glamaApiKey", apiKey) await this.postStateToWebview() - if (this.cline) { - this.cline.api = buildApiHandler({ + if (this.getCurrentCline()) { + this.getCurrentCline()!.api = buildApiHandler({ apiProvider: glama, glamaApiKey: apiKey, }) @@ -2295,7 +2366,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 @@ -2309,8 +2380,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async deleteTaskWithId(id: string) { - if (id === this.cline?.taskId) { - await this.clearTask() + if (id === this.getCurrentCline()?.taskId) { + await this.removeClineWithIdFromStack(id) } const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id) @@ -2434,10 +2505,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, uriScheme: vscode.env.uriScheme, - currentTaskItem: this.cline?.taskId - ? (taskHistory || []).find((item) => item.id === this.cline?.taskId) + currentTaskItem: this.getCurrentCline()?.taskId + ? (taskHistory || []).find((item) => 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), @@ -2472,11 +2543,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 /* @@ -2914,10 +2980,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" }) } @@ -2935,7 +2998,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 0a8f73308f5..15d0eff7086 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -381,7 +381,7 @@ describe("ClineProvider", () => { // @ts-ignore - accessing private property for testing provider.cline = { abortTask: mockAbortTask } - await provider.clearTask() + await provider.removeClineFromStack() expect(mockAbortTask).toHaveBeenCalled() // @ts-ignore - accessing private property for testing 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({ From 87f6ac4d06083a895aadc5c15ffb1532eb70ba9e Mon Sep 17 00:00:00 2001 From: ShayBC Date: Sun, 23 Feb 2025 05:15:23 +0200 Subject: [PATCH 02/23] pass last message of a subtask to parent task --- src/core/Cline.ts | 32 ++++++++++++++----------------- src/core/webview/ClineProvider.ts | 4 ++-- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 134961129cc..54de357d469 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -490,26 +490,22 @@ export class Cline { ]) } - async resumePausedTask() { + async resumePausedTask(lastMessage?: string) { // release this Cline instance from paused state this.isPaused = false - // Clear any existing ask state and simulate a completed ask response - // this.askResponse = "messageResponse"; - // this.askResponseText = "Sub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task."; - // this.askResponseImages = undefined; - // this.lastMessageTs = Date.now(); - // This adds the completion message to conversation history - await this.say( - "text", - "Sub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task.", - ) - - // this.userMessageContent.push({ - // type: "text", - // text: `${"Result:\\n\\nSub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task."}`, - // }) + await this.say("text", `new_task finished successfully! ${lastMessage ?? "Please continue to the next task."}`) + + await this.addToApiConversationHistory({ + role: "assistant", + content: [ + { + type: "text", + text: `new_task finished successfully! ${lastMessage ?? "Please continue to the next task."}`, + }, + ], + }) try { // Resume parent task @@ -2699,7 +2695,7 @@ export class Cline { 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 - this.providerRef.deref()?.finishSubTask() + this.providerRef.deref()?.finishSubTask(lastMessage?.text) } } @@ -2720,7 +2716,7 @@ export class Cline { 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 - this.providerRef.deref()?.finishSubTask() + this.providerRef.deref()?.finishSubTask(lastMessage?.text) } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d690efa9dd5..b00c744ff66 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -218,11 +218,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { // 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() { + async finishSubTask(lastMessage?: string) { // 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() + this.getCurrentCline()?.resumePausedTask(lastMessage) } /* From 20f90732043223163275ba1c69d406d03a3ba22f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 23 Feb 2025 02:20:36 -0500 Subject: [PATCH 03/23] Change response to be a user message --- src/core/Cline.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 54de357d469..35879a42913 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -498,11 +498,11 @@ export class Cline { await this.say("text", `new_task finished successfully! ${lastMessage ?? "Please continue to the next task."}`) await this.addToApiConversationHistory({ - role: "assistant", + role: "user", content: [ { type: "text", - text: `new_task finished successfully! ${lastMessage ?? "Please continue to the next task."}`, + text: `[new_task completed] Result: ${lastMessage ?? "Please continue to the next task."}`, }, ], }) From 655930cf5f3925460414e9098d8851549e4a3333 Mon Sep 17 00:00:00 2001 From: ShayBC Date: Mon, 24 Feb 2025 00:27:40 +0200 Subject: [PATCH 04/23] added getClineStackSize() to ClineProvider and fixed its tests --- src/core/webview/ClineProvider.ts | 5 ++ .../webview/__tests__/ClineProvider.test.ts | 57 +++++++++++++------ 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b00c744ff66..a8f10f6fcd0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -215,6 +215,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { 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 diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 15d0eff7086..65408b8973b 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -8,6 +8,7 @@ import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessa import { setSoundEnabled } from "../../../utils/sound" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" +import { Cline } from "../../Cline" // Mock custom-instructions module const mockAddCustomInstructions = jest.fn() @@ -377,15 +378,43 @@ describe("ClineProvider", () => { }) test("clearTask aborts current task", async () => { + // prepare the mock object const mockAbortTask = jest.fn() - // @ts-ignore - accessing private property for testing - provider.cline = { abortTask: mockAbortTask } + const clineMock = { abortTask: mockAbortTask } as unknown as Cline + + // add the mock object to the stack + provider.addClineToStack(clineMock) + // 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(mockAbortTask).toHaveBeenCalled() - // @ts-ignore - accessing private property for testing - expect(provider.cline).toBeUndefined() + + // check if the stack size was decreased + expect(stackSizeBeforeAbort - stackSizeAfterAbort).toBe(1) + }) + + test("addClineToStack adds multiple Cline instances to the stack", () => { + // prepare test data + const mockCline1 = { taskId: "test-task-id-1" } as unknown as Cline + const mockCline2 = { taskId: "test-task-id-2" } as unknown as Cline + + // add Cline instances to the stack + provider.addClineToStack(mockCline1) + 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 () => { @@ -788,9 +817,8 @@ describe("ClineProvider", () => { taskId: "test-task-id", abortTask: jest.fn(), handleWebviewAskResponse: jest.fn(), - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline + } as unknown as Cline + provider.addClineToStack(mockCline) // Mock getTaskWithId ;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({ @@ -841,9 +869,8 @@ describe("ClineProvider", () => { taskId: "test-task-id", abortTask: jest.fn(), handleWebviewAskResponse: jest.fn(), - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline + } as unknown as Cline + provider.addClineToStack(mockCline) // Mock getTaskWithId ;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({ @@ -871,9 +898,8 @@ describe("ClineProvider", () => { overwriteClineMessages: jest.fn(), overwriteApiConversationHistory: jest.fn(), taskId: "test-task-id", - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline + } as unknown as Cline + provider.addClineToStack(mockCline) // Trigger message deletion const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] @@ -1377,9 +1403,8 @@ describe("ClineProvider", () => { const mockCline = { api: undefined, abortTask: jest.fn(), - } - // @ts-ignore - accessing private property for testing - provider.cline = mockCline + } as unknown as Cline + provider.addClineToStack(mockCline) const testApiConfig = { apiProvider: "anthropic" as const, From 01765995ada92491ef7576d10f8d7a8da22f4223 Mon Sep 17 00:00:00 2001 From: ShayBC Date: Mon, 24 Feb 2025 23:00:11 +0200 Subject: [PATCH 05/23] added task no indicator + improved deleteTask code --- src/core/Cline.ts | 13 ++++ src/core/__tests__/Cline.test.ts | 1 + src/core/webview/ClineProvider.ts | 72 +++++++++---------- src/shared/HistoryItem.ts | 1 + webview-ui/src/components/chat/TaskHeader.tsx | 5 +- .../src/components/history/HistoryPreview.tsx | 6 ++ .../history/__tests__/HistoryView.test.tsx | 2 + 7 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 35879a42913..bcec6243fff 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -75,6 +75,7 @@ type UserContent = Array< 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) @@ -139,6 +140,7 @@ export class Cline { } this.taskId = crypto.randomUUID() + this.taskNumber = -1 this.api = buildApiHandler(apiConfiguration) this.terminalManager = new TerminalManager() this.urlContentFetcher = new UrlContentFetcher(provider.context) @@ -170,6 +172,16 @@ export class Cline { 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 + } + // Add method to update diffStrategy async updateDiffStrategy(experimentalDiffStrategy?: boolean) { // If not provided, get from current state @@ -276,6 +288,7 @@ export class Cline { await this.providerRef.deref()?.updateTaskHistory({ id: this.taskId, + number: this.taskNumber, ts: lastRelevantMessage.ts, task: taskMessage.text ?? "", tokensIn: apiMetrics.totalTokensIn, diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 3da0c8cdd3d..0afce81bce5 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -222,6 +222,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 a8f10f6fcd0..6e9e2025264 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -153,6 +153,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement configManager: ConfigManager customModesManager: CustomModesManager + private lastTaskNumber = -1 constructor( readonly context: vscode.ExtensionContext, @@ -180,6 +181,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { // 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. addClineToStack(cline: Cline): void { + // if cline.getTaskNumber() is -1, it means it is a new task + if (cline.getTaskNumber() === -1) { + // increase last cline number by 1 + this.lastTaskNumber = this.lastTaskNumber + 1 + cline.setTaskNumber(this.lastTaskNumber) + } + // if cline.getTaskNumber() > lastTaskNumber, set lastTaskNumber to cline.getTaskNumber() + else if (cline.getTaskNumber() > this.lastTaskNumber) { + this.lastTaskNumber = cline.getTaskNumber() + } + // push the cline instance to the stack this.clineStack.push(cline) } @@ -192,6 +204,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { // make sure no reference kept, once promises end it will be garbage collected clineToBeRemoved = undefined } + // if the stack is empty, reset the last task number + if (this.clineStack.length === 0) { + this.lastTaskNumber = -1 + } } // remove the cline object with the received clineId, and all the cline objects bove it in the stack @@ -520,6 +536,8 @@ 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) this.addClineToStack(newCline) } @@ -2384,38 +2402,25 @@ 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) { + // 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) { await this.removeClineWithIdFromStack(id) } - 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") - - if (await fileExistsAtPath(legacyMessagesFilePath)) { - await fs.unlink(legacyMessagesFilePath) - } - + // check if checkpoints are enabled const { checkpointsEnabled } = await this.getState() + // get the base directory of the project const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) - // Delete checkpoints branch. + // delete checkpoints branch from project git repo if (checkpointsEnabled && baseDir) { const branchSummary = await simpleGit(baseDir) .branch(["-D", `roo-code-checkpoints-${id}`]) @@ -2426,22 +2431,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } - // Delete checkpoints directory - 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)}`, - ) - } + // 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)}`, + ) } - - // Succeeds if the dir is empty. - await fs.rmdir(taskDirPath) } async deleteTaskFromState(id: string) { 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 b35be0cd2a6..90d050cf538 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -158,7 +158,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 b2898fc6a8d..f0484b1dcc8 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -120,6 +120,12 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => { }}> {formatDate(item.ts)} + + ({item.number === 0 ? "Main" : item.number}) +