Skip to content

Commit 737f27d

Browse files
edelaunanavedmerchantTest User
authored
fix(task-lifecycle): preserve parent-child link when delegated subtask is interrupted (#560) (#787)
* fix(task-lifecycle): preserve parent-child link when delegated subtask is interrupted * test(e2e): interrupted child resumes and reports back to parent * Fix interrupted subtask resume state restoration * fix(task-lifecycle): prevent parent repair from winning race against child cancellation * fix(api): return 0 instead of throwing for unavailable task history length * test(webview-settings): increase change-detection test timeout to resolve CI flakes --------- Co-authored-by: Naved Merchant <naved.merchant@gmail.com> Co-authored-by: Test User <test@example.com>
1 parent 21a15e5 commit 737f27d

26 files changed

Lines changed: 861 additions & 127 deletions

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: 112 additions & 10 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.`
@@ -32,15 +39,17 @@ const requestContains = (req: ChatCompletionRequest, expected: string[]) => {
3239
const completionAfterAnswer = (followupId: string, completionId: string) => ({
3340
match: {
3441
predicate: (req: ChatCompletionRequest) =>
42+
!requestContains(req, [SUBTASK_INTERRUPT_CHILD_MARKER]) &&
43+
!requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER]) &&
3544
// Preferred: structured tool-result message carries the followup answer.
36-
toolResultContains(req, followupId, [SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
37-
// Fallback 1: answer present alongside the tool-call ID but not in a role:tool message.
38-
requestContains(req, [followupId, SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
39-
// Fallback 2: answer arrives as a bare user message after task resume (no tool-call ID context).
40-
requestContains(req, [
41-
SUBTASK_CHILD_MARKER,
42-
`<user_message>\\n${SUBTASK_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`,
43-
]),
45+
(toolResultContains(req, followupId, [SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
46+
// Fallback 1: answer present alongside the tool-call ID but not in a role:tool message.
47+
requestContains(req, [followupId, SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
48+
// Fallback 2: answer arrives as a bare user message after task resume (no tool-call ID context).
49+
requestContains(req, [
50+
SUBTASK_CHILD_MARKER,
51+
`<user_message>\\n${SUBTASK_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`,
52+
])),
4453
},
4554
response: {
4655
toolCalls: [
@@ -90,7 +99,8 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
9099

91100
mock.addFixture({
92101
match: {
93-
toolCallId: "call_subtasks_fast_parent_new_task_001",
102+
predicate: (req: ChatCompletionRequest) =>
103+
requestContains(req, [SUBTASK_FAST_PARENT_MARKER, "call_subtasks_fast_parent_new_task_001"]),
94104
},
95105
response: {
96106
toolCalls: [
@@ -143,7 +153,8 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
143153

144154
mock.addFixture({
145155
match: {
146-
toolCallId: "call_subtasks_parent_new_task_001",
156+
predicate: (req: ChatCompletionRequest) =>
157+
requestContains(req, [SUBTASK_PARENT_MARKER, "call_subtasks_parent_new_task_001"]),
147158
},
148159
response: {
149160
toolCalls: [
@@ -252,4 +263,95 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
252263
],
253264
},
254265
})
266+
267+
// Interrupted-child-resumes-and-reports-back scenario (#560)
268+
mock.addFixture({
269+
match: {
270+
userMessage: new RegExp(SUBTASK_INTERRUPT_PARENT_MARKER),
271+
sequenceIndex: 0,
272+
},
273+
response: {
274+
toolCalls: [
275+
{
276+
name: "new_task",
277+
arguments: JSON.stringify({
278+
mode: "ask",
279+
message: SUBTASK_INTERRUPT_CHILD_PROMPT,
280+
}),
281+
id: "call_interrupt_parent_new_task_001",
282+
},
283+
],
284+
},
285+
})
286+
287+
// The parent prompt embeds SUBTASK_INTERRUPT_CHILD_MARKER verbatim, so parent-resume turns
288+
// also match a bare substring check. Exclude the parent marker so they fall through.
289+
// The answer exclusion must use the <user_message> wrapping: the bare answer is a single
290+
// digit that can appear anywhere in the serialized request (timestamps in environment
291+
// details, token counts), which would make this fixture unmatchable.
292+
mock.addFixture({
293+
match: {
294+
predicate: (req: ChatCompletionRequest) =>
295+
requestContains(req, [SUBTASK_INTERRUPT_CHILD_MARKER]) &&
296+
!requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER]) &&
297+
!requestContains(req, ["call_interrupt_child_followup_001"]) &&
298+
!requestContains(req, [
299+
`<user_message>\\n${SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`,
300+
]),
301+
},
302+
response: {
303+
toolCalls: [
304+
{
305+
name: "ask_followup_question",
306+
arguments: JSON.stringify({
307+
question: "What is the square root of 81?",
308+
follow_up: [{ text: SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER }],
309+
}),
310+
id: "call_interrupt_child_followup_001",
311+
},
312+
],
313+
},
314+
})
315+
316+
mock.addFixture({
317+
match: {
318+
predicate: (req: ChatCompletionRequest) =>
319+
// Preferred: structured tool-result message carries the followup answer.
320+
toolResultContains(req, "call_interrupt_child_followup_001", [
321+
SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
322+
]) ||
323+
// Fallback 1: answer present alongside the tool-call ID.
324+
requestContains(req, ["call_interrupt_child_followup_001", SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER]) ||
325+
// Fallback 2: answer arrives as a bare user message after task resume.
326+
requestContains(req, [
327+
SUBTASK_INTERRUPT_CHILD_MARKER,
328+
`<user_message>\\n${SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`,
329+
]),
330+
},
331+
response: {
332+
toolCalls: [
333+
{
334+
name: "attempt_completion",
335+
arguments: JSON.stringify({ result: SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER }),
336+
id: "call_interrupt_child_completion_002",
337+
},
338+
],
339+
},
340+
})
341+
342+
mock.addFixture({
343+
match: {
344+
predicate: (req: ChatCompletionRequest) =>
345+
requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER, "call_interrupt_parent_new_task_001"]),
346+
},
347+
response: {
348+
toolCalls: [
349+
{
350+
name: "attempt_completion",
351+
arguments: JSON.stringify({ result: SUBTASK_INTERRUPT_PARENT_RESULT }),
352+
id: "call_interrupt_parent_completion_003",
353+
},
354+
],
355+
},
356+
})
255357
}

0 commit comments

Comments
 (0)