Skip to content

Commit 8c17b41

Browse files
authored
test(task-lifecycle): add regression coverage for issue #566 (interrupted subtask parent-link) (#911)
1 parent 6e05ae9 commit 8c17b41

3 files changed

Lines changed: 380 additions & 0 deletions

File tree

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE"
77
const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE"
88
const SUBTASK_INTERRUPT_PARENT_MARKER = "SUBTASK_PARENT_INTERRUPT_RESUME"
99
const SUBTASK_INTERRUPT_CHILD_MARKER = "SUBTASK_CHILD_INTERRUPT_RESUME"
10+
export const SUBTASK_API_HANG_PARENT_MARKER = "SUBTASK_PARENT_API_HANG_INTERRUPT_RESUME"
11+
export const SUBTASK_API_HANG_CHILD_MARKER = "SUBTASK_CHILD_API_HANG_INTERRUPT_RESUME"
1012
const SUBTASK_FAST_PARENT_MARKER = "SUBTASK_PARENT_IMMEDIATE_COMPLETION"
1113
const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION"
1214
const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE"
@@ -24,13 +26,21 @@ export const SUBTASK_INTERRUPT_PARENT_PROMPT = `${SUBTASK_INTERRUPT_PARENT_MARKE
2426
export const SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER = "9"
2527
export const SUBTASK_INTERRUPT_PARENT_RESULT = "Interrupted parent resumed"
2628

29+
const SUBTASK_API_HANG_CHILD_PROMPT = `${SUBTASK_API_HANG_CHILD_MARKER}: Complete with the exact result "Hung child completed".`
30+
export const SUBTASK_API_HANG_PARENT_PROMPT = `${SUBTASK_API_HANG_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_API_HANG_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "API hang parent resumed".`
31+
export const SUBTASK_API_HANG_RESUME_MESSAGE = "Continue after provider hang."
32+
export const SUBTASK_API_HANG_CHILD_RESULT = "Hung child completed"
33+
export const SUBTASK_API_HANG_PARENT_RESULT = "API hang parent resumed"
34+
2735
const SUBTASK_XPROFILE_SAME_CHILD_PROMPT = `${SUBTASK_XPROFILE_SAME_CHILD_MARKER}: Complete immediately with the exact result "Same-profile child completed".`
2836
const SUBTASK_XPROFILE_DIFFERENT_CHILD_PROMPT = `${SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER}: Complete immediately with the exact result "Different-profile child completed".`
2937
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.`
3038
export const SUBTASK_XPROFILE_SAME_CHILD_RESULT = "Same-profile child completed"
3139
export const SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT = "Different-profile child completed"
3240
export const SUBTASK_XPROFILE_PARENT_RESULT = "Sequential cross-profile parent resumed"
3341

42+
const apiHangChildMatch = new RegExp(SUBTASK_API_HANG_CHILD_MARKER)
43+
3444
const requestContains = (req: ChatCompletionRequest, expected: string[]) => {
3545
const rawRequest = JSON.stringify(req)
3646
return expected.every((text) => rawRequest.includes(text))
@@ -167,6 +177,79 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
167177
},
168178
})
169179

