diff --git a/e2e/src/suite/extension.test.ts b/e2e/src/suite/extension.test.ts index 969087ff02d..bc09f816f5e 100644 --- a/e2e/src/suite/extension.test.ts +++ b/e2e/src/suite/extension.test.ts @@ -9,10 +9,6 @@ suite("Roo Code Extension", () => { }) test("Commands should be registered", async () => { - const timeout = 10 * 1_000 - const interval = 1_000 - const startTime = Date.now() - const expectedCommands = [ "roo-cline.plusButtonClicked", "roo-cline.mcpButtonClicked", @@ -25,23 +21,6 @@ suite("Roo Code Extension", () => { "roo-cline.improveCode", ] - while (Date.now() - startTime < timeout) { - const commands = await vscode.commands.getCommands(true) - const missingCommands = [] - - for (const cmd of expectedCommands) { - if (!commands.includes(cmd)) { - missingCommands.push(cmd) - } - } - - if (missingCommands.length === 0) { - break - } - - await new Promise((resolve) => setTimeout(resolve, interval)) - } - const commands = await vscode.commands.getCommands(true) for (const cmd of expectedCommands) { diff --git a/e2e/src/suite/index.ts b/e2e/src/suite/index.ts index aea3d882320..3a0fe27255a 100644 --- a/e2e/src/suite/index.ts +++ b/e2e/src/suite/index.ts @@ -8,55 +8,39 @@ import { RooCodeAPI } from "../../../src/exports/roo-code" import { waitUntilReady } from "./utils" declare global { - var extension: vscode.Extension | undefined var api: RooCodeAPI } export async function run() { - const mocha = new Mocha({ ui: "tdd", timeout: 300_000 }) - const testsRoot = path.resolve(__dirname, "..") - - try { - // Find all test files. - const files = await glob("**/**.test.js", { cwd: testsRoot }) - files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f))) - - const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline") - - if (!extension) { - throw new Error("Extension not found") - } - - const api = extension.isActive ? extension.exports : await extension.activate() - - await api.setConfiguration({ - apiProvider: "openrouter", - openRouterApiKey: process.env.OPENROUTER_API_KEY!, - openRouterModelId: "anthropic/claude-3.5-sonnet", - }) - - await waitUntilReady(api) - - globalThis.api = api - globalThis.extension = extension - - return new Promise((resolve, reject) => { - try { - mocha.run((failures: number) => { - if (failures > 0) { - reject(new Error(`${failures} tests failed.`)) - } else { - resolve() - } - }) - } catch (err) { - console.error(err) - reject(err) - } - }) - } catch (err) { - console.error("Error while running tests:") - console.error(err) - throw err + const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline") + + if (!extension) { + throw new Error("Extension not found") } + + // Activate the extension if it's not already active. + const api = extension.isActive ? extension.exports : await extension.activate() + + // TODO: We might want to support a "free" model out of the box so + // contributors can run the tests locally without having to pay. + await api.setConfiguration({ + apiProvider: "openrouter", + openRouterApiKey: process.env.OPENROUTER_API_KEY!, + openRouterModelId: "anthropic/claude-3.5-sonnet", + }) + + await waitUntilReady({ api }) + + // Expose the API to the tests. + globalThis.api = api + + // Add all the tests to the runner. + const mocha = new Mocha({ ui: "tdd", timeout: 300_000 }) + const cwd = path.resolve(__dirname, "..") + ;(await glob("**/**.test.js", { cwd })).forEach((testFile) => mocha.addFile(path.resolve(cwd, testFile))) + + // Let's go! + return new Promise((resolve, reject) => + mocha.run((failures) => (failures === 0 ? resolve() : reject(new Error(`${failures} tests failed.`)))), + ) } diff --git a/e2e/src/suite/modes.test.ts b/e2e/src/suite/modes.test.ts index 5083d289f6a..a786513ec01 100644 --- a/e2e/src/suite/modes.test.ts +++ b/e2e/src/suite/modes.test.ts @@ -1,63 +1,34 @@ import * as assert from "assert" -import { waitForMessage } from "./utils" +import { waitForMessage, getMessage } from "./utils" suite("Roo Code Modes", () => { test("Should handle switching modes correctly", async function () { - const timeout = 300_000 const api = globalThis.api - const testPrompt = + let prompt = "For each mode (Code, Architect, Ask) respond with the mode name and what it specializes in after switching to that mode, do not start with the current mode, be sure to say 'I AM DONE' after the task is complete." await api.setConfiguration({ mode: "Code", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }) - await api.startNewTask(testPrompt) + let taskId = await api.startNewTask(prompt) + await waitForMessage({ api, taskId, include: "I AM DONE", exclude: "be sure to say", timeout: 300_000 }) - await waitForMessage(api, { include: "I AM DONE", exclude: "be sure to say", timeout }) + // Start grading portion of test to grade the response from 1 to 10. + prompt = `Given this prompt: ${prompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${api + .getMessages(taskId) + .filter(({ type }) => type === "say") + .map(({ text }) => text ?? "") + .join("\n")}\nBe sure to say 'I AM DONE GRADING' after the task is complete.` - if (api.getMessages().length === 0) { - assert.fail("No messages received") - } - - // Log the messages to the console. - api.getMessages().forEach(({ type, text }) => { - if (type === "say") { - console.log(text) - } - }) - - // Start Grading Portion of test to grade the response from 1 to 10. await api.setConfiguration({ mode: "Ask" }) + taskId = await api.startNewTask(prompt) + await waitForMessage({ api, taskId, include: "I AM DONE GRADING", exclude: "be sure to say" }) - let output = api - .getMessages() - .map(({ type, text }) => (type === "say" ? text : "")) - .join("\n") - - await api.startNewTask( - `Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output}\nBe sure to say 'I AM DONE GRADING' after the task is complete.`, + const match = getMessage({ api, taskId, include: "Grade:", exclude: "Grade: (1-10)" })?.text?.match( + /Grade: (\d+)/, ) - await waitForMessage(api, { include: "I AM DONE GRADING", exclude: "be sure to say", timeout }) - - if (api.getMessages().length === 0) { - assert.fail("No messages received") - } - - api.getMessages().forEach(({ type, text }) => { - if (type === "say" && text?.includes("Grade:")) { - console.log(text) - } - }) - - const gradeMessage = api - .getMessages() - .find( - ({ type, text }) => type === "say" && !text?.includes("Grade: (1-10)") && text?.includes("Grade:"), - )?.text - - const gradeMatch = gradeMessage?.match(/Grade: (\d+)/) - const gradeNum = gradeMatch ? parseInt(gradeMatch[1]) : undefined - assert.ok(gradeNum !== undefined && gradeNum >= 7 && gradeNum <= 10, "Grade must be between 7 and 10") + const score = parseInt(match?.[1] ?? "0") + assert.ok(score >= 7 && score <= 10, "Grade must be between 7 and 10.") }) }) diff --git a/e2e/src/suite/subtasks.test.ts b/e2e/src/suite/subtasks.test.ts index 4a3a65375a9..a679f1661bd 100644 --- a/e2e/src/suite/subtasks.test.ts +++ b/e2e/src/suite/subtasks.test.ts @@ -1,9 +1,9 @@ import * as assert from "assert" -import { sleep, waitForToolUse, waitForMessage } from "./utils" +import { sleep, waitForMessage, waitFor, getMessage } from "./utils" suite("Roo Code Subtasks", () => { - test.skip("Should handle subtask cancellation and resumption correctly", async function () { + test("Should handle subtask cancellation and resumption correctly", async function () { const api = globalThis.api await api.setConfiguration({ @@ -11,48 +11,60 @@ suite("Roo Code Subtasks", () => { alwaysAllowModeSwitch: true, alwaysAllowSubtasks: true, autoApprovalEnabled: true, + enableCheckpoints: false, }) + const childPrompt = "You are a calculator. Respond only with numbers. What is the square root of 9?" + // Start a parent task that will create a subtask. - await api.startNewTask( + const parentTaskId = await api.startNewTask( "You are the parent task. " + - "Create a subtask by using the new_task tool with the message 'You are the subtask'. " + - "After creating the subtask, wait for it to complete and then respond with 'Parent task resumed'.", + `Create a subtask by using the new_task tool with the message '${childPrompt}'.` + + "After creating the subtask, wait for it to complete and then respond 'Parent task resumed'.", ) - await waitForToolUse(api, "new_task") + let subTaskId: string | undefined = undefined - // Cancel the current task (which should be the subtask). - await api.cancelTask() + // Wait for the subtask to be spawned and then cancel it. + api.on("taskSpawned", (taskId) => (subTaskId = taskId)) + await waitFor(() => !!subTaskId) + await sleep(2_000) // Give the task a chance to start and populate the history. + await api.cancelCurrentTask() - // Check if the parent task is still waiting (not resumed). We need to - // wait a bit to ensure any task resumption would have happened. + // Wait a bit to ensure any task resumption would have happened. await sleep(5_000) // The parent task should not have resumed yet, so we shouldn't see // "Parent task resumed". assert.ok( - !api.getMessages().some(({ type, text }) => type === "say" && text?.includes("Parent task resumed")), - "Parent task should not have resumed after subtask cancellation.", + getMessage({ + api, + taskId: parentTaskId, + include: "Parent task resumed", + exclude: "You are the parent task", + }) === undefined, + "Parent task should not have resumed after subtask cancellation", ) // Start a new task with the same message as the subtask. - await api.startNewTask("You are the subtask") - - // Wait for the subtask to complete. - await waitForMessage(api, { include: "Task complete" }) + const anotherTaskId = await api.startNewTask(childPrompt) + await waitForMessage({ taskId: anotherTaskId, api, include: "3" }) - // Verify that the parent task is still not resumed. We need to wait a - // bit to ensure any task resumption would have happened. + // Wait a bit to ensure any task resumption would have happened. await sleep(5_000) // The parent task should still not have resumed. assert.ok( - !api.getMessages().some(({ type, text }) => type === "say" && text?.includes("Parent task resumed")), - "Parent task should not have resumed after subtask completion.", + getMessage({ + api, + taskId: parentTaskId, + include: "Parent task resumed", + exclude: "You are the parent task", + }) === undefined, + "Parent task should not have resumed after subtask cancellation", ) // Clean up - cancel all tasks. - await api.cancelTask() + await api.cancelCurrentTask() }) }) diff --git a/e2e/src/suite/task.test.ts b/e2e/src/suite/task.test.ts index 679a82f550e..840654a5082 100644 --- a/e2e/src/suite/task.test.ts +++ b/e2e/src/suite/task.test.ts @@ -4,7 +4,7 @@ suite("Roo Code Task", () => { test("Should handle prompt and response correctly", async function () { const api = globalThis.api await api.setConfiguration({ mode: "Ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }) - await api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'") - await waitForMessage(api, { include: "My name is Roo" }) + const taskId = await api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'") + await waitForMessage({ api, taskId, include: "My name is Roo" }) }) }) diff --git a/e2e/src/suite/utils.ts b/e2e/src/suite/utils.ts index c0927fc6503..caaf1584366 100644 --- a/e2e/src/suite/utils.ts +++ b/e2e/src/suite/utils.ts @@ -41,36 +41,51 @@ export const waitFor = ( ]) } -export const waitUntilReady = async (api: RooCodeAPI, { timeout = 10_000, interval = 250 }: WaitForOptions = {}) => { +type WaitUntilReadyOptions = WaitForOptions & { + api: RooCodeAPI +} + +export const waitUntilReady = async ({ api, ...options }: WaitUntilReadyOptions) => { await vscode.commands.executeCommand("roo-cline.SidebarProvider.focus") - await waitFor(api.isReady, { timeout, interval }) + await waitFor(() => api.isReady(), options) +} + +type WaitForToolUseOptions = WaitUntilReadyOptions & { + taskId: string + toolName: string } -export const waitForToolUse = async (api: RooCodeAPI, toolName: string, options: WaitForOptions = {}) => +export const waitForToolUse = async ({ api, taskId, toolName, ...options }: WaitForToolUseOptions) => waitFor( () => api - .getMessages() + .getMessages(taskId) .some(({ type, say, text }) => type === "say" && say === "tool" && text && text.includes(toolName)), options, ) -export const waitForMessage = async ( - api: RooCodeAPI, - options: WaitForOptions & { include: string; exclude?: string }, -) => - waitFor( - () => - api - .getMessages() - .some( - ({ type, text }) => - type === "say" && - text && - text.includes(options.include) && - (!options.exclude || !text.includes(options.exclude)), - ), - options, - ) +type WaitForMessageOptions = WaitUntilReadyOptions & { + taskId: string + include: string + exclude?: string +} + +export const waitForMessage = async ({ api, taskId, include, exclude, ...options }: WaitForMessageOptions) => + waitFor(() => !!getMessage({ api, taskId, include, exclude }), options) + +type GetMessageOptions = { + api: RooCodeAPI + taskId: string + include: string + exclude?: string +} + +export const getMessage = ({ api, taskId, include, exclude }: GetMessageOptions) => + api + .getMessages(taskId) + .find( + ({ type, text }) => + type === "say" && text && text.includes(include) && (!exclude || !text.includes(exclude)), + ) export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/src/activate/createRooCodeAPI.ts b/src/activate/createRooCodeAPI.ts deleted file mode 100644 index aa404999aa8..00000000000 --- a/src/activate/createRooCodeAPI.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as vscode from "vscode" - -import { ClineProvider } from "../core/webview/ClineProvider" - -import { RooCodeAPI } from "../exports/roo-code" -import { ConfigurationValues } from "../shared/globalState" - -export function createRooCodeAPI(outputChannel: vscode.OutputChannel, provider: ClineProvider): RooCodeAPI { - return { - startNewTask: async (task?: string, images?: string[]) => { - outputChannel.appendLine("Starting new task") - - await provider.removeClineFromStack() - await provider.postStateToWebview() - await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) - - await provider.postMessageToWebview({ - type: "invoke", - invoke: "sendMessage", - text: task, - images: images, - }) - - outputChannel.appendLine( - `Task started with message: ${task ? `"${task}"` : "undefined"} and ${images?.length || 0} image(s)`, - ) - }, - - cancelTask: async () => { - outputChannel.appendLine("Cancelling current task") - await provider.cancelTask() - }, - - sendMessage: async (message?: string, images?: string[]) => { - outputChannel.appendLine( - `Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`, - ) - - await provider.postMessageToWebview({ - type: "invoke", - invoke: "sendMessage", - text: message, - images: images, - }) - }, - - pressPrimaryButton: async () => { - outputChannel.appendLine("Pressing primary button") - await provider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" }) - }, - - pressSecondaryButton: async () => { - outputChannel.appendLine("Pressing secondary button") - await provider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" }) - }, - - setConfiguration: async (values: Partial) => { - await provider.setValues(values) - }, - - isReady: () => provider.viewLaunched, - - getMessages: () => provider.messages, - } -} diff --git a/src/activate/index.ts b/src/activate/index.ts index 7cc36a0b8a5..658bf467f7a 100644 --- a/src/activate/index.ts +++ b/src/activate/index.ts @@ -1,5 +1,4 @@ export { handleUri } from "./handleUri" export { registerCommands } from "./registerCommands" export { registerCodeActions } from "./registerCodeActions" -export { createRooCodeAPI } from "./createRooCodeAPI" export { registerTerminalActions } from "./registerTerminalActions" diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 28bdab1ed0b..12a57b98120 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1,13 +1,14 @@ +import fs from "fs/promises" +import * as path from "path" +import os from "os" +import crypto from "crypto" +import EventEmitter from "events" + import { Anthropic } from "@anthropic-ai/sdk" import cloneDeep from "clone-deep" -import { DiffStrategy, getDiffStrategy } from "./diff/DiffStrategy" -import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator" import delay from "delay" -import fs from "fs/promises" -import os from "os" import pWaitFor from "p-wait-for" import getFolderSize from "get-folder-size" -import * as path from "path" import { serializeError } from "serialize-error" import * as vscode from "vscode" @@ -62,7 +63,7 @@ import { calculateApiCostAnthropic } from "../utils/cost" import { fileExistsAtPath } from "../utils/fs" import { arePathsEqual, getReadablePath } from "../utils/path" import { parseMentions } from "./mentions" -import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "./ignore/RooIgnoreController" +import { RooIgnoreController } from "./ignore/RooIgnoreController" import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message" import { formatResponse } from "./prompts/responses" import { SYSTEM_PROMPT } from "./prompts/system" @@ -72,9 +73,10 @@ import { detectCodeOmission } from "../integrations/editor/detect-omission" import { BrowserSession } from "../services/browser/BrowserSession" import { formatLanguage } from "../shared/language" import { McpHub } from "../services/mcp/McpHub" -import crypto from "crypto" +import { DiffStrategy, getDiffStrategy } from "./diff/DiffStrategy" import { insertGroups } from "./diff/insert-groups" import { telemetryService } from "../services/telemetry/TelemetryService" +import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution @@ -82,6 +84,15 @@ const cwd = type ToolResponse = string | Array type UserContent = Array +export type ClineEvents = { + message: [{ action: "created" | "updated"; message: ClineMessage }] + taskStarted: [] + taskPaused: [] + taskUnpaused: [] + taskAborted: [] + taskSpawned: [taskId: string] +} + export type ClineOptions = { provider: ClineProvider apiConfiguration: ApiConfiguration @@ -95,21 +106,22 @@ export type ClineOptions = { historyItem?: HistoryItem experiments?: Record startTask?: boolean + rootTask?: Cline + parentTask?: Cline + taskNumber?: number } -export class Cline { +export class Cline extends EventEmitter { 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) + + // Subtasks + readonly rootTask: Cline | undefined = undefined + readonly parentTask: Cline | undefined = undefined + readonly taskNumber: number 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 + private pauseInterval: NodeJS.Timeout | undefined + readonly apiConfiguration: ApiConfiguration api: ApiHandler private urlContentFetcher: UrlContentFetcher @@ -168,7 +180,12 @@ export class Cline { historyItem, experiments, startTask = true, + rootTask, + parentTask, + taskNumber, }: ClineOptions) { + super() + if (startTask && !task && !images && !historyItem) { throw new Error("Either historyItem or task/images must be provided") } @@ -192,6 +209,10 @@ export class Cline { this.enableCheckpoints = enableCheckpoints this.checkpointStorage = checkpointStorage + this.rootTask = rootTask + this.parentTask = parentTask + this.taskNumber = taskNumber ?? -1 + if (historyItem) { telemetryService.captureTaskRestarted(this.taskId) } else { @@ -231,46 +252,6 @@ 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, multiSearchReplaceDiffStrategy?: boolean) { // If not provided, get from current state @@ -283,6 +264,7 @@ export class Cline { multiSearchReplaceDiffStrategy = stateExperimental?.[EXPERIMENT_IDS.MULTI_SEARCH_AND_REPLACE] ?? false } } + this.diffStrategy = getDiffStrategy( this.api.getModel().id, this.fuzzyMatchThreshold, @@ -351,6 +333,8 @@ export class Cline { private async addToClineMessages(message: ClineMessage) { this.clineMessages.push(message) + await this.providerRef.deref()?.postStateToWebview() + this.emit("message", { action: "created", message }) await this.saveClineMessages() } @@ -359,6 +343,11 @@ export class Cline { await this.saveClineMessages() } + private async updateClineMessage(partialMessage: ClineMessage) { + await this.providerRef.deref()?.postMessageToWebview({ type: "partialMessage", partialMessage }) + this.emit("message", { action: "updated", message: partialMessage }) + } + private async saveClineMessages() { try { const taskDir = await this.ensureTaskDirectoryExists() @@ -411,7 +400,14 @@ export class Cline { partial?: boolean, progressStatus?: ToolProgressStatus, ): 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 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(`Task: ${this.taskNumber} Roo Code instance aborted (#1)`) } @@ -422,32 +418,28 @@ export class Cline { lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type if (partial) { if (isUpdatingPreviousPartial) { - // existing partial message, so update it + // Existing partial message, so update it. lastMessage.text = text lastMessage.partial = partial lastMessage.progressStatus = progressStatus - // todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener - // await this.saveClineMessages() - // await this.providerRef.deref()?.postStateToWebview() - await this.providerRef - .deref() - ?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) + // TODO: Be more efficient about saving and posting only new + // data or one whole message at a time so ignore partial for + // saves, and only post parts of partial message instead of + // whole array in new listener. + this.updateClineMessage(lastMessage) 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 - // this.askResponseText = undefined - // this.askResponseImages = undefined + // This is a new partial message, so add it with partial + // state. askTs = Date.now() 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)") } } else { - // partial=false means its a complete version of a previously partial message if (isUpdatingPreviousPartial) { - // this is the complete version of a previously partial message, so replace the partial with the complete version + // This is the complete version of a previously partial + // message, so replace the partial with the complete version. this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined @@ -464,39 +456,37 @@ export class Cline { lastMessage.text = text lastMessage.partial = false lastMessage.progressStatus = progressStatus - await this.saveClineMessages() - // await this.providerRef.deref()?.postStateToWebview() - await this.providerRef - .deref() - ?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) + this.updateClineMessage(lastMessage) } else { - // this is a new partial=false message, so add it like normal + // This is a new and complete message, so add it like normal. this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text }) - await this.providerRef.deref()?.postStateToWebview() } } } else { - // this is a new non-partial message, so add it like normal - // const lastMessage = this.clineMessages.at(-1) + // This is a new non-partial message, so add it like normal. this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text }) - await this.providerRef.deref()?.postStateToWebview() } await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + if (this.lastMessageTs !== askTs) { - throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully + // Could happen if we send multiple asks in a row i.e. with + // command_output. It's important that when we know an ask could + // fail, it is handled gracefully. + throw new Error("Current ask promise was ignored") } + const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages } this.askResponse = undefined this.askResponseText = undefined @@ -533,39 +523,34 @@ export class Cline { lastMessage.images = images lastMessage.partial = partial lastMessage.progressStatus = progressStatus - await this.providerRef - .deref() - ?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) + this.updateClineMessage(lastMessage) } else { // this is a new partial message, so add it with partial state const sayTs = Date.now() this.lastMessageTs = sayTs await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, partial }) - await this.providerRef.deref()?.postStateToWebview() } } else { - // partial=false means its a complete version of a previously partial message + // New now have a complete version of a previously partial message. if (isUpdatingPreviousPartial) { - // this is the complete version of a previously partial message, so replace the partial with the complete version + // This is the complete version of a previously partial + // message, so replace the partial with the complete version. this.lastMessageTs = lastMessage.ts // lastMessage.ts = sayTs lastMessage.text = text lastMessage.images = images lastMessage.partial = false lastMessage.progressStatus = progressStatus - - // instead of streaming partialMessage events, we do a save and post like normal to persist to disk + // Instead of streaming partialMessage events, we do a save + // and post like normal to persist to disk. await this.saveClineMessages() - // await this.providerRef.deref()?.postStateToWebview() - await this.providerRef - .deref() - ?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) // more performant than an entire postStateToWebview + // More performant than an entire postStateToWebview. + this.updateClineMessage(lastMessage) } else { - // this is a new partial=false message, so add it like normal + // This is a new and complete message, so add it like normal. const sayTs = Date.now() this.lastMessageTs = sayTs await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images }) - await this.providerRef.deref()?.postStateToWebview() } } } else { @@ -573,7 +558,6 @@ export class Cline { const sayTs = Date.now() this.lastMessageTs = sayTs await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, checkpoint }) - await this.providerRef.deref()?.postStateToWebview() } } @@ -612,6 +596,7 @@ export class Cline { async resumePausedTask(lastMessage?: string) { // release this Cline instance from paused state this.isPaused = false + this.emit("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 @@ -675,16 +660,6 @@ export class Cline { .slice() .reverse() .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // could be multiple resume tasks - // const lastClineMessage = this.clineMessages[lastClineMessageIndex] - // could be a completion result with a command - // const secondLastClineMessage = this.clineMessages - // .slice() - // .reverse() - // .find( - // (m, index) => - // index !== lastClineMessageIndex && !(m.ask === "resume_task" || m.ask === "resume_completed_task") - // ) - // (lastClineMessage?.ask === "command" && secondLastClineMessage?.ask === "completion_result") let askType: ClineAsk if (lastClineMessage?.ask === "completion_result") { @@ -876,6 +851,8 @@ export class Cline { let nextUserContent = userContent let includeFileDetails = true + this.emit("taskStarted") + while (!this.abort) { const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails) includeFileDetails = false // we only need file details the first time @@ -911,8 +888,15 @@ export class Cline { } this.abort = true + this.emit("taskAborted") + + // Stop waiting for child task completion. + if (this.pauseInterval) { + clearInterval(this.pauseInterval) + this.pauseInterval = undefined + } - // Release any terminals associated with this task + // Release any terminals associated with this task. TerminalRegistry.releaseTerminalsForTask(this.taskId) this.urlContentFetcher.closeBrowser() @@ -2877,37 +2861,44 @@ export class Cline { break } - // Show what we're about to do const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, content: message, }) - const didApprove = await askApproval("tool", toolMessage) + if (!didApprove) { 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 + const provider = this.providerRef.deref() + + if (!provider) { + break + } + + // Preserve the current mode so we can resume with it later. + this.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug + + // Switch mode first, then create new task instance. + await provider.handleModeSwitch(mode) + + // Delay to allow mode change to take effect before next tool is executed. + await delay(500) + + const newCline = await provider.initClineWithTask(message, undefined, this) + this.emit("taskSpawned", newCline.taskId) - // Switch mode first, then create new task instance - 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 + + // Set the isPaused flag to true so the parent + // task can wait for the sub-task to finish. this.isPaused = true + this.emit("taskPaused") + break } } catch (error) { @@ -3016,8 +3007,9 @@ export class Cline { telemetryService.captureTaskCompleted(this.taskId) } - if (this.isSubTask) { + if (this.parentTask) { const didApprove = await askFinishSubTaskApproval() + if (!didApprove) { break } @@ -3100,17 +3092,19 @@ 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 + // 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. async waitForResume() { - // wait until isPaused is false await new Promise((resolve) => { - const interval = setInterval(() => { + this.pauseInterval = setInterval(() => { if (!this.isPaused) { - clearInterval(interval) + clearInterval(this.pauseInterval) + this.pauseInterval = undefined resolve() } - }, 1000) // TBD: the 1 sec should be added to the settings, also should add a timeout to prevent infinit wait + }, 1000) }) } @@ -3143,32 +3137,37 @@ export class Cline { this.consecutiveMistakeCount = 0 } - // get previous api req's index to check token usage and determine if we need to truncate conversation history + // 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`) + // In this Cline request loop, we need to check if this task instance + // has been asked to wait for a subtask to finish before continuing. + const provider = this.providerRef.deref() + + if (this.isPaused && provider) { + provider.log(`[subtasks] paused ${this.taskId}`) 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 + provider.log(`[subtasks] resumed ${this.taskId}`) + const currentMode = (await provider.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}'`, - ) + // The mode has changed, we need to switch back to the paused mode. + await provider.handleModeSwitch(this.pausedModeSlug) + + // Delay to allow mode change to take effect before next tool is executed. + await delay(500) + + provider.log( + `[subtasks] task ${this.taskId} has switched back to '${this.pausedModeSlug}' from '${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 + // 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( "api_req_started", JSON.stringify({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 70b21f87bb4..7064bf000a0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -9,9 +9,8 @@ import * as vscode from "vscode" import { setPanel } from "../../activate/registerCommands" import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api" -import { CheckpointStorage } from "../../shared/checkpoints" import { findLast } from "../../shared/array" -import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt" +import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" import { SecretKey, @@ -23,7 +22,7 @@ import { import { HistoryItem } from "../../shared/HistoryItem" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" -import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes" +import { Mode, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes" import { checkExistKey } from "../../shared/checkExistApiConfig" import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" @@ -82,7 +81,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { private contextProxy: ContextProxy configManager: ConfigManager customModesManager: CustomModesManager - private lastTaskNumber = -1 constructor( readonly context: vscode.ExtensionContext, @@ -115,82 +113,40 @@ 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. 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 - } + // Add this cline instance into the stack that represents the order of all the called tasks. + this.clineStack.push(cline) - 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]) - } + // Ensure getState() resolves correctly. + const state = await this.getState() - // 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 + if (!state || typeof state.mode !== "string") { + throw new Error("Error failed to retrieve current mode from state.") } } - // Removes and destroys the top Cline instance (the current finished task), activating the previous one (resuming the parent task). + // 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}`) - } + if (this.clineStack.length === 0) { + return + } - // make sure no reference kept, once promises end it will be garbage collected - clineToBeRemoved = undefined - this.log(`[subtasks] Task: ${removedTaskNumber} stopped`) - } + // Pop the top Cline instance from the stack. + var clineToBeRemoved = this.clineStack.pop() - // if the stack is empty, reset the last task number - if (this.clineStack.length === 0) { - this.lastTaskNumber = -1 - } + if (clineToBeRemoved) { + try { + // Abort the running task and set isAbandoned to true so + // all running promises will exit as well. + await clineToBeRemoved.abortTask(true) + } catch (e) { + this.log(`[subtasks] encountered error while aborting task ${clineToBeRemoved.taskId}: ${e.message}`) } - } catch (error) { - this.log(`Error in removeClineFromStack: ${error.message}`) - throw error + + // Make sure no reference kept, once promises end it will be + // garbage collected. + this.log(`[subtasks] task ${clineToBeRemoved.taskId} stopped`) + clineToBeRemoved = undefined } } @@ -205,7 +161,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { const index = this.clineStack.findIndex((c) => c.taskId === clineId) if (index === -1) { - this.log(`[subtasks] No task found with ID: ${clineId}`) + this.log(`[subtasks] no task found with id ${clineId}`) return } @@ -213,11 +169,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { try { await this.removeClineFromStack() } catch (removalError) { - this.log(`Error removing task at stack index ${i}: ${removalError.message}`) + this.log(`[subtasks] error removing task at stack index ${i}: ${removalError.message}`) } } } catch (error) { - this.log(`Error in removeClineWithIdFromStack: ${error.message}`) + this.log(`[subtasks] unexpected error in removeClineWithIdFromStack: ${error.message}`) throw error } } @@ -260,16 +216,20 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.outputChannel.appendLine("Disposing ClineProvider...") await this.removeClineFromStack() this.outputChannel.appendLine("Cleared task") + if (this.view && "dispose" in this.view) { this.view.dispose() this.outputChannel.appendLine("Disposed webview") } + while (this.disposables.length) { const x = this.disposables.pop() + if (x) { x.dispose() } } + this.workspaceTracker?.dispose() this.workspaceTracker = undefined this.mcpHub?.dispose() @@ -325,6 +285,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { params: Record, ): Promise { const visibleProvider = await ClineProvider.getInstance() + if (!visibleProvider) { return } @@ -344,12 +305,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { } if (visibleProvider.getCurrentCline() && command.endsWith("InCurrentTask")) { - await visibleProvider.postMessageToWebview({ - type: "invoke", - invoke: "sendMessage", - text: prompt, - }) - + await visibleProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text: prompt }) return } @@ -481,22 +437,20 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.disposables, ) - // if the extension is starting a new session, clear previous task state + // If the extension is starting a new session, clear previous task state. 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() + public async initClineWithSubTask(parent: Cline, task?: string, images?: string[]) { + return this.initClineWithTask(task, images, parent) } // 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[]) { + public async initClineWithTask(task?: string, images?: string[], parentTask?: Cline) { const { apiConfiguration, customModePrompts, @@ -512,7 +466,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { const modePrompt = customModePrompts?.[mode] as PromptComponent const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n") - const newCline = new Cline({ + const cline = new Cline({ provider: this, apiConfiguration, customInstructions: effectiveInstructions, @@ -523,11 +477,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { task, images, experiments, + rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined, + parentTask, + taskNumber: this.clineStack.length + 1, }) - await this.addClineToStack(newCline) + + await this.addClineToStack(cline) + this.log(`[subtasks] ${cline.parentTask ? "child" : "parent"} task ${cline.taskId} started`) + return cline } - public async initClineWithHistoryItem(historyItem: HistoryItem) { + public async initClineWithHistoryItem(historyItem: HistoryItem & { rootTask?: Cline; parentTask?: Cline }) { await this.removeClineFromStack() const { @@ -571,7 +531,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } - const newCline = new Cline({ + const cline = new Cline({ provider: this, apiConfiguration, customInstructions: effectiveInstructions, @@ -580,10 +540,14 @@ export class ClineProvider implements vscode.WebviewViewProvider { fuzzyMatchThreshold, historyItem, experiments, + rootTask: historyItem.rootTask, + parentTask: historyItem.parentTask, + taskNumber: historyItem.number, }) - newCline.setTaskNumber(historyItem.number) - await this.addClineToStack(newCline) + await this.addClineToStack(cline) + this.log(`[subtasks] ${cline.parentTask ? "child" : "parent"} task ${cline.taskId} started`) + return cline } public async postMessageToWebview(message: ExtensionMessage) { @@ -2039,64 +2003,56 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async cancelTask() { - if (this.getCurrentCline()) { - const currentCline = this.getCurrentCline()! - const { historyItem } = await this.getTaskWithId(currentCline.taskId) - - // Store parent task information if this is a subtask - // Check if this is a subtask by seeing if it has a parent task - const parentTask = currentCline.getParentTask() - const isSubTask = parentTask !== undefined - const rootTask = isSubTask ? currentCline.getRootTask() : undefined - - currentCline.abortTask() - - await pWaitFor( - () => - 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.getCurrentCline()!.isWaitingForFirstChunk, - { - timeout: 3_000, - }, - ).catch(() => { - console.error("Failed to abort task") - }) + const currentCline = this.getCurrentCline() - 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.getCurrentCline()!.abandoned = true - } + if (!currentCline) { + return + } - // Clears task again, so we need to abortTask manually above. - await this.initClineWithHistoryItem(historyItem) + console.log(`[subtasks] cancelling task ${currentCline.taskId}`) + + const { historyItem } = await this.getTaskWithId(currentCline.taskId) + // Preserve parent and root task information for history item. + const rootTask = currentCline.rootTask + const parentTask = currentCline.parentTask + + currentCline.abortTask() + + await pWaitFor( + () => + 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.getCurrentCline()!.isWaitingForFirstChunk, + { + timeout: 3_000, + }, + ).catch(() => { + console.error("Failed to abort task") + }) - // Restore parent-child relationship if this was a subtask - if (isSubTask && this.getCurrentCline() && parentTask) { - this.getCurrentCline()!.setSubTask() - this.getCurrentCline()!.setParentTask(parentTask) - if (rootTask) { - this.getCurrentCline()!.setRootTask(rootTask) - } - this.log( - `[subtasks] Restored parent-child relationship for task: ${this.getCurrentCline()!.getTaskNumber()}`, - ) - } + 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.getCurrentCline()!.abandoned = true } + + // Clears task again, so we need to abortTask manually above. + await this.initClineWithHistoryItem({ ...historyItem, rootTask, parentTask }) } async updateCustomInstructions(instructions?: string) { - // User may be clearing the field + // User may be clearing the field. await this.updateGlobalState("customInstructions", instructions || undefined) + if (this.getCurrentCline()) { this.getCurrentCline()!.customInstructions = instructions || undefined } + await this.postStateToWebview() } @@ -2248,10 +2204,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { async showTaskWithId(id: string) { if (id !== this.getCurrentCline()?.taskId) { - // non-current task + // Non-current task. const { historyItem } = await this.getTaskWithId(id) - await this.initClineWithHistoryItem(historyItem) // clears existing task + await this.initClineWithHistoryItem(historyItem) // Clears existing task. } + await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index d2afd5fcdc9..abe7a8475af 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -794,7 +794,7 @@ describe("ClineProvider", () => { expect(state.customModePrompts).toEqual({}) }) - test("uses mode-specific custom instructions in Cline initialization", async () => { + test.only("uses mode-specific custom instructions in Cline initialization", async () => { // Setup mock state const modeCustomInstructions = "Code mode instructions" const mockApiConfig = { @@ -833,6 +833,9 @@ describe("ClineProvider", () => { fuzzyMatchThreshold: 1.0, task: "Test task", experiments: experimentDefault, + rootTask: undefined, + parentTask: undefined, + taskNumber: 1, }) }) diff --git a/src/exports/api.ts b/src/exports/api.ts new file mode 100644 index 00000000000..b66d50aa2db --- /dev/null +++ b/src/exports/api.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from "events" +import * as vscode from "vscode" + +import { ClineProvider } from "../core/webview/ClineProvider" + +import { RooCodeAPI, RooCodeEvents, ConfigurationValues } from "./roo-code" +import { MessageHistory } from "./message-history" + +export class API extends EventEmitter implements RooCodeAPI { + private readonly outputChannel: vscode.OutputChannel + private readonly provider: ClineProvider + private readonly history: MessageHistory + + constructor(outputChannel: vscode.OutputChannel, provider: ClineProvider) { + super() + + this.outputChannel = outputChannel + this.provider = provider + this.history = new MessageHistory() + + this.on("message", ({ taskId, action, message }) => { + // if (message.type === "say") { + // console.log("message", { taskId, action, message }) + // } + + if (action === "created") { + this.history.add(taskId, message) + } else if (action === "updated") { + this.history.update(taskId, message) + } + }) + } + + public async startNewTask(text?: string, images?: string[]) { + await this.provider.removeClineFromStack() + await this.provider.postStateToWebview() + await this.provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await this.provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images }) + + const cline = await this.provider.initClineWithTask(text, images) + cline.on("message", (message) => this.emit("message", { taskId: cline.taskId, ...message })) + cline.on("taskSpawned", (taskId) => this.emit("taskSpawned", taskId)) + + return cline.taskId + } + + public async cancelCurrentTask() { + await this.provider.cancelTask() + } + + public async sendMessage(text?: string, images?: string[]) { + await this.provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) + } + + public async pressPrimaryButton() { + await this.provider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" }) + } + + public async pressSecondaryButton() { + await this.provider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" }) + } + + public async setConfiguration(values: Partial) { + await this.provider.setValues(values) + } + + public isReady() { + return this.provider.viewLaunched + } + + public getMessages(taskId: string) { + return this.history.getMessages(taskId) + } +} diff --git a/src/exports/message-history.ts b/src/exports/message-history.ts new file mode 100644 index 00000000000..f17e044f8d9 --- /dev/null +++ b/src/exports/message-history.ts @@ -0,0 +1,35 @@ +import { ClineMessage } from "./roo-code" + +export class MessageHistory { + private readonly messages: Record> + private readonly list: Record + + constructor() { + this.messages = {} + this.list = {} + } + + public add(taskId: string, message: ClineMessage) { + if (!this.messages[taskId]) { + this.messages[taskId] = {} + } + + this.messages[taskId][message.ts] = message + + if (!this.list[taskId]) { + this.list[taskId] = [] + } + + this.list[taskId].push(message.ts) + } + + public update(taskId: string, message: ClineMessage) { + if (this.messages[taskId][message.ts]) { + this.messages[taskId][message.ts] = message + } + } + + public getMessages(taskId: string) { + return (this.list[taskId] ?? []).map((ts) => this.messages[taskId][ts]).filter(Boolean) + } +} diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index ed19fc59ea5..0007c106d84 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -1,15 +1,23 @@ -export interface RooCodeAPI { +import { EventEmitter } from "events" + +export interface RooCodeEvents { + message: [{ taskId: string; action: "created" | "updated"; message: ClineMessage }] + taskSpawned: [taskId: string] +} + +export interface RooCodeAPI extends EventEmitter { /** * Starts a new task with an optional initial message and images. * @param task Optional initial task message. * @param images Optional array of image data URIs (e.g., "data:image/webp;base64,..."). + * @returns The ID of the new task. */ - startNewTask(task?: string, images?: string[]): Promise + startNewTask(task?: string, images?: string[]): Promise /** * Cancels the current task. */ - cancelTask(): Promise + cancelCurrentTask(): Promise /** * Sends a message to the current task. @@ -40,9 +48,11 @@ export interface RooCodeAPI { isReady(): boolean /** - * Returns the messages from the current task. + * Returns the messages for a given task. + * @param taskId The ID of the task. + * @returns An array of ClineMessage objects. */ - getMessages(): ClineMessage[] + getMessages(taskId: string): ClineMessage[] } export type ClineAsk = diff --git a/src/extension.ts b/src/extension.ts index 60ae65c9a43..39f7e4ab05f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,8 +19,9 @@ import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { McpServerManager } from "./services/mcp/McpServerManager" import { telemetryService } from "./services/telemetry/TelemetryService" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" +import { API } from "./exports/api" -import { handleUri, registerCommands, registerCodeActions, createRooCodeAPI, registerTerminalActions } from "./activate" +import { handleUri, registerCommands, registerCodeActions, registerTerminalActions } from "./activate" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -41,9 +42,10 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(outputChannel) outputChannel.appendLine("Roo-Code extension activated") - // Initialize telemetry service after environment variables are loaded + // Initialize telemetry service after environment variables are loaded. telemetryService.initialize() - // Initialize terminal shell execution handlers + + // Initialize terminal shell execution handlers. TerminalRegistry.initialize() // Get default commands from configuration. @@ -53,16 +55,17 @@ export function activate(context: vscode.ExtensionContext) { if (!context.globalState.get("allowedCommands")) { context.globalState.update("allowedCommands", defaultCommands) } - const sidebarProvider = new ClineProvider(context, outputChannel) - telemetryService.setProvider(sidebarProvider) + + const provider = new ClineProvider(context, outputChannel) + telemetryService.setProvider(provider) context.subscriptions.push( - vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, { + vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, provider, { webviewOptions: { retainContextWhenHidden: true }, }), ) - registerCommands({ context, outputChannel, provider: sidebarProvider }) + registerCommands({ context, outputChannel, provider }) /** * We use the text document content provider API to show the left side for diff @@ -102,7 +105,8 @@ export function activate(context: vscode.ExtensionContext) { registerCodeActions(context) registerTerminalActions(context) - return createRooCodeAPI(outputChannel, sidebarProvider) + // Implements the `RooCodeAPI` interface. + return new API(outputChannel, provider) } // This method is called when your extension is deactivated diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 7a4152f2391..c0bcb4ed65a 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -62,7 +62,7 @@ export interface ExtensionMessage { | "historyButtonClicked" | "promptsButtonClicked" | "didBecomeVisible" - invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" + invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" state?: ExtensionState images?: string[] ollamaModels?: string[] @@ -88,6 +88,8 @@ export interface ExtensionMessage { slug?: string success?: boolean values?: Record + requestId?: string + promptText?: string } export interface ApiConfigMeta { @@ -213,23 +215,6 @@ export interface ClineApiReqInfo { streamingFailedMessage?: string } -export interface ShowHumanRelayDialogMessage { - type: "showHumanRelayDialog" - requestId: string - promptText: string -} - -export interface HumanRelayResponseMessage { - type: "humanRelayResponse" - requestId: string - text: string -} - -export interface HumanRelayCancelMessage { - type: "humanRelayCancel" - requestId: string -} - export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" export type ToolProgressStatus = { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4dc7417c823..e9a64d891b3 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -132,18 +132,6 @@ export interface WebviewMessage { requestId?: string } -// Human relay related message types -export interface HumanRelayResponseMessage extends WebviewMessage { - type: "humanRelayResponse" - requestId: string - text: string -} - -export interface HumanRelayCancelMessage extends WebviewMessage { - type: "humanRelayCancel" - requestId: string -} - export const checkoutDiffPayloadSchema = z.object({ ts: z.number(), previousCommitHash: z.string().optional(), diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 9ce93b9a0eb..4276d404cd2 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react" import { useEvent } from "react-use" import { ExtensionMessage } from "../../src/shared/ExtensionMessage" -import { ShowHumanRelayDialogMessage } from "../../src/shared/ExtensionMessage" import TranslationProvider from "./i18n/TranslationContext" import { vscode } from "./utils/vscode" @@ -64,14 +63,10 @@ const App = () => { switchTab(newTab) } } - const mes: ShowHumanRelayDialogMessage = message as ShowHumanRelayDialogMessage - // Processing displays human relay dialog messages - if (mes.type === "showHumanRelayDialog" && mes.requestId && mes.promptText) { - setHumanRelayDialogState({ - isOpen: true, - requestId: mes.requestId, - promptText: mes.promptText, - }) + + if (message.type === "showHumanRelayDialog" && message.requestId && message.promptText) { + const { requestId, promptText } = message + setHumanRelayDialogState({ isOpen: true, requestId, promptText }) } }, [switchTab], @@ -93,9 +88,7 @@ const App = () => { }, [telemetrySetting, telemetryKey, machineId, didHydrateState]) // Tell the extension that we are ready to receive messages. - useEffect(() => { - vscode.postMessage({ type: "webviewDidLaunch" }) - }, []) + useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), []) if (!didHydrateState) { return null diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 46958e738d4..10e310d4419 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -308,6 +308,19 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie return false }, [modifiedMessages, clineAsk, enableButtons, primaryButtonText]) + const handleChatReset = useCallback(() => { + // Only reset message-specific state, preserving mode. + setInputValue("") + setTextAreaDisabled(true) + setSelectedImages([]) + setClineAsk(undefined) + setEnableButtons(false) + // Do not reset mode here as it should persist. + // setPrimaryButtonText(undefined) + // setSecondaryButtonText(undefined) + disableAutoScrollRef.current = false + }, []) + const handleSendMessage = useCallback( (text: string, images: string[]) => { text = text.trim() @@ -319,36 +332,22 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "followup": case "tool": case "browser_action_launch": - case "command": // user can provide feedback to a tool or command use - case "command_output": // user can send input to command stdin + case "command": // User can provide feedback to a tool or command use. + case "command_output": // User can send input to command stdin. case "use_mcp_server": - case "completion_result": // if this happens then the user has feedback for the completion result + case "completion_result": // If this happens then the user has feedback for the completion result. case "resume_task": case "resume_completed_task": case "mistake_limit_reached": - vscode.postMessage({ - type: "askResponse", - askResponse: "messageResponse", - text, - images, - }) + vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) break - // there is no other case that a textfield should be enabled + // There is no other case that a textfield should be enabled. } } - // Only reset message-specific state, preserving mode - setInputValue("") - setTextAreaDisabled(true) - setSelectedImages([]) - setClineAsk(undefined) - setEnableButtons(false) - // Do not reset mode here as it should persist - // setPrimaryButtonText(undefined) - // setSecondaryButtonText(undefined) - disableAutoScrollRef.current = false + handleChatReset() } }, - [messages.length, clineAsk], + [messages.length, clineAsk, handleChatReset], ) const handleSetChatBoxMessage = useCallback( @@ -501,6 +500,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie break case "invoke": switch (message.invoke!) { + case "newChat": + handleChatReset() + break case "sendMessage": handleSendMessage(message.text ?? "", message.images ?? []) break @@ -521,6 +523,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie isHidden, textAreaDisabled, enableButtons, + handleChatReset, handleSendMessage, handleSetChatBoxMessage, handlePrimaryButtonClick,