Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 28 additions & 18 deletions e2e/src/suite/modes.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,44 @@
import * as assert from "assert"

import { waitForMessage, getMessage } from "./utils"
import { getCompletion, getMessage, sleep, waitForCompletion, waitUntilAborted } from "./utils"

suite("Roo Code Modes", () => {
test("Should handle switching modes correctly", async function () {
const api = globalThis.api

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."
/**
* Switch modes.
*/

const switchModesPrompt =
"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."

await api.setConfiguration({ mode: "Code", alwaysAllowModeSwitch: true, autoApprovalEnabled: true })
let taskId = await api.startNewTask(prompt)
await waitForMessage({ api, taskId, include: "I AM DONE", exclude: "be sure to say", timeout: 300_000 })
const switchModesTaskId = await api.startNewTask(switchModesPrompt)
await waitForCompletion({ api, taskId: switchModesTaskId, timeout: 60_000 })

// 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.`
/**
* Grade the response.
*/

await api.setConfiguration({ mode: "Ask" })
taskId = await api.startNewTask(prompt)
await waitForMessage({ api, taskId, include: "I AM DONE GRADING", exclude: "be sure to say" })
const gradePrompt =
`Given this prompt: ${switchModesPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ` +
api
.getMessages(switchModesTaskId)
.filter(({ type }) => type === "say")
.map(({ text }) => text ?? "")
.join("\n")

const match = getMessage({ api, taskId, include: "Grade:", exclude: "Grade: (1-10)" })?.text?.match(
/Grade: (\d+)/,
)
await api.setConfiguration({ mode: "Ask" })
const gradeTaskId = await api.startNewTask(gradePrompt)
await waitForCompletion({ api, taskId: gradeTaskId, timeout: 60_000 })

const completion = getCompletion({ api, taskId: gradeTaskId })
const match = completion?.text?.match(/Grade: (\d+)/)
const score = parseInt(match?.[1] ?? "0")
assert.ok(score >= 7 && score <= 10, "Grade must be between 7 and 10.")
assert.ok(score >= 7 && score <= 10, `Grade must be between 7 and 10 - ${completion?.text}`)

await api.cancelCurrentTask()
})
})
17 changes: 9 additions & 8 deletions e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as assert from "assert"

import { sleep, waitForMessage, waitFor, getMessage } from "./utils"
import { sleep, waitFor, getMessage, waitForCompletion } from "./utils"

