Skip to content

Commit 9327c3c

Browse files
committed
test(e2e): simplyfying e2e tests
1 parent afdbe00 commit 9327c3c

3 files changed

Lines changed: 77 additions & 202 deletions

File tree

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

Lines changed: 77 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,84 @@ import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_PARENT_PROMPT } from "../fixture
99
suite("Roo Code Subtasks", function () {
1010
setDefaultSuiteTimeout(this)
1111

12-
test("Should keep parent paused after subtask cancellation", async () => {
12+
// Race mitigation: skipDelegationRepair prevents removeClineFromStack from
13+
// auto-resuming the parent when the child is cancelled (Race 2).
14+
test("parent stays paused after subtask cancellation", async () => {
1315
const api = globalThis.api
1416
const asks: Record<string, ClineMessage[]> = {}
1517
const messages: Record<string, ClineMessage[]> = {}
16-
const waitForStage = async (label: string, condition: Parameters<typeof waitFor>[0]) => {
17-
try {
18-
await waitFor(condition)
19-
} catch (error) {
20-
const message = error instanceof Error ? error.message : String(error)
21-
throw new Error(`${label}: ${message}`)
18+
19+
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
20+
if (message.type === "ask") {
21+
asks[taskId] = asks[taskId] || []
22+
asks[taskId].push(message)
23+
}
24+
if (message.type === "say" && message.partial === false) {
25+
messages[taskId] = messages[taskId] || []
26+
messages[taskId].push(message)
2227
}
2328
}
2429

30+
api.on(RooCodeEventName.Message, messageHandler)
31+
32+
try {
33+
const parentTaskId = await api.startNewTask({
34+
configuration: {
35+
mode: "ask",
36+
alwaysAllowModeSwitch: true,
37+
alwaysAllowSubtasks: true,
38+
autoApprovalEnabled: true,
39+
enableCheckpoints: false,
40+
},
41+
text: SUBTASK_PARENT_PROMPT,
42+
})
43+
44+
let spawnedTaskId: string | undefined
45+
await waitFor(() => {
46+
const stack = api.getCurrentTaskStack()
47+
const current = stack[stack.length - 1]
48+
if (current && current !== parentTaskId) {
49+
spawnedTaskId = current
50+
return true
51+
}
52+
return false
53+
})
54+
55+
await waitFor(
56+
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
57+
)
58+
59+
await api.cancelCurrentTask()
60+
61+
assert.ok(
62+
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
63+
undefined,
64+
"Parent task should not have resumed after subtask cancellation",
65+
)
66+
67+
await waitFor(() => api.getCurrentTaskStack().at(-1) === spawnedTaskId)
68+
await waitFor(
69+
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
70+
)
71+
72+
await api.clearCurrentTask()
73+
} finally {
74+
api.off(RooCodeEventName.Message, messageHandler)
75+
}
76+
})
77+
78+
// Race mitigation: runDelegationTransition lock + cancelledDelegationChildIds guard
79+
// ensures cancelTask() wins over a concurrent reopenParentFromDelegation() (Race 3).
80+
test("cancelled child completes in-place and does not reopen parent", async () => {
81+
const api = globalThis.api
82+
const asks: Record<string, ClineMessage[]> = {}
83+
const messages: Record<string, ClineMessage[]> = {}
84+
2585
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
2686
if (message.type === "ask") {
2787
asks[taskId] = asks[taskId] || []
2888
asks[taskId].push(message)
2989
}
30-
3190
if (message.type === "say" && message.partial === false) {
3291
messages[taskId] = messages[taskId] || []
3392
messages[taskId].push(message)
@@ -64,35 +123,25 @@ suite("Roo Code Subtasks", function () {
64123
})
65124

66125
let spawnedTaskId: string | undefined
67-
await waitForStage("wait for spawned subtask", () => {
68-
const currentTaskStack = api.getCurrentTaskStack()
69-
const currentTaskId = currentTaskStack[currentTaskStack.length - 1]
70-
if (currentTaskId && currentTaskId !== parentTaskId) {
71-
spawnedTaskId = currentTaskId
126+
await waitFor(() => {
127+
const stack = api.getCurrentTaskStack()
128+
const current = stack[stack.length - 1]
129+
if (current && current !== parentTaskId) {
130+
spawnedTaskId = current
72131
return true
73132
}
74133
return false
75134
})
76-
await waitForStage(
77-
"wait for delegated child followup ask",
135+
136+
await waitFor(
78137
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
79138
)
80-
const cancelledChildTaskId = spawnedTaskId!
81139

140+
const cancelledChildTaskId = spawnedTaskId!
82141
await api.cancelCurrentTask()
83142

84-
assert.ok(
85-
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
86-
undefined,
87-
"Parent task should not have resumed after subtask cancellation",
88-
)
89-
90-
await waitForStage(
91-
"wait for cancelled child task to remain active",
92-
() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId,
93-
)
94-
await waitForStage(
95-
"wait for cancelled child resume ask",
143+
await waitFor(() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId)
144+
await waitFor(
96145
() =>
97146
asks[cancelledChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ??
98147
false,
@@ -126,7 +175,6 @@ suite("Roo Code Subtasks", function () {
126175
cancelledChildTaskId,
127176
"Cancelled child task should remain the active completed task",
128177
)
129-
130178
assert.ok(
131179
messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
132180
undefined,

src/__tests__/provider-delegation.spec.ts

Lines changed: 0 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
101101
const parentTask = {
102102
taskId: "parent-1",
103103
emit: vi.fn(),
104-
getTaskMode: vi.fn().mockResolvedValue("architect"),
105104
} as any
106105
const childStart = vi.fn(() => callOrder.push("child.start"))
107106

@@ -146,129 +145,4 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
146145
// Verify ordering: createTask → updateTaskHistory → child.start
147146
expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"])
148147
})
149-
150-
it("removes the paused child when parent delegation metadata cannot be persisted", async () => {
151-
const parentTask = {
152-
taskId: "parent-1",
153-
emit: vi.fn(),
154-
getTaskMode: vi.fn().mockResolvedValue("architect"),
155-
} as any
156-
const childStart = vi.fn()
157-
const persistError = new Error("history write failed")
158-
159-
const providerEmit = vi.fn()
160-
const updateTaskHistory = vi.fn().mockRejectedValue(persistError)
161-
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
162-
const deleteTaskFromState = vi.fn().mockResolvedValue(undefined)
163-
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart })
164-
const createTaskWithHistoryItem = vi.fn().mockResolvedValue(undefined)
165-
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
166-
const parentHistory = {
167-
id: "parent-1",
168-
task: "Parent",
169-
tokensIn: 0,
170-
tokensOut: 0,
171-
totalCost: 0,
172-
childIds: [],
173-
}
174-
const getTaskWithId = vi.fn().mockResolvedValue({
175-
historyItem: parentHistory,
176-
})
177-
178-
const provider = {
179-
emit: providerEmit,
180-
getCurrentTask: vi.fn(() => parentTask),
181-
removeClineFromStack,
182-
createTask,
183-
getTaskWithId,
184-
updateTaskHistory,
185-
deleteTaskFromState,
186-
createTaskWithHistoryItem,
187-
handleModeSwitch,
188-
log: vi.fn(),
189-
} as unknown as ClineProvider
190-
191-
await expect(
192-
(ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
193-
parentTaskId: "parent-1",
194-
message: "Do something",
195-
initialTodos: [],
196-
mode: "code",
197-
}),
198-
).rejects.toThrow("history write failed")
199-
200-
expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, {
201-
initialTodos: [],
202-
initialStatus: "active",
203-
startTask: false,
204-
})
205-
expect(childStart).not.toHaveBeenCalled()
206-
expect(removeClineFromStack).toHaveBeenNthCalledWith(1, { skipDelegationRepair: true })
207-
expect(removeClineFromStack).toHaveBeenNthCalledWith(2, { skipDelegationRepair: true })
208-
expect(deleteTaskFromState).toHaveBeenCalledWith("child-1")
209-
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(
210-
{ ...parentHistory, mode: "architect" },
211-
{ startTask: false },
212-
)
213-
expect(providerEmit).not.toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1")
214-
})
215-
216-
it("logs rollback cleanup failures while preserving the parent metadata error", async () => {
217-
const parentTask = {
218-
taskId: "parent-1",
219-
emit: vi.fn(),
220-
getTaskMode: vi.fn().mockRejectedValue(new Error("mode unavailable")),
221-
} as any
222-
const childStart = vi.fn()
223-
const persistError = new Error("history write failed")
224-
const log = vi.fn()
225-
226-
const removeClineFromStack = vi
227-
.fn()
228-
.mockResolvedValueOnce(undefined)
229-
.mockRejectedValueOnce(new Error("child stack cleanup failed"))
230-
const deleteTaskFromState = vi.fn().mockRejectedValue(new Error("child history cleanup failed"))
231-
const createTaskWithHistoryItem = vi.fn().mockRejectedValue(new Error("parent rehydrate failed"))
232-
const parentHistory = {
233-
id: "parent-1",
234-
task: "Parent",
235-
tokensIn: 0,
236-
tokensOut: 0,
237-
totalCost: 0,
238-
childIds: [],
239-
mode: "ask",
240-
}
241-
242-
const provider = {
243-
emit: vi.fn(),
244-
getCurrentTask: vi.fn(() => parentTask),
245-
removeClineFromStack,
246-
createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart }),
247-
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistory }),
248-
updateTaskHistory: vi.fn().mockRejectedValue(persistError),
249-
deleteTaskFromState,
250-
createTaskWithHistoryItem,
251-
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
252-
log,
253-
} as unknown as ClineProvider
254-
255-
await expect(
256-
(ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
257-
parentTaskId: "parent-1",
258-
message: "Do something",
259-
initialTodos: [],
260-
mode: "code",
261-
}),
262-
).rejects.toThrow("history write failed")
263-
264-
expect(childStart).not.toHaveBeenCalled()
265-
expect(deleteTaskFromState).toHaveBeenCalledWith("child-1")
266-
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistory, { startTask: false })
267-
expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed to capture parent mode"))
268-
expect(log).toHaveBeenCalledWith(
269-
expect.stringContaining("Failed to remove child child-1 after parent metadata"),
270-
)
271-
expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed to remove child child-1 history"))
272-
expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed to rehydrate parent parent-1"))
273-
})
274148
})

src/core/webview/ClineProvider.ts

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3227,16 +3227,6 @@ export class ClineProvider
32273227
`[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`,
32283228
)
32293229
}
3230-
let parentModeBeforeDelegation: string | undefined
3231-
try {
3232-
parentModeBeforeDelegation = await parent.getTaskMode()
3233-
} catch (error) {
3234-
this.log(
3235-
`[delegateParentAndOpenChild] Failed to capture parent mode for ${parentTaskId} before delegation (non-fatal): ${
3236-
error instanceof Error ? error.message : String(error)
3237-
}`,
3238-
)
3239-
}
32403230
// 2) Flush pending tool results to API history BEFORE disposing the parent.
32413231
// This is critical: when tools are called before new_task,
32423232
// their tool_result blocks are in userMessageContent but not yet saved to API history.
@@ -3317,10 +3307,8 @@ export class ClineProvider
33173307
})
33183308

