Add Cline events and roofactor subtasks slightly

This commit is contained in:
cte 2025-03-12 22:52:37 -07:00
parent 705b3ba11f
commit 4b23644683
19 changed files with 540 additions and 594 deletions

View file

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

View file

@ -8,55 +8,39 @@ import { RooCodeAPI } from "../../../src/exports/roo-code"
import { waitUntilReady } from "./utils"
declare global {
var extension: vscode.Extension<RooCodeAPI> | undefined
var api: RooCodeAPI
}
export async function run() {
const mocha = new Mocha({ ui: "tdd", timeout: 300_000 })
const testsRoot = path.resolve(__dirname, "..")
const extension = vscode.extensions.getExtension<RooCodeAPI>("RooVeterinaryInc.roo-cline")
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<RooCodeAPI>("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<void>((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
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<void>((resolve, reject) =>
mocha.run((failures) => (failures === 0 ? resolve() : reject(new Error(`${failures} tests failed.`)))),
)
}

View file

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

View file

@ -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")
const anotherTaskId = await api.startNewTask(childPrompt)
await waitForMessage({ taskId: anotherTaskId, api, include: "3" })
// Wait for the subtask to complete.
await waitForMessage(api, { include: "Task complete" })
// 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()
})
})

View file

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

View file

@ -41,36 +41,51 @@ export const waitFor = (
])
}
export const waitUntilReady = async (api: RooCodeAPI, { timeout = 10_000, interval = 250 }: WaitForOptions = {}) => {
await vscode.commands.executeCommand("roo-cline.SidebarProvider.focus")
await waitFor(api.isReady, { timeout, interval })
type WaitUntilReadyOptions = WaitForOptions & {
api: RooCodeAPI
}
export const waitForToolUse = async (api: RooCodeAPI, toolName: string, options: WaitForOptions = {}) =>
export const waitUntilReady = async ({ api, ...options }: WaitUntilReadyOptions) => {
await vscode.commands.executeCommand("roo-cline.SidebarProvider.focus")
await waitFor(() => api.isReady(), options)
}
type WaitForToolUseOptions = WaitUntilReadyOptions & {
taskId: string
toolName: string
}
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))

View file

@ -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<ConfigurationValues>) => {
await provider.setValues(values)
},
isReady: () => provider.viewLaunched,
getMessages: () => provider.messages,
}
}

View file

@ -1,5 +1,4 @@
export { handleUri } from "./handleUri"
export { registerCommands } from "./registerCommands"
export { registerCodeActions } from "./registerCodeActions"
export { createRooCodeAPI } from "./createRooCodeAPI"
export { registerTerminalActions } from "./registerTerminalActions"

View file

@ -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<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.Messages.ContentBlockParam>
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<string, boolean>
startTask?: boolean
rootTask?: Cline
parentTask?: Cline
taskNumber?: number
}
export class Cline {
export class Cline extends EventEmitter<ClineEvents> {
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")
// Release any terminals associated with this task
// Stop waiting for child task completion.
if (this.pauseInterval) {
clearInterval(this.pauseInterval)
this.pauseInterval = undefined
}
// 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<void>((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({

View file

@ -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.")
}
// Add this cline instance into the stack that represents the order of all the called tasks.
this.clineStack.push(cline)
// Ensure lastTaskNumber is a valid number
if (typeof this.lastTaskNumber !== "number") {
this.lastTaskNumber = -1
}
// Ensure getState() resolves correctly.
const state = await this.getState()
const taskNumber = cline.getTaskNumber()
if (taskNumber === -1) {
this.lastTaskNumber += 1
cline.setTaskNumber(this.lastTaskNumber)
} else if (taskNumber > this.lastTaskNumber) {
this.lastTaskNumber = taskNumber
}
// set this cline task parent cline (the task that launched it), and the root cline (the top most task that eventually launched it)
if (this.clineStack.length >= 1) {
cline.setParentTask(this.getCurrentCline())
cline.setRootTask(this.clineStack[0])
}
// add this cline instance into the stack that represents the order of all the called tasks
this.clineStack.push(cline)
// Ensure getState() resolves correctly
const state = await this.getState()
if (!state || typeof state.mode !== "string") {
throw new Error("Error failed to retrieve current mode from state.")
}
this.log(`[subtasks] Task: ${cline.getTaskNumber()} started at '${state.mode}' mode`)
} catch (error) {
this.log(`Error in addClineToStack: ${error.message}`)
throw error
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) {
return
}
// Pop the top Cline instance from the stack.
var clineToBeRemoved = this.clineStack.pop()
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}`)
}
if (this.clineStack.length === 0) {
this.log("[subtasks] No active tasks to remove.")
} else {
// pop the top Cline instance from the stack
var clineToBeRemoved = this.clineStack.pop()
if (clineToBeRemoved) {
const removedTaskNumber = clineToBeRemoved.getTaskNumber()
try {
// abort the running task and set isAbandoned to true so all running promises will exit as well
await clineToBeRemoved.abortTask(true)
} catch (abortError) {
this.log(`Error failed aborting task ${removedTaskNumber}: ${abortError.message}`)
}
// make sure no reference kept, once promises end it will be garbage collected
clineToBeRemoved = undefined
this.log(`[subtasks] Task: ${removedTaskNumber} stopped`)
}
// if the stack is empty, reset the last task number
if (this.clineStack.length === 0) {
this.lastTaskNumber = -1
}
}
} catch (error) {
this.log(`Error in removeClineFromStack: ${error.message}`)
throw error
// 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<string, string | any[]>,
): Promise<void> {
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)
const currentCline = this.getCurrentCline()
// 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")
})
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)
// 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 (!currentCline) {
return
}
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")
})
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" })
}

View file

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

74
src/exports/api.ts Normal file
View file

@ -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<RooCodeEvents> 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<ConfigurationValues>) {
await this.provider.setValues(values)
}
public isReady() {
return this.provider.viewLaunched
}
public getMessages(taskId: string) {
return this.history.getMessages(taskId)
}
}

View file

@ -0,0 +1,35 @@
import { ClineMessage } from "./roo-code"
export class MessageHistory {
private readonly messages: Record<string, Record<number, ClineMessage>>
private readonly list: Record<string, number[]>
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)
}
}

View file

@ -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<RooCodeEvents> {
/**
* 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<void>
startNewTask(task?: string, images?: string[]): Promise<string>
/**
* Cancels the current task.
*/
cancelTask(): Promise<void>
cancelCurrentTask(): Promise<void>
/**
* 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 =

View file

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

View file

@ -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<string, any>
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 = {

View file

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

View file

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

View file

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