180+
mock.addFixture({
181+
match: {
182+
userMessage: new RegExp(SUBTASK_API_HANG_PARENT_MARKER),
183+
sequenceIndex: 0,
184+
},
185+
response: {
186+
toolCalls: [
187+
{
188+
name: "new_task",
189+
arguments: JSON.stringify({
190+
mode: "ask",
191+
message: SUBTASK_API_HANG_CHILD_PROMPT,
192+
}),
193+
id: "call_api_hang_parent_new_task_001",
194+
},
195+
],
196+
},
197+
})
198+
199+
mock.addFixture({
200+
match: {
201+
userMessage: apiHangChildMatch,
202+
sequenceIndex: 0,
203+
},
204+
// Keep the first child response pending long enough for the e2e test to cancel an in-flight API request.
205+
latency: 15_000,
206+
response: {
207+
toolCalls: [
208+
{
209+
name: "attempt_completion",
210+
arguments: JSON.stringify({ result: SUBTASK_API_HANG_CHILD_RESULT }),
211+
id: "call_api_hang_child_completion_002",
212+
},
213+
],
214+
},
215+
})
216+
217+
mock.addFixture({
218+
match: {
219+
userMessage: apiHangChildMatch,
220+
sequenceIndex: 1,
221+
},
222+
response: {
223+
toolCalls: [
224+
{
225+
name: "attempt_completion",
226+
arguments: JSON.stringify({ result: SUBTASK_API_HANG_CHILD_RESULT }),
227+
id: "call_api_hang_child_completion_003",
228+
},
229+
],
230+
},
231+
})
232+
233+
mock.addFixture({
234+
match: {
235+
predicate: (req: ChatCompletionRequest) =>
236+
requestContains(req, [
237+
SUBTASK_API_HANG_PARENT_MARKER,
238+
"call_api_hang_parent_new_task_001",
239+
SUBTASK_API_HANG_CHILD_RESULT,
240+
]),
241+
},
242+
response: {
243+
toolCalls: [
244+
{
245+
name: "attempt_completion",
246+
arguments: JSON.stringify({ result: SUBTASK_API_HANG_PARENT_RESULT }),
247+
id: "call_api_hang_parent_completion_004",
248+
},
249+
],
250+
},
251+
})
252+
170253
// Issue #457 sequence: a same-profile child returns first, then the resumed
171254
// parent delegates to a child whose mode uses a different API profile.
172255
mock.addFixture({

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

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
55
import { setDefaultSuiteTimeout } from "./test-utils"
66
import { sleep, waitFor, waitUntilCompleted } from "./utils"
77
import {
8+
SUBTASK_API_HANG_CHILD_RESULT,
9+
SUBTASK_API_HANG_CHILD_MARKER,
10+
SUBTASK_API_HANG_PARENT_MARKER,
11+
SUBTASK_API_HANG_PARENT_PROMPT,
12+
SUBTASK_API_HANG_PARENT_RESULT,
13+
SUBTASK_API_HANG_RESUME_MESSAGE,
814
SUBTASK_CHILD_FOLLOWUP_ANSWER,
915
SUBTASK_FAST_PARENT_PROMPT,
1016
SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
@@ -17,6 +23,45 @@ import {
1723
SUBTASK_XPROFILE_SAME_CHILD_RESULT,
1824
} from "../fixtures/subtasks"
1925

26+
type AimockMessageContent = string | Array<{ type?: string; text?: string }>
27+
28+
type AimockJournalEntry = {
29+
body?: {
30+
messages?: Array<{
31+
role?: string
32+
content?: AimockMessageContent
33+
}>
34+
}
35+
}
36+
37+
const messageContentText = (content?: AimockMessageContent) => {
38+
if (typeof content === "string") {
39+
return content
40+
}
41+
42+
return content?.map((part) => part.text ?? "").join("") ?? ""
43+
}
44+
45+
const waitForAimockRequestContaining = async (expectedText: string, excludeText?: string) => {
46+
const aimockUrl = process.env.AIMOCK_URL
47+
assert.ok(aimockUrl, "AIMOCK_URL must be set for aimock journal assertions")
48+
49+
await waitFor(async () => {
50+
const response = await fetch(`${aimockUrl}/__aimock/journal`)
51+
const entries = (await response.json()) as AimockJournalEntry[]
52+
53+
return entries.some((entry) => {
54+
const messages = entry.body?.messages
55+
if (!messages) return false
56+
const entryText = messages.map((m) => messageContentText(m.content)).join("")
57+
if (excludeText && entryText.includes(excludeText)) return false
58+
return messages.some(
59+
(message) => message.role === "user" && messageContentText(message.content).includes(expectedText),
60+
)
61+
})
62+
})
63+
}
64+
2065
suite("Roo Code Subtasks", function () {
2166
setDefaultSuiteTimeout(this)
2267

@@ -412,6 +457,113 @@ suite("Roo Code Subtasks", function () {
412457
}
413458
})
414459

460+
// Issue #566: a child interrupted while its provider request is still pending
461+
// must keep its parent link when manually resumed and completed.
462+
test("API-hung interrupted child resumes and returns to parent", async () => {
463+
const api = globalThis.api
464+
const asks: Record<string, ClineMessage[]> = {}
465+
const says: Record<string, ClineMessage[]> = {}
466+
467+
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
468+
if (message.type === "ask") {
469+
asks[taskId] = asks[taskId] || []
470+
asks[taskId].push(message)
471+
}
472+
if (message.type === "say" && message.partial === false) {
473+
says[taskId] = says[taskId] || []
474+
says[taskId].push(message)
475+
}
476+
}
477+
478+
api.on(RooCodeEventName.Message, messageHandler)
479+
480+
try {
481+
const parentTaskId = await api.startNewTask({
482+
configuration: {
483+
mode: "ask",
484+
alwaysAllowModeSwitch: true,
485+
alwaysAllowSubtasks: true,
486+
autoApprovalEnabled: true,
487+
enableCheckpoints: false,
488+
},
489+
text: SUBTASK_API_HANG_PARENT_PROMPT,
490+
})
491+
492+
let childTaskId: string | undefined
493+
await waitFor(() => {
494+
const stack = api.getCurrentTaskStack()
495+
const current = stack[stack.length - 1]
496+
if (current && current !== parentTaskId) {
497+
childTaskId = current
498+
return true
499+
}
500+
return false
501+
})
502+
503+
await waitForAimockRequestContaining(SUBTASK_API_HANG_CHILD_MARKER, SUBTASK_API_HANG_PARENT_MARKER)
504+
505+
await api.cancelCurrentTask()
506+
507+
await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId)
508+
await waitFor(
509+
() => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
510+
)
511+
512+
const interruptedChild = await api.getTaskHistoryItem(childTaskId!)
513+
assert.strictEqual(interruptedChild?.status, "interrupted", "Child should be interrupted after manual stop")
514+
assert.strictEqual(
515+
interruptedChild?.parentTaskId,
516+
parentTaskId,
517+
"Interrupted child should retain its parent link before resume",
518+
)
519+
520+
const completedParentTaskId = await waitUntilCompleted({
521+
api,
522+
start: async () => {
523+
await api.sendMessage(SUBTASK_API_HANG_RESUME_MESSAGE)
524+
return parentTaskId
525+
},
526+
})
527+
528+
assert.strictEqual(
529+
completedParentTaskId,
530+
parentTaskId,
531+
"Parent task should complete after API-hung child resumes and reports back",
532+
)
533+
assert.strictEqual(
534+
says[childTaskId!]
535+
?.filter(({ say }) => say === "completion_result")
536+
.map(({ text }) => text?.trim())
537+
.find((text) => text === SUBTASK_API_HANG_CHILD_RESULT),
538+
SUBTASK_API_HANG_CHILD_RESULT,
539+
"Child should complete with its expected result after resume",
540+
)
541+
assert.strictEqual(
542+
says[parentTaskId]
543+
?.filter(({ say }) => say === "completion_result")
544+
.map(({ text }) => text?.trim())
545+
.find((text) => text === SUBTASK_API_HANG_PARENT_RESULT),
546+
SUBTASK_API_HANG_PARENT_RESULT,
547+
"Parent should resume and complete with its expected result",
548+
)
549+
550+
const parent = await api.getTaskHistoryItem(parentTaskId)
551+
assert.notStrictEqual(parent?.status, "delegated", "Parent history should not remain delegated")
552+
assert.strictEqual(parent?.awaitingChildId, undefined, "Parent awaitingChildId should be cleared")
553+
assert.strictEqual(parent?.completedByChildId, childTaskId, "Parent should record completed child")
554+
555+
const child = await api.getTaskHistoryItem(childTaskId!)
556+
assert.strictEqual(child?.status, "completed", "Child history should be completed")
557+
assert.strictEqual(child?.parentTaskId, parentTaskId, "Completed child should still point to parent")
558+
} finally {
559+
api.off(RooCodeEventName.Message, messageHandler)
560+
while (api.getCurrentTaskStack().length > 0) {
561+
await api.clearCurrentTask()
562+
}
563+
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
564+
}
565+
})
566+
415567
test("same-profile child returns before a different-profile child", async () => {
416568
const api = globalThis.api
417569
const says: Record<string, ClineMessage[]> = {}

0 commit comments

Comments
 (0)