Skip to content
Draft
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
117 changes: 117 additions & 0 deletions apps/vscode-e2e/src/fixtures/subtasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { LLMock } from "@copilotkit/aimock"

import { toolResultContains } from "./tool-result"

const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE"
const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE"

export const SUBTASK_CHILD_PROMPT = `${SUBTASK_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.`
export const SUBTASK_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.`
export const SUBTASK_CHILD_FOLLOWUP_ANSWER = "9"
const INTERRUPTED_TOOL_RESULT = "Task was interrupted before this tool call could be completed."

export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
mock.addFixture({
match: {
userMessage: new RegExp(SUBTASK_PARENT_MARKER),
},
response: {
toolCalls: [
{
name: "new_task",
arguments: JSON.stringify({
mode: "ask",
message: SUBTASK_CHILD_PROMPT,
}),
id: "call_subtasks_parent_new_task_001",
},
],
},
})

mock.addFixture({
match: {
userMessage: new RegExp(SUBTASK_CHILD_MARKER),
},
response: {
toolCalls: [
{
name: "ask_followup_question",
arguments: JSON.stringify({
question: "What is the square root of 81?",
follow_up: [{ text: SUBTASK_CHILD_FOLLOWUP_ANSWER }],
}),
id: "call_subtasks_child_followup_001",
},
],
},
})

mock.addFixture({
match: {
toolCallId: "call_subtasks_child_followup_001",
Comment thread
roomote[bot] marked this conversation as resolved.
predicate: (req) => toolResultContains(req, "call_subtasks_child_followup_001", [INTERRUPTED_TOOL_RESULT]),
},
response: {
toolCalls: [
{
name: "ask_followup_question",
arguments: JSON.stringify({
question: "What is the square root of 81?",
follow_up: [{ text: SUBTASK_CHILD_FOLLOWUP_ANSWER }],
}),
id: "call_subtasks_child_followup_resume_002",
},
],
},
})

mock.addFixture({
match: {
toolCallId: "call_subtasks_child_followup_001",
predicate: (req) =>
toolResultContains(req, "call_subtasks_child_followup_001", [SUBTASK_CHILD_FOLLOWUP_ANSWER]),
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: "9" }),
id: "call_subtasks_child_completion_002",
},
],
},
})

mock.addFixture({
match: {
toolCallId: "call_subtasks_child_followup_resume_002",
predicate: (req) =>
toolResultContains(req, "call_subtasks_child_followup_resume_002", [SUBTASK_CHILD_FOLLOWUP_ANSWER]),
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: "9" }),
id: "call_subtasks_child_completion_resume_003",
},
],
},
})

mock.addFixture({
match: {
toolCallId: "call_subtasks_parent_new_task_001",
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: "Parent task resumed" }),
id: "call_subtasks_parent_completion_003",
},
],
},
})
}
2 changes: 2 additions & 0 deletions apps/vscode-e2e/src/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { addExecuteCommandResultFixtures } from "./fixtures/execute-command"
import { addListFilesResultFixtures } from "./fixtures/list-files"
import { addReadFileResultFixtures } from "./fixtures/read-file"
import { addSearchFilesResultFixtures } from "./fixtures/search-files"
import { addSubtaskFixtures } from "./fixtures/subtasks"
import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool"
import { addWriteToFileResultFixtures } from "./fixtures/write-to-file"

Expand Down Expand Up @@ -92,6 +93,7 @@ async function main() {
addListFilesResultFixtures(mock)
addReadFileResultFixtures(mock)
addSearchFilesResultFixtures(mock)
addSubtaskFixtures(mock)
addUseMcpToolResultFixtures(mock)
addWriteToFileResultFixtures(mock)

Expand Down
175 changes: 122 additions & 53 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,73 +2,142 @@ import * as assert from "assert"

import { RooCodeEventName, type ClineMessage } from "@roo-code/types"

import { setDefaultSuiteTimeout } from "./test-utils"
import { sleep, waitFor, waitUntilCompleted } from "./utils"
import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_PARENT_PROMPT } from "../fixtures/subtasks"

suite("Roo Code Subtasks", function () {
setDefaultSuiteTimeout(this)

suite.skip("Roo Code Subtasks", () => {
test("Should handle subtask cancellation and resumption correctly", async () => {
const api = globalThis.api

const asks: Record<string, ClineMessage[]> = {}
const messages: Record<string, ClineMessage[]> = {}
const waitForStage = async (label: string, condition: Parameters<typeof waitFor>[0]) => {
try {
await waitFor(condition)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`${label}: ${message}`)
}
}

const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
if (message.type === "ask") {
asks[taskId] = asks[taskId] || []
asks[taskId].push(message)
}

api.on(RooCodeEventName.Message, ({ taskId, message }) => {
if (message.type === "say" && message.partial === false) {
messages[taskId] = messages[taskId] || []
messages[taskId].push(message)
}
})

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.
const parentTaskId = await api.startNewTask({
configuration: {
mode: "ask",
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
autoApprovalEnabled: true,
enableCheckpoints: false,
},
text:
"You are the parent task. " +
`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'.",
})

