Skip to content
Open
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
71 changes: 71 additions & 0 deletions apps/vscode-e2e/src/fixtures/subtasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export const SUBTASK_INTERRUPT_PARENT_PROMPT = `${SUBTASK_INTERRUPT_PARENT_MARKE
export const SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER = "9"
export const SUBTASK_INTERRUPT_PARENT_RESULT = "Interrupted parent resumed"

// Abandon-subtask scenario (#559) — separate markers to avoid sequenceIndex collisions with the
// interrupted-child-resumes tests above, which exhaust the sequence count for INTERRUPT markers.
const SUBTASK_ABANDON_PARENT_MARKER = "SUBTASK_PARENT_ABANDON_SEVER"
const SUBTASK_ABANDON_CHILD_MARKER = "SUBTASK_CHILD_ABANDON_SEVER"
const SUBTASK_ABANDON_CHILD_PROMPT = `${SUBTASK_ABANDON_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_ABANDON_PARENT_PROMPT = `${SUBTASK_ABANDON_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_ABANDON_CHILD_PROMPT}" Do not answer directly.`
export const SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER = "9"

const SUBTASK_XPROFILE_SAME_CHILD_PROMPT = `${SUBTASK_XPROFILE_SAME_CHILD_MARKER}: Complete immediately with the exact result "Same-profile child completed".`
const SUBTASK_XPROFILE_DIFFERENT_CHILD_PROMPT = `${SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER}: Complete immediately with the exact result "Different-profile child completed".`
export const SUBTASK_XPROFILE_PARENT_PROMPT = `${SUBTASK_XPROFILE_PARENT_MARKER}: First use new_task to create a code-mode subtask with this exact message: "${SUBTASK_XPROFILE_SAME_CHILD_PROMPT}" After it returns, create an ask-mode subtask with the next instructions you receive.`
Expand Down Expand Up @@ -354,4 +362,67 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
],
},
})

// Abandon-subtask scenario (#559)
mock.addFixture({
match: {
userMessage: new RegExp(SUBTASK_ABANDON_PARENT_MARKER),
sequenceIndex: 0,
},
response: {
toolCalls: [
{
name: "new_task",
arguments: JSON.stringify({
mode: "ask",
message: SUBTASK_ABANDON_CHILD_PROMPT,
}),
id: "call_abandon_parent_new_task_001",
},
],
},
})

mock.addFixture({
match: {
predicate: (req: ChatCompletionRequest) =>
requestContains(req, [SUBTASK_ABANDON_CHILD_MARKER]) &&
!requestContains(req, [SUBTASK_ABANDON_PARENT_MARKER]) &&
!requestContains(req, ["call_abandon_child_followup_001"]) &&
!requestContains(req, [`<user_message>\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`]),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
response: {
toolCalls: [
{
name: "ask_followup_question",
arguments: JSON.stringify({
question: "What is the square root of 81?",
follow_up: [{ text: SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER }],
}),
id: "call_abandon_child_followup_001",
},
],
},
})

mock.addFixture({
match: {
predicate: (req: ChatCompletionRequest) =>
toolResultContains(req, "call_abandon_child_followup_001", [SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER]) ||
requestContains(req, ["call_abandon_child_followup_001", SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER]) ||
requestContains(req, [
SUBTASK_ABANDON_CHILD_MARKER,
`<user_message>\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`,
]),
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER }),
id: "call_abandon_child_completion_002",
},
],
},
})
}
163 changes: 163 additions & 0 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
import { setDefaultSuiteTimeout } from "./test-utils"
import { sleep, waitFor, waitUntilCompleted } from "./utils"
import {
SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER,
SUBTASK_ABANDON_PARENT_PROMPT,
SUBTASK_CHILD_FOLLOWUP_ANSWER,
SUBTASK_FAST_PARENT_PROMPT,
SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
Expand Down Expand Up @@ -617,4 +619,165 @@ suite("Roo Code Subtasks", function () {
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
}
})