33193309
// 5) Persist parent delegation metadata BEFORE the child starts writing.
3320-
let parentHistoryBeforeDelegation: HistoryItem | undefined
33213310
try {
33223311
const { historyItem } = await this.getTaskWithId(parentTaskId)
3323-
parentHistoryBeforeDelegation = historyItem
33243312
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId]))
33253313
const updatedHistory: typeof historyItem = {
33263314
...historyItem,
@@ -3336,41 +3324,6 @@ export class ClineProvider
33363324
(err as Error)?.message ?? String(err)
33373325
}`,
33383326
)
3339-
try {
3340-
await this.removeClineFromStack({ skipDelegationRepair: true })
3341-
} catch (cleanupErr) {
3342-
this.log(
3343-
`[delegateParentAndOpenChild] Failed to remove child ${child.taskId} after parent metadata persistence failure (non-fatal): ${
3344-
(cleanupErr as Error)?.message ?? String(cleanupErr)
3345-
}`,
3346-
)
3347-
}
3348-
try {
3349-
await this.deleteTaskFromState(child.taskId)
3350-
} catch (cleanupErr) {
3351-
this.log(
3352-
`[delegateParentAndOpenChild] Failed to remove child ${child.taskId} history after parent metadata persistence failure (non-fatal): ${
3353-
(cleanupErr as Error)?.message ?? String(cleanupErr)
3354-
}`,
3355-
)
3356-
}
3357-
if (parentHistoryBeforeDelegation) {
3358-
try {
3359-
await this.createTaskWithHistoryItem(
3360-
{
3361-
...parentHistoryBeforeDelegation,
3362-
mode: parentModeBeforeDelegation ?? parentHistoryBeforeDelegation.mode,
3363-
},
3364-
{ startTask: false },
3365-
)
3366-
} catch (cleanupErr) {
3367-
this.log(
3368-
`[delegateParentAndOpenChild] Failed to rehydrate parent ${parentTaskId} after parent metadata persistence failure (non-fatal): ${
3369-
(cleanupErr as Error)?.message ?? String(cleanupErr)
3370-
}`,
3371-
)
3372-
}
3373-
}
33743327
throw err
33753328
}
33763329

0 commit comments

Comments
 (0)