let spawnedTaskId: string | undefined = undefined

// Wait for the subtask to be spawned and then cancel it.
api.on(RooCodeEventName.TaskSpawned, (_, childTaskId) => (spawnedTaskId = childTaskId))
await waitFor(() => !!spawnedTaskId)
await sleep(1_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(2_000)

// The parent task should not have resumed yet, so we shouldn't see
// "Parent task resumed".
assert.ok(
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
undefined,
"Parent task should not have resumed after subtask cancellation",
)
}

const findCompletionText = (taskId: string) =>
messages[taskId]
?.filter(
(message) =>
message.type === "say" && (message.say === "completion_result" || message.say === "text"),
)
.map((message) => message.text?.trim())
.find((text): text is string => !!text)

const findErrorText = (taskId: string) =>
messages[taskId]
?.filter((message) => message.type === "say" && message.say === "error")
.map((message) => message.text?.trim())
.find((text): text is string => !!text)

// Start a new task with the same message as the subtask.
const anotherTaskId = await api.startNewTask({ text: childPrompt })
await waitUntilCompleted({ api, taskId: anotherTaskId })
api.on(RooCodeEventName.Message, messageHandler)

// Wait a bit to ensure any task resumption would have happened.
await sleep(2_000)
try {
const parentTaskId = await api.startNewTask({
configuration: {
mode: "ask",
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
autoApprovalEnabled: true,
enableCheckpoints: false,
},
text: SUBTASK_PARENT_PROMPT,
})

// The parent task should still not have resumed.
assert.ok(
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
let spawnedTaskId: string | undefined
await waitForStage("wait for spawned subtask", () => {
const currentTaskStack = api.getCurrentTaskStack()
const currentTaskId = currentTaskStack[currentTaskStack.length - 1]
if (currentTaskId && currentTaskId !== parentTaskId) {
spawnedTaskId = currentTaskId
return true
}
return false
})
await waitForStage(
"wait for delegated child followup ask",
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
)
const cancelledChildTaskId = spawnedTaskId!
const delegatedFollowupCount =
asks[cancelledChildTaskId]?.filter(({ type, ask }) => type === "ask" && ask === "followup").length ?? 0

await api.cancelCurrentTask()

await sleep(2_000)

assert.ok(
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
undefined,
"Parent task should not have resumed after subtask cancellation",
)

await waitForStage(
"wait for cancelled child task to remain active",
() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId,
)
await waitForStage(
"wait for cancelled child resume ask",
() =>
asks[cancelledChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ??
false,
)
await api.approveCurrentAsk()
await waitForStage(
"wait for resumed child followup ask",
() =>
(asks[cancelledChildTaskId]?.filter(({ type, ask }) => type === "ask" && ask === "followup")
.length ?? 0) > delegatedFollowupCount,
)
await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER)
await waitUntilCompleted({ api, taskId: cancelledChildTaskId })

assert.strictEqual(
findErrorText(cancelledChildTaskId),
undefined,
"Parent task should not have resumed after subtask cancellation",
)
"Cancelled child should not emit an error",
)
assert.strictEqual(
findCompletionText(cancelledChildTaskId),
"9",
"Cancelled child should complete with `9`",
)
assert.strictEqual(
api.getCurrentTaskStack().at(-1),
cancelledChildTaskId,
"Cancelled child should stay active after resuming from cancellation",
)

await sleep(2_000)

assert.ok(
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
undefined,
"Parent task should not have resumed after subtask cancellation",
)

// Clean up - cancel all tasks.
await api.clearCurrentTask()
await waitUntilCompleted({ api, taskId: parentTaskId })
await api.clearCurrentTask()
} finally {
api.off(RooCodeEventName.Message, messageHandler)
}
})
})
25 changes: 14 additions & 11 deletions src/core/tools/AttemptCompletionTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,21 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
// This shows the user the completion result and waits for acceptance
// without injecting another tool_result to the parent
} else if (status === "active") {
// Normal subtask completion - do delegation
const delegation = await this.delegateToParent(
task,
result,
provider,
askFinishSubTaskApproval,
pushToolResult,
)
if (delegation === "delegated") {
this.emitTaskCompleted(task)
const { historyItem: parentHistory } = await provider.getTaskWithId(task.parentTaskId)

if (parentHistory.status === "delegated" && parentHistory.awaitingChildId === task.taskId) {
const delegation = await this.delegateToParent(
task,
result,
provider,
askFinishSubTaskApproval,
pushToolResult,
)
if (delegation === "delegated") {
this.emitTaskCompleted(task)
}
if (delegation !== "continue") return
}
if (delegation !== "continue") return
} else {
// Unexpected status (undefined or "delegated") - log error and skip delegation
// undefined indicates a bug in status persistence during child creation
Expand Down
Loading
Loading