Skip to content

Commit 2d0e42a

Browse files
committed
test(e2e): interrupted child resumes and reports back to parent
1 parent 71a4981 commit 2d0e42a

5 files changed

Lines changed: 228 additions & 5 deletions

File tree

apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface HistoryResult extends AutocompleteItem {
2121
/** Mode the task was run in */
2222
mode?: string
2323
/** Task status */
24-
status?: "active" | "completed" | "delegated"
24+
status?: "active" | "completed" | "delegated" | "interrupted"
2525
}
2626

2727
/**
@@ -133,8 +133,22 @@ export function createHistoryTrigger(config: HistoryTriggerConfig): Autocomplete
133133

134134
renderItem: (item: HistoryResult, isSelected: boolean) => {
135135
// Status indicator
136-
const statusIcon = item.status === "completed" ? "✓" : item.status === "active" ? "●" : "○"
137-
const statusColor = item.status === "completed" ? "green" : item.status === "active" ? "yellow" : "gray"
136+
const statusIcon =
137+
item.status === "completed"
138+
? "✓"
139+
: item.status === "active"
140+
? "●"
141+
: item.status === "interrupted"
142+
? "⏸"
143+
: "○"
144+
const statusColor =
145+
item.status === "completed"
146+
? "green"
147+
: item.status === "active"
148+
? "yellow"
149+
: item.status === "interrupted"
150+
? "cyan"
151+
: "gray"
138152

139153
// Mode indicator (if available)
140154
const modeText = item.mode ? ` [${item.mode}]` : ""
@@ -178,7 +192,7 @@ export function toHistoryResult(item: {
178192
totalCost?: number
179193
workspace?: string
180194
mode?: string
181-
status?: "active" | "completed" | "delegated"
195+
status?: "active" | "completed" | "delegated" | "interrupted"
182196
}): HistoryResult {
183197
return {
184198
key: item.id, // Use task ID as the unique key

apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,24 @@ describe("HistoryTrigger", () => {
188188
expect(output).toContain("○")
189189
})
190190

191+
it("should render interrupted status with correct indicator", () => {
192+
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
193+
194+
const interruptedItem: HistoryResult = {
195+
key: "task-interrupted",
196+
id: "task-interrupted",
197+
task: "Interrupted subtask waiting to resume",
198+
ts: Date.now() - 1000 * 60 * 5,
199+
mode: "ask",
200+
status: "interrupted",
201+
}
202+
const { lastFrame } = render(trigger.renderItem(interruptedItem, false) as React.ReactElement)
203+
204+
const output = lastFrame()
205+
// Should contain the interrupted status indicator (⏸)
206+
expect(output).toContain("⏸")
207+
})
208+
191209
it("should render selected items with different styling", () => {
192210
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
193211

apps/cli/src/ui/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export interface TaskHistoryItem {
109109
totalCost?: number
110110
workspace?: string
111111
mode?: string
112-
status?: "active" | "completed" | "delegated"
112+
status?: "active" | "completed" | "delegated" | "interrupted"
113113
tokensIn?: number
114114
tokensOut?: number
115115
}

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { toolResultContains } from "./tool-result"
55

66
const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE"
77
const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE"
8+
const SUBTASK_INTERRUPT_PARENT_MARKER = "SUBTASK_PARENT_INTERRUPT_RESUME"
9+
const SUBTASK_INTERRUPT_CHILD_MARKER = "SUBTASK_CHILD_INTERRUPT_RESUME"
810
const SUBTASK_FAST_PARENT_MARKER = "SUBTASK_PARENT_IMMEDIATE_COMPLETION"
911
const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION"
1012
const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE"
@@ -17,6 +19,11 @@ export const SUBTASK_CHILD_FOLLOWUP_ANSWER = "9"
1719
const SUBTASK_FAST_CHILD_PROMPT = `${SUBTASK_FAST_CHILD_MARKER}: Complete immediately with the exact result "Fast child completed".`
1820
export const SUBTASK_FAST_PARENT_PROMPT = `${SUBTASK_FAST_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_FAST_CHILD_PROMPT}" Do not answer directly.`
1921

22+
const SUBTASK_INTERRUPT_CHILD_PROMPT = `${SUBTASK_INTERRUPT_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.`
23+
export const SUBTASK_INTERRUPT_PARENT_PROMPT = `${SUBTASK_INTERRUPT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_INTERRUPT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "Interrupted parent resumed".`
24+
export const SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER = "9"
25+
export const SUBTASK_INTERRUPT_PARENT_RESULT = "Interrupted parent resumed"
26+
2027
const SUBTASK_XPROFILE_SAME_CHILD_PROMPT = `${SUBTASK_XPROFILE_SAME_CHILD_MARKER}: Complete immediately with the exact result "Same-profile child completed".`
2128
const SUBTASK_XPROFILE_DIFFERENT_CHILD_PROMPT = `${SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER}: Complete immediately with the exact result "Different-profile child completed".`
2229
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.`
@@ -252,4 +259,87 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
252259
],
253260
},
254261
})
262+
263+
// Interrupted-child-resumes-and-reports-back scenario (#560)
264+
mock.addFixture({
265+
match: {
266+
userMessage: new RegExp(SUBTASK_INTERRUPT_PARENT_MARKER),
267+
sequenceIndex: 0,
268+
},
269+
response: {
270+
toolCalls: [
271+
{
272+
name: "new_task",
273+
arguments: JSON.stringify({
274+
mode: "ask",
275+
message: SUBTASK_INTERRUPT_CHILD_PROMPT,
276+
}),
277+
id: "call_interrupt_parent_new_task_001",
278+
},
279+
],
280+
},
281+
})
282+
283+
// The parent prompt embeds SUBTASK_INTERRUPT_CHILD_MARKER verbatim, so parent-resume turns
284+
// also match a bare substring check. Exclude the parent marker so they fall through.
285+
mock.addFixture({
286+
match: {
287+
predicate: (req: ChatCompletionRequest) =>
288+
requestContains(req, [SUBTASK_INTERRUPT_CHILD_MARKER]) &&
289+
!requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER]),
290+
},
291+
response: {
292+
toolCalls: [
293+
{
294+
name: "ask_followup_question",
295+
arguments: JSON.stringify({
296+
question: "What is the square root of 81?",
297+
follow_up: [{ text: SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER }],
298+
}),
299+
id: "call_interrupt_child_followup_001",
300+
},
301+
],
302+
},
303+
})
304+
305+
mock.addFixture({
306+
match: {
307+
predicate: (req: ChatCompletionRequest) =>
308+
// Preferred: structured tool-result message carries the followup answer.
309+
toolResultContains(req, "call_interrupt_child_followup_001", [
310+
SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
311+
]) ||
312+
// Fallback 1: answer present alongside the tool-call ID.
313+
requestContains(req, ["call_interrupt_child_followup_001", SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER]) ||
314+
// Fallback 2: answer arrives as a bare user message after task resume.
315+
requestContains(req, [
316+
SUBTASK_INTERRUPT_CHILD_MARKER,
317+
`<user_message>\\n${SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`,
318+
]),
319+
},
320+
response: {
321+
toolCalls: [
322+
{
323+
name: "attempt_completion",
324+
arguments: JSON.stringify({ result: "9" }),
325+
id: "call_interrupt_child_completion_002",
326+
},
327+
],
328+
},
329+
})
330+
331+
mock.addFixture({
332+
match: {
333+
toolCallId: "call_interrupt_parent_new_task_001",
334+
},
335+
response: {
336+
toolCalls: [
337+
{
338+
name: "attempt_completion",
339+
arguments: JSON.stringify({ result: "Interrupted parent resumed" }),
340+
id: "call_interrupt_parent_completion_003",
341+
},
342+
],
343+
},
344+
})
255345
}

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { waitFor, waitUntilCompleted } from "./utils"
77
import {
88
SUBTASK_CHILD_FOLLOWUP_ANSWER,
99
SUBTASK_FAST_PARENT_PROMPT,
10+
SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
11+
SUBTASK_INTERRUPT_PARENT_PROMPT,
12+
SUBTASK_INTERRUPT_PARENT_RESULT,
1013
SUBTASK_PARENT_PROMPT,
1114
SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT,
1215
SUBTASK_XPROFILE_PARENT_PROMPT,
@@ -439,4 +442,102 @@ suite("Roo Code Subtasks", function () {
439442
}
440443
}
441444
})
445+
446+
// Issue #560: interrupted child resumes and reports back to parent.
447+
// Before the fix, cancelTask() severed the parent link, so the resumed child
448+
// fell through to "Start New Task" instead of delegating back to the parent.
449+
test("interrupted child resumes and reports back to parent", async () => {
450+
const api = globalThis.api
451+
const asks: Record<string, ClineMessage[]> = {}
452+
const says: Record<string, ClineMessage[]> = {}
453+
454+
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
455+
if (message.type === "ask") {
456+
asks[taskId] = asks[taskId] || []
457+
asks[taskId].push(message)
458+
}
459+
if (message.type === "say" && message.partial === false) {
460+
says[taskId] = says[taskId] || []
461+
says[taskId].push(message)
462+
}
463+
}
464+
465+
api.on(RooCodeEventName.Message, messageHandler)
466+
467+
try {
468+
const parentTaskId = await api.startNewTask({
469+
configuration: {
470+
mode: "ask",
471+
alwaysAllowModeSwitch: true,
472+
alwaysAllowSubtasks: true,
473+
autoApprovalEnabled: true,
474+
enableCheckpoints: false,
475+
},
476+
text: SUBTASK_INTERRUPT_PARENT_PROMPT,
477+
})
478+
479+
// Wait for child to spawn
480+
let childTaskId: string | undefined
481+
await waitFor(() => {
482+
const stack = api.getCurrentTaskStack()
483+
const current = stack[stack.length - 1]
484+
if (current && current !== parentTaskId) {
485+
childTaskId = current
486+
return true
487+
}
488+
return false
489+
})
490+
491+
// Wait for the child's followup question
492+
await waitFor(() => asks[childTaskId!]?.some(({ ask }) => ask === "followup") ?? false)
493+
494+
// Cancel the child — it should be marked "interrupted", parent stays "delegated"
495+
await api.cancelCurrentTask()
496+
497+
// Child should be back on the stack (rehydrated as interrupted)
498+
await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId)
499+
await waitFor(
500+
() => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
501+
)
502+
503+
// Parent must not have resumed yet
504+
assert.strictEqual(
505+
says[parentTaskId]?.find(({ say }) => say === "completion_result"),
506+
undefined,
507+
"Parent must not have resumed while child is interrupted",
508+
)
509+
510+
// Resume the child and answer the followup — child should complete and reopen parent
511+
const completedParentTaskId = await waitUntilCompleted({
512+
api,
513+
start: async () => {
514+
await api.sendMessage(SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER)
515+
return parentTaskId
516+
},
517+
})
518+
519+
assert.strictEqual(
520+
completedParentTaskId,
521+
parentTaskId,
522+
"Parent task should be the one that completes after interrupted child reports back",
523+
)
524+
525+
const parentCompletionText = says[parentTaskId]
526+
?.filter(({ say }) => say === "completion_result")
527+
.map(({ text }) => text?.trim())
528+
.find((t): t is string => !!t)
529+
530+
assert.strictEqual(
531+
parentCompletionText,
532+
SUBTASK_INTERRUPT_PARENT_RESULT,
533+
"Parent should complete with expected result after interrupted child reports back",
534+
)
535+
} finally {
536+
api.off(RooCodeEventName.Message, messageHandler)
537+
while (api.getCurrentTaskStack().length > 0) {
538+
await api.clearCurrentTask()
539+
}
540+
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
541+
}
542+
})
442543
})

0 commit comments

Comments
 (0)