suite("Roo Code Subtasks", () => {
test("Should handle subtask cancellation and resumption correctly", async function () {
Expand All @@ -23,16 +23,16 @@ suite("Roo Code Subtasks", () => {
"After creating the subtask, wait for it to complete and then respond 'Parent task resumed'.",
)

let subTaskId: string | undefined = undefined
let spawnedTaskId: string | undefined = undefined

// Wait for the subtask to be spawned and then cancel it.
api.on("taskSpawned", (taskId) => (subTaskId = taskId))
await waitFor(() => !!subTaskId)
api.on("taskSpawned", (_, childTaskId) => (spawnedTaskId = childTaskId))
await waitFor(() => !!spawnedTaskId)
await sleep(2_000) // Give the task a chance to start and populate the history.
await api.cancelCurrentTask()

// Wait a bit to ensure any task resumption would have happened.
await sleep(5_000)
await sleep(2_000)

// The parent task should not have resumed yet, so we shouldn't see
// "Parent task resumed".
Expand All @@ -48,10 +48,10 @@ suite("Roo Code Subtasks", () => {

// Start a new task with the same message as the subtask.
const anotherTaskId = await api.startNewTask(childPrompt)
await waitForMessage({ taskId: anotherTaskId, api, include: "3" })
await waitForCompletion({ api, taskId: anotherTaskId })

// Wait a bit to ensure any task resumption would have happened.
await sleep(5_000)
await sleep(2_000)

// The parent task should still not have resumed.
assert.ok(
Expand All @@ -65,6 +65,7 @@ suite("Roo Code Subtasks", () => {
)

// Clean up - cancel all tasks.
await api.cancelCurrentTask()
await api.clearCurrentTask()
await waitForCompletion({ api, taskId: parentTaskId })
})
})
28 changes: 18 additions & 10 deletions e2e/src/suite/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,27 @@ export const waitUntilReady = async ({ api, ...options }: WaitUntilReadyOptions)
await waitFor(() => api.isReady(), options)
}

type WaitForToolUseOptions = WaitUntilReadyOptions & {
type WaitUntilAbortedOptions = WaitForOptions & {
api: RooCodeAPI
taskId: string
toolName: string
}

export const waitForToolUse = async ({ api, taskId, toolName, ...options }: WaitForToolUseOptions) =>
waitFor(
() =>
api
.getMessages(taskId)
.some(({ type, say, text }) => type === "say" && say === "tool" && text && text.includes(toolName)),
options,
)
export const waitUntilAborted = async ({ api, taskId, ...options }: WaitUntilAbortedOptions) => {
const set = new Set<string>()
api.on("taskAborted", (taskId) => set.add(taskId))
await waitFor(() => set.has(taskId), options)
}

export const waitForCompletion = async ({
api,
taskId,
...options
}: WaitUntilReadyOptions & {
taskId: string
}) => waitFor(() => !!getCompletion({ api, taskId }), options)

export const getCompletion = ({ api, taskId }: { api: RooCodeAPI; taskId: string }) =>
api.getMessages(taskId).find(({ say, partial }) => say === "completion_result" && partial === false)

type WaitForMessageOptions = WaitUntilReadyOptions & {
taskId: string
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 39 additions & 23 deletions src/core/Cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export type ClineOptions = {

export class Cline extends EventEmitter<ClineEvents> {
readonly taskId: string
readonly instanceId: string

// Subtasks
readonly rootTask: Cline | undefined = undefined
Expand Down Expand Up @@ -196,6 +197,7 @@ export class Cline extends EventEmitter<ClineEvents> {
})

this.taskId = historyItem ? historyItem.id : crypto.randomUUID()
this.instanceId = crypto.randomUUID().slice(0, 8)
this.taskNumber = -1
this.apiConfiguration = apiConfiguration
this.api = buildApiHandler(apiConfiguration)
Expand Down Expand Up @@ -409,9 +411,11 @@ export class Cline extends EventEmitter<ClineEvents> {
// 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)`)
throw new Error(`[Cline#ask] task ${this.taskId}.${this.instanceId} aborted`)
}

let askTs: number

if (partial !== undefined) {
const lastMessage = this.clineMessages.at(-1)
const isUpdatingPreviousPartial =
Expand Down Expand Up @@ -509,7 +513,7 @@ export class Cline extends EventEmitter<ClineEvents> {
progressStatus?: ToolProgressStatus,
): Promise<undefined> {
if (this.abort) {
throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#2)`)
throw new Error(`[Cline#say] task ${this.taskId}.${this.instanceId} aborted`)
}

if (partial !== undefined) {
Expand Down Expand Up @@ -584,6 +588,9 @@ export class Cline extends EventEmitter<ClineEvents> {
this.isInitialized = true

let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)

console.log(`[subtasks] task ${this.taskId}.${this.instanceId} starting`)

await this.initiateTaskLoop([
{
type: "text",
Expand Down Expand Up @@ -841,6 +848,9 @@ export class Cline extends EventEmitter<ClineEvents> {
}

await this.overwriteApiConversationHistory(modifiedApiConversationHistory)

console.log(`[subtasks] task ${this.taskId}.${this.instanceId} resuming from history item`)

await this.initiateTaskLoop(newUserContent)
}

Expand All @@ -857,31 +867,36 @@ export class Cline extends EventEmitter<ClineEvents> {
const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails)
includeFileDetails = false // we only need file details the first time

// The way this agentic loop works is that cline will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task.
// There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Cline is prompted to finish the task as efficiently as he can.
// The way this agentic loop works is that cline will be given a
// task that he then calls tools to complete. Unless there's an
// attempt_completion call, we keep responding back to him with his
// tool's responses until he either attempt_completion or does not
// use anymore tools. If he does not use anymore tools, we ask him
// to consider if he's completed the task and then call
// attempt_completion, otherwise proceed with completing the task.
// There is a MAX_REQUESTS_PER_TASK limit to prevent infinite
// requests, but Cline is prompted to finish the task as efficiently
// as he can.

//const totalCost = this.calculateApiCostAnthropic(totalInputTokens, totalOutputTokens)
if (didEndLoop) {
// For now a task never 'completes'. This will only happen if the user hits max requests and denies resetting the count.
//this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
// For now a task never 'completes'. This will only happen if
// the user hits max requests and denies resetting the count.
break
} else {
// this.say(
// "tool",
// "Cline responded with only text blocks but has not called attempt_completion yet. Forcing him to continue with task..."
// )
nextUserContent = [
{
type: "text",
text: formatResponse.noToolsUsed(),
},
]
nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed() }]
this.consecutiveMistakeCount++
}
}
}

async abortTask(isAbandoned = false) {
// if (this.abort) {
// console.log(`[subtasks] already aborted task ${this.taskId}.${this.instanceId}`)
// return
// }

console.log(`[subtasks] aborting task ${this.taskId}.${this.instanceId}`)

// Will stop any autonomously running promises.
if (isAbandoned) {
this.abandoned = true
Expand Down Expand Up @@ -1237,7 +1252,7 @@ export class Cline extends EventEmitter<ClineEvents> {

async presentAssistantMessage() {
if (this.abort) {
throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#3)`)
throw new Error(`[Cline#presentAssistantMessage] task ${this.taskId}.${this.instanceId} aborted`)
}

if (this.presentAssistantMessageLocked) {
Expand Down Expand Up @@ -3113,7 +3128,7 @@ export class Cline extends EventEmitter<ClineEvents> {
includeFileDetails: boolean = false,
): Promise<boolean> {
if (this.abort) {
throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#4)`)
throw new Error(`[Cline#recursivelyMakeClineRequests] task ${this.taskId}.${this.instanceId} aborted`)
}

if (this.consecutiveMistakeCount >= 3) {
Expand Down Expand Up @@ -3146,9 +3161,9 @@ export class Cline extends EventEmitter<ClineEvents> {
const provider = this.providerRef.deref()

if (this.isPaused && provider) {
provider.log(`[subtasks] paused ${this.taskId}`)
provider.log(`[subtasks] paused ${this.taskId}.${this.instanceId}`)
await this.waitForResume()
provider.log(`[subtasks] resumed ${this.taskId}`)
provider.log(`[subtasks] resumed ${this.taskId}.${this.instanceId}`)
const currentMode = (await provider.getState())?.mode ?? defaultModeSlug

if (currentMode !== this.pausedModeSlug) {
Expand All @@ -3159,7 +3174,7 @@ export class Cline extends EventEmitter<ClineEvents> {
await delay(500)

provider.log(
`[subtasks] task ${this.taskId} has switched back to '${this.pausedModeSlug}' from '${currentMode}'`,
`[subtasks] task ${this.taskId}.${this.instanceId} has switched back to '${this.pausedModeSlug}' from '${currentMode}'`,
)
}
}
Expand Down Expand Up @@ -3279,6 +3294,7 @@ export class Cline extends EventEmitter<ClineEvents> {
let assistantMessage = ""
let reasoningMessage = ""
this.isStreaming = true

try {
for await (const chunk of stream) {
if (!chunk) {
Expand Down Expand Up @@ -3356,7 +3372,7 @@ export class Cline extends EventEmitter<ClineEvents> {

// need to call here in case the stream was aborted
if (this.abort || this.abandoned) {
throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#5)`)
throw new Error(`[Cline#recursivelyMakeClineRequests] task ${this.taskId}.${this.instanceId} aborted`)
}

this.didCompleteReadingStream = true
Expand Down
Loading
Loading