// Issue #559: explicit "Abandon subtask" action. Unlike cancellation alone (which leaves
// the child "interrupted" and the parent "delegated" so the child can still resume and
// report back), abandoning severs the link outright: the parent goes back to "active" and
// the child's parentTaskId/rootTaskId are cleared so a later resume can never reattach it.
test("abandoning an interrupted subtask severs the parent-child link", async () => {
const api = globalThis.api
const asks: Record<string, ClineMessage[]> = {}
const says: Record<string, ClineMessage[]> = {}

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

api.on(RooCodeEventName.Message, messageHandler)

try {
const parentTaskId = await api.startNewTask({
configuration: {
mode: "ask",
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
autoApprovalEnabled: true,
enableCheckpoints: false,
},
text: SUBTASK_ABANDON_PARENT_PROMPT,
})

let childTaskId: string | undefined
await waitFor(() => {
const stack = api.getCurrentTaskStack()
const current = stack[stack.length - 1]
if (current && current !== parentTaskId) {
childTaskId = current
return true
}
return false
})

await waitFor(() => asks[childTaskId!]?.some(({ ask }) => ask === "followup") ?? false)
await waitFor(async () => (await api.getTaskApiConversationHistoryLength(childTaskId!)) > 0)

// Cancel the child — marked "interrupted", parent stays "delegated".
await api.cancelCurrentTask()

await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId)
await waitFor(
() => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
)

const interruptedChild = await api.getTaskHistoryItem(childTaskId!)
assert.strictEqual(interruptedChild?.status, "interrupted", "Child should be marked interrupted")

const delegatedParent = await api.getTaskHistoryItem(parentTaskId)
assert.strictEqual(delegatedParent?.status, "delegated", "Parent should still be delegated before abandon")
assert.strictEqual(
delegatedParent?.awaitingChildId,
childTaskId,
"Parent should await the interrupted child",
)

// The interrupted child is the live/open task at this point (cancelTask rehydrates
// it onto the stack). Abandon must close that live instance before severing the
// persisted link — otherwise a later save on the still-open child would rebuild
// parentTaskId/rootTaskId from its live (readonly) fields and silently reattach it.
const abandoned = await api.abandonSubtask(childTaskId!)
assert.strictEqual(abandoned, true, "abandonSubtask should report the link was severed")

await waitFor(() => api.getCurrentTaskStack().at(-1) !== childTaskId)

const parentAfterAbandon = await api.getTaskHistoryItem(parentTaskId)
assert.strictEqual(parentAfterAbandon?.status, "active", "Parent should return to active after abandon")
assert.strictEqual(
parentAfterAbandon?.awaitingChildId,
undefined,
"Parent awaitingChildId should be cleared",
)
assert.strictEqual(parentAfterAbandon?.delegatedToId, undefined, "Parent delegatedToId should be cleared")

const childAfterAbandon = await api.getTaskHistoryItem(childTaskId!)
// The child's own status is left untouched (VALID_TRANSITIONS only allows interrupted → completed);
// only its parent/root links are cleared so it can never reattach to the parent again.
assert.strictEqual(childAfterAbandon?.status, "interrupted", "Child status stays interrupted")
assert.strictEqual(childAfterAbandon?.parentTaskId, undefined, "Child parentTaskId should be cleared")
assert.strictEqual(childAfterAbandon?.rootTaskId, undefined, "Child rootTaskId should be cleared")

// A second abandon call is a no-op since the parent is no longer delegated to this child.
const secondAbandon = await api.abandonSubtask(childTaskId!)
assert.strictEqual(secondAbandon, false, "Second abandonSubtask call should be a no-op")

// Resume and complete the abandoned child — it must NOT reopen or reattach to the
// parent. Before the abandon fix, a subsequent save on the still-live child could
// silently rewrite its persisted parentTaskId back to the parent; this proves the
// link stays severed all the way through a real resume/save/complete cycle.
// api.resumeTask() re-instantiates the child from history, which re-raises its own
// "resume_task" ask; answering it with the follow-up answer (same pattern the sibling
// "cancelled child completes and reopens parent" test above uses) both resumes the
// task and supplies the answer the re-asked follow-up question is waiting for.
// asks[childTaskId] already holds the earlier resume_task ask from the pre-abandon
// cancellation, so the wait below must look for a NEW one, not just any occurrence.
const askCountBeforeResume = asks[childTaskId!]?.length ?? 0
await api.resumeTask(childTaskId!)
await waitFor(() =>
(asks[childTaskId!] ?? [])
.slice(askCountBeforeResume)
.some(({ type, ask }) => type === "ask" && ask === "resume_task"),
)

const completedChildTaskId = await waitUntilCompleted({
api,
start: async () => {
await api.sendMessage(SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER)
return childTaskId!
},
})

assert.strictEqual(
completedChildTaskId,
childTaskId,
"The abandoned child itself should be the task that completes, not the parent",
)
assert.strictEqual(
says[parentTaskId]?.find(({ say }) => say === "completion_result"),
undefined,
"Parent must never complete/reopen after its abandoned child resumes and completes",
)

const parentAfterChildCompletes = await api.getTaskHistoryItem(parentTaskId)
assert.strictEqual(
parentAfterChildCompletes?.status,
"active",
"Parent status must remain untouched by the abandoned child's completion",
)
assert.strictEqual(
parentAfterChildCompletes?.awaitingChildId,
undefined,
"Parent must not start awaiting the abandoned child again",
)

const childAfterCompletion = await api.getTaskHistoryItem(childTaskId!)
assert.strictEqual(
childAfterCompletion?.parentTaskId,
undefined,
"Child parentTaskId must still be cleared after it completes on its own — " +
"proves the live-instance save did not resurrect the old link",
)
} finally {
api.off(RooCodeEventName.Message, messageHandler)
while (api.getCurrentTaskStack().length > 0) {
await api.clearCurrentTask()
}
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
}
})
})
8 changes: 8 additions & 0 deletions packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
* Cancels the current task.
*/
cancelCurrentTask(): Promise<void>
/**
* Severs the delegated parent-child link for an interrupted (cancelled, not running)
* subtask, so the parent stops waiting on it and returns to "active". No-op (returns
* false) unless the child is interrupted and its parent is still delegated to it.
* @param childTaskId The ID of the child (subtask) to abandon.
* @returns True if the link was severed, false if there was nothing to abandon.
*/
abandonSubtask(childTaskId: string): Promise<boolean>
/**
* Sends a message to the current task.
* @param message Optional message to send.
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ export interface WebviewMessage {
| "shareCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "abandonSubtaskWithId"
| "exportTaskWithId"
| "importSettings"
| "exportSettings"
Expand Down
Loading
Loading