Skip to content

Commit d16b322

Browse files
roomoteedelauna
authored andcommitted
test: tighten subtasks cancellation replay
1 parent 9629594 commit d16b322

4 files changed

Lines changed: 200 additions & 7 deletions

File tree

apps/vscode-e2e/src/fixtures/subtasks.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { LLMock } from "@copilotkit/aimock"
22

3+
import { toolResultContains } from "./tool-result"
4+
35
export const SUBTASK_PARENT_PROMPT = "SUBTASK_PARENT_CANCELLATION_SMOKE"
46
export const SUBTASK_CHILD_PROMPT = "SUBTASK_CHILD_CALCULATOR_SMOKE"
57
export const SUBTASK_CHILD_FOLLOWUP_ANSWER = "9"
8+
const INTERRUPTED_TOOL_RESULT = "Task was interrupted before this tool call could be completed."
69

710
export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
811
mock.addFixture({
@@ -44,6 +47,27 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
4447
mock.addFixture({
4548
match: {
4649
toolCallId: "call_subtasks_child_followup_001",
50+
predicate: (req) => toolResultContains(req, "call_subtasks_child_followup_001", [INTERRUPTED_TOOL_RESULT]),
51+
},
52+
response: {
53+
toolCalls: [
54+
{
55+
name: "ask_followup_question",
56+
arguments: JSON.stringify({
57+
question: "What is the square root of 81?",
58+
follow_up: [{ text: SUBTASK_CHILD_FOLLOWUP_ANSWER }],
59+
}),
60+
id: "call_subtasks_child_followup_resume_002",
61+
},
62+
],
63+
},
64+
})
65+
66+
mock.addFixture({
67+
match: {
68+
toolCallId: "call_subtasks_child_followup_001",
69+
predicate: (req) =>
70+
toolResultContains(req, "call_subtasks_child_followup_001", [SUBTASK_CHILD_FOLLOWUP_ANSWER]),
4771
},
4872
response: {
4973
toolCalls: [
@@ -56,6 +80,23 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
5680
},
5781
})
5882

83+
mock.addFixture({
84+
match: {
85+
toolCallId: "call_subtasks_child_followup_resume_002",
86+
predicate: (req) =>
87+
toolResultContains(req, "call_subtasks_child_followup_resume_002", [SUBTASK_CHILD_FOLLOWUP_ANSWER]),
88+
},
89+
response: {
90+
toolCalls: [
91+
{
92+
name: "attempt_completion",
93+
arguments: JSON.stringify({ result: "9" }),
94+
id: "call_subtasks_child_completion_resume_003",
95+
},
96+
],
97+
},
98+
})
99+
59100
mock.addFixture({
60101
match: {
61102
toolCallId: "call_subtasks_parent_new_task_001",

apps/vscode-e2e/src/suite/subtasks.test.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
44

55
import { setDefaultSuiteTimeout } from "./test-utils"
66
import { sleep, waitFor, waitUntilCompleted } from "./utils"
7-
import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_CHILD_PROMPT, SUBTASK_PARENT_PROMPT } from "../fixtures/subtasks"
7+
import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_PARENT_PROMPT } from "../fixtures/subtasks"
88

99
suite("Roo Code Subtasks", function () {
1010
setDefaultSuiteTimeout(this)
@@ -34,6 +34,21 @@ suite("Roo Code Subtasks", function () {
3434
}
3535
}
3636

37+
const findCompletionText = (taskId: string) =>
38+
messages[taskId]
39+
?.filter(
40+
(message) =>
41+
message.type === "say" && (message.say === "completion_result" || message.say === "text"),
42+
)
43+
.map((message) => message.text?.trim())
44+
.find((text): text is string => !!text)
45+
46+
const findErrorText = (taskId: string) =>
47+
messages[taskId]
48+
?.filter((message) => message.type === "say" && message.say === "error")
49+
.map((message) => message.text?.trim())
50+
.find((text): text is string => !!text)
51+
3752
api.on(RooCodeEventName.Message, messageHandler)
3853

3954
try {
@@ -62,6 +77,9 @@ suite("Roo Code Subtasks", function () {
6277
"wait for delegated child followup ask",
6378
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
6479
)
80+
const cancelledChildTaskId = spawnedTaskId!
81+
const delegatedFollowupCount =
82+
asks[cancelledChildTaskId]?.filter(({ type, ask }) => type === "ask" && ask === "followup").length ?? 0
6583

6684
await api.cancelCurrentTask()
6785

@@ -73,13 +91,41 @@ suite("Roo Code Subtasks", function () {
7391
"Parent task should not have resumed after subtask cancellation",
7492
)
7593

76-
const anotherTaskId = await api.startNewTask({ text: SUBTASK_CHILD_PROMPT })
7794
await waitForStage(
78-
"wait for standalone child followup ask",
79-
() => asks[anotherTaskId]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
95+
"wait for cancelled child task to remain active",
96+
() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId,
97+
)
98+
await waitForStage(
99+
"wait for cancelled child resume ask",
100+
() =>
101+
asks[cancelledChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ??
102+
false,
103+
)
104+
await api.approveCurrentAsk()
105+
await waitForStage(
106+
"wait for resumed child followup ask",
107+
() =>
108+
(asks[cancelledChildTaskId]?.filter(({ type, ask }) => type === "ask" && ask === "followup")
109+
.length ?? 0) > delegatedFollowupCount,
80110
)
81111
await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER)
82-
await waitUntilCompleted({ api, taskId: anotherTaskId })
112+
await waitUntilCompleted({ api, taskId: cancelledChildTaskId })
113+
114+
assert.strictEqual(
115+
findErrorText(cancelledChildTaskId),
116+
undefined,
117+
"Cancelled child should not emit an error",
118+
)
119+
assert.strictEqual(
120+
findCompletionText(cancelledChildTaskId),
121+
"9",
122+
"Cancelled child should complete with `9`",
123+
)
124+
assert.strictEqual(
125+
api.getCurrentTaskStack().at(-1),
126+
cancelledChildTaskId,
127+
"Cancelled child should stay active after resuming from cancellation",
128+
)
83129

84130
await sleep(2_000)
85131

src/core/webview/ClineProvider.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2900,8 +2900,8 @@ export class ClineProvider
29002900
}
29012901

29022902
// Preserve parent and root task information for history item.
2903-
const rootTask = task.rootTask
2904-
const parentTask = task.parentTask
2903+
let rootTask = task.rootTask
2904+
let parentTask = task.parentTask
29052905

29062906
// Mark this as a user-initiated cancellation so provider-only rehydration can occur
29072907
task.abortReason = "user_cancelled"
@@ -2959,6 +2959,34 @@ export class ClineProvider
29592959
return
29602960
}
29612961

2962+
if (task.parentTaskId) {
2963+
try {
2964+
const { historyItem: parentHistory } = await this.getTaskWithId(task.parentTaskId)
2965+
2966+
if (parentHistory.status === "delegated" && parentHistory.awaitingChildId === task.taskId) {
2967+
await this.updateTaskHistory({
2968+
...parentHistory,
2969+
status: "active",
2970+
awaitingChildId: undefined,
2971+
})
2972+
2973+
historyItem = {
2974+
...historyItem,
2975+
parentTaskId: undefined,
2976+
rootTaskId: undefined,
2977+
}
2978+
parentTask = undefined
2979+
rootTask = undefined
2980+
}
2981+
} catch (error) {
2982+
this.log(
2983+
`[cancelTask] Failed to detach delegated parent for ${task.taskId}: ${
2984+
error instanceof Error ? error.message : String(error)
2985+
}`,
2986+
)
2987+
}
2988+
}
2989+
29622990
// Clears task again, so we need to abortTask manually above.
29632991
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
29642992
}

src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,4 +316,82 @@ describe("ClineProvider flicker-free cancel", () => {
316316
expect((provider as any).clineStack[0]).toBe(mockParentTask)
317317
expect((provider as any).clineStack[1]).toBe(mockTask2)
318318
})
319+
320+
it("detaches a cancelled delegated child before rehydrating it", async () => {
321+
const mockRootTask = { taskId: "root-1" }
322+
const mockParentTask = { taskId: "parent-1" }
323+
const childHistory: HistoryItem = {
324+
id: "child-1",
325+
number: 2,
326+
task: "child task",
327+
ts: Date.now(),
328+
tokensIn: 10,
329+
tokensOut: 20,
330+
totalCost: 0.001,
331+
workspace: "/test/workspace",
332+
parentTaskId: "parent-1",
333+
rootTaskId: "root-1",
334+
}
335+
const parentHistory: HistoryItem = {
336+
id: "parent-1",
337+
number: 1,
338+
task: "parent task",
339+
ts: Date.now(),
340+
tokensIn: 10,
341+
tokensOut: 20,
342+
totalCost: 0.001,
343+
workspace: "/test/workspace",
344+
status: "delegated",
345+
awaitingChildId: "child-1",
346+
delegatedToId: "child-1",
347+
}
348+
349+
Object.assign(mockTask1, {
350+
taskId: "child-1",
351+
instanceId: "instance-child",
352+
rootTask: mockRootTask,
353+
parentTask: mockParentTask,
354+
parentTaskId: "parent-1",
355+
cancelCurrentRequest: vi.fn(),
356+
abortTask: vi.fn(),
357+
abandoned: false,
358+
isStreaming: false,
359+
didFinishAbortingStream: true,
360+
isWaitingForFirstChunk: false,
361+
})
362+
;(provider as any).clineStack = [mockTask1]
363+
provider.getTaskWithId = vi.fn().mockImplementation((id) => {
364+
if (id === "child-1") {
365+
return Promise.resolve({ historyItem: childHistory })
366+
}
367+
if (id === "parent-1") {
368+
return Promise.resolve({ historyItem: parentHistory })
369+
}
370+
throw new Error(`unexpected task lookup: ${id}`)
371+
}) as any
372+
373+
const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([])
374+
const createTaskWithHistoryItemSpy = vi
375+
.spyOn(provider, "createTaskWithHistoryItem")
376+
.mockResolvedValue(undefined as any)
377+
378+
await provider.cancelTask()
379+
380+
expect(updateTaskHistorySpy).toHaveBeenCalledWith(
381+
expect.objectContaining({
382+
id: "parent-1",
383+
status: "active",
384+
awaitingChildId: undefined,
385+
}),
386+
)
387+
expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith(
388+
expect.objectContaining({
389+
id: "child-1",
390+
parentTaskId: undefined,
391+
rootTaskId: undefined,
392+
parentTask: undefined,
393+
rootTask: undefined,
394+
}),
395+
)
396+
})
319397
})

0 commit comments

Comments
 (0)