diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx
index 443fdfa979..f1be762244 100644
--- a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx
+++ b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx
@@ -21,7 +21,7 @@ export interface HistoryResult extends AutocompleteItem {
/** Mode the task was run in */
mode?: string
/** Task status */
- status?: "active" | "completed" | "delegated"
+ status?: "active" | "completed" | "delegated" | "interrupted"
}
/**
@@ -133,8 +133,22 @@ export function createHistoryTrigger(config: HistoryTriggerConfig): Autocomplete
renderItem: (item: HistoryResult, isSelected: boolean) => {
// Status indicator
- const statusIcon = item.status === "completed" ? "✓" : item.status === "active" ? "●" : "○"
- const statusColor = item.status === "completed" ? "green" : item.status === "active" ? "yellow" : "gray"
+ const statusIcon =
+ item.status === "completed"
+ ? "✓"
+ : item.status === "active"
+ ? "●"
+ : item.status === "interrupted"
+ ? "⏸"
+ : "○"
+ const statusColor =
+ item.status === "completed"
+ ? "green"
+ : item.status === "active"
+ ? "yellow"
+ : item.status === "interrupted"
+ ? "cyan"
+ : "gray"
// Mode indicator (if available)
const modeText = item.mode ? ` [${item.mode}]` : ""
@@ -178,7 +192,7 @@ export function toHistoryResult(item: {
totalCost?: number
workspace?: string
mode?: string
- status?: "active" | "completed" | "delegated"
+ status?: "active" | "completed" | "delegated" | "interrupted"
}): HistoryResult {
return {
key: item.id, // Use task ID as the unique key
diff --git a/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx
index 8e5906ac7c..1d1a891425 100644
--- a/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx
+++ b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx
@@ -188,6 +188,24 @@ describe("HistoryTrigger", () => {
expect(output).toContain("○")
})
+ it("should render interrupted status with correct indicator", () => {
+ const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
+
+ const interruptedItem: HistoryResult = {
+ key: "task-interrupted",
+ id: "task-interrupted",
+ task: "Interrupted subtask waiting to resume",
+ ts: Date.now() - 1000 * 60 * 5,
+ mode: "ask",
+ status: "interrupted",
+ }
+ const { lastFrame } = render(trigger.renderItem(interruptedItem, false) as React.ReactElement)
+
+ const output = lastFrame()
+ // Should contain the interrupted status indicator (⏸)
+ expect(output).toContain("⏸")
+ })
+
it("should render selected items with different styling", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts
index 3c45377c67..b3c8944c22 100644
--- a/apps/cli/src/ui/types.ts
+++ b/apps/cli/src/ui/types.ts
@@ -109,7 +109,7 @@ export interface TaskHistoryItem {
totalCost?: number
workspace?: string
mode?: string
- status?: "active" | "completed" | "delegated"
+ status?: "active" | "completed" | "delegated" | "interrupted"
tokensIn?: number
tokensOut?: number
}
diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts
index bb639dde9c..772eb3306a 100644
--- a/apps/vscode-e2e/src/fixtures/subtasks.ts
+++ b/apps/vscode-e2e/src/fixtures/subtasks.ts
@@ -5,6 +5,8 @@ import { toolResultContains } from "./tool-result"
const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE"
const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE"
+const SUBTASK_INTERRUPT_PARENT_MARKER = "SUBTASK_PARENT_INTERRUPT_RESUME"
+const SUBTASK_INTERRUPT_CHILD_MARKER = "SUBTASK_CHILD_INTERRUPT_RESUME"
const SUBTASK_FAST_PARENT_MARKER = "SUBTASK_PARENT_IMMEDIATE_COMPLETION"
const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION"
const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE"
@@ -17,6 +19,11 @@ export const SUBTASK_CHILD_FOLLOWUP_ANSWER = "9"
const SUBTASK_FAST_CHILD_PROMPT = `${SUBTASK_FAST_CHILD_MARKER}: Complete immediately with the exact result "Fast child completed".`
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.`
+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.`
+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".`
+export const SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER = "9"
+export const SUBTASK_INTERRUPT_PARENT_RESULT = "Interrupted parent resumed"
+
const SUBTASK_XPROFILE_SAME_CHILD_PROMPT = `${SUBTASK_XPROFILE_SAME_CHILD_MARKER}: Complete immediately with the exact result "Same-profile child completed".`
const SUBTASK_XPROFILE_DIFFERENT_CHILD_PROMPT = `${SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER}: Complete immediately with the exact result "Different-profile child completed".`
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[]) => {
const completionAfterAnswer = (followupId: string, completionId: string) => ({
match: {
predicate: (req: ChatCompletionRequest) =>
+ !requestContains(req, [SUBTASK_INTERRUPT_CHILD_MARKER]) &&
+ !requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER]) &&
// Preferred: structured tool-result message carries the followup answer.
- toolResultContains(req, followupId, [SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
- // Fallback 1: answer present alongside the tool-call ID but not in a role:tool message.
- requestContains(req, [followupId, SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
- // Fallback 2: answer arrives as a bare user message after task resume (no tool-call ID context).
- requestContains(req, [
- SUBTASK_CHILD_MARKER,
- `\\n${SUBTASK_CHILD_FOLLOWUP_ANSWER}\\n`,
- ]),
+ (toolResultContains(req, followupId, [SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
+ // Fallback 1: answer present alongside the tool-call ID but not in a role:tool message.
+ requestContains(req, [followupId, SUBTASK_CHILD_FOLLOWUP_ANSWER]) ||
+ // Fallback 2: answer arrives as a bare user message after task resume (no tool-call ID context).
+ requestContains(req, [
+ SUBTASK_CHILD_MARKER,
+ `\\n${SUBTASK_CHILD_FOLLOWUP_ANSWER}\\n`,
+ ])),
},
response: {
toolCalls: [
@@ -90,7 +99,8 @@ export function addSubtaskFixtures(mock: InstanceType) {
mock.addFixture({
match: {
- toolCallId: "call_subtasks_fast_parent_new_task_001",
+ predicate: (req: ChatCompletionRequest) =>
+ requestContains(req, [SUBTASK_FAST_PARENT_MARKER, "call_subtasks_fast_parent_new_task_001"]),
},
response: {
toolCalls: [
@@ -143,7 +153,8 @@ export function addSubtaskFixtures(mock: InstanceType) {
mock.addFixture({
match: {
- toolCallId: "call_subtasks_parent_new_task_001",
+ predicate: (req: ChatCompletionRequest) =>
+ requestContains(req, [SUBTASK_PARENT_MARKER, "call_subtasks_parent_new_task_001"]),
},
response: {
toolCalls: [
@@ -252,4 +263,95 @@ export function addSubtaskFixtures(mock: InstanceType) {
],
},
})
+
+ // Interrupted-child-resumes-and-reports-back scenario (#560)
+ mock.addFixture({
+ match: {
+ userMessage: new RegExp(SUBTASK_INTERRUPT_PARENT_MARKER),
+ sequenceIndex: 0,
+ },
+ response: {
+ toolCalls: [
+ {
+ name: "new_task",
+ arguments: JSON.stringify({
+ mode: "ask",
+ message: SUBTASK_INTERRUPT_CHILD_PROMPT,
+ }),
+ id: "call_interrupt_parent_new_task_001",
+ },
+ ],
+ },
+ })
+
+ // The parent prompt embeds SUBTASK_INTERRUPT_CHILD_MARKER verbatim, so parent-resume turns
+ // also match a bare substring check. Exclude the parent marker so they fall through.
+ // The answer exclusion must use the wrapping: the bare answer is a single
+ // digit that can appear anywhere in the serialized request (timestamps in environment
+ // details, token counts), which would make this fixture unmatchable.
+ mock.addFixture({
+ match: {
+ predicate: (req: ChatCompletionRequest) =>
+ requestContains(req, [SUBTASK_INTERRUPT_CHILD_MARKER]) &&
+ !requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER]) &&
+ !requestContains(req, ["call_interrupt_child_followup_001"]) &&
+ !requestContains(req, [
+ `\\n${SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER}\\n`,
+ ]),
+ },
+ response: {
+ toolCalls: [
+ {
+ name: "ask_followup_question",
+ arguments: JSON.stringify({
+ question: "What is the square root of 81?",
+ follow_up: [{ text: SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER }],
+ }),
+ id: "call_interrupt_child_followup_001",
+ },
+ ],
+ },
+ })
+
+ mock.addFixture({
+ match: {
+ predicate: (req: ChatCompletionRequest) =>
+ // Preferred: structured tool-result message carries the followup answer.
+ toolResultContains(req, "call_interrupt_child_followup_001", [
+ SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
+ ]) ||
+ // Fallback 1: answer present alongside the tool-call ID.
+ requestContains(req, ["call_interrupt_child_followup_001", SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER]) ||
+ // Fallback 2: answer arrives as a bare user message after task resume.
+ requestContains(req, [
+ SUBTASK_INTERRUPT_CHILD_MARKER,
+ `\\n${SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER}\\n`,
+ ]),
+ },
+ response: {
+ toolCalls: [
+ {
+ name: "attempt_completion",
+ arguments: JSON.stringify({ result: SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER }),
+ id: "call_interrupt_child_completion_002",
+ },
+ ],
+ },
+ })
+
+ mock.addFixture({
+ match: {
+ predicate: (req: ChatCompletionRequest) =>
+ requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER, "call_interrupt_parent_new_task_001"]),
+ },
+ response: {
+ toolCalls: [
+ {
+ name: "attempt_completion",
+ arguments: JSON.stringify({ result: SUBTASK_INTERRUPT_PARENT_RESULT }),
+ id: "call_interrupt_parent_completion_003",
+ },
+ ],
+ },
+ })
}
diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts
index f3d1042799..95d74970b9 100644
--- a/apps/vscode-e2e/src/suite/subtasks.test.ts
+++ b/apps/vscode-e2e/src/suite/subtasks.test.ts
@@ -3,10 +3,13 @@ import * as assert from "assert"
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
import { setDefaultSuiteTimeout } from "./test-utils"
-import { waitFor, waitUntilCompleted } from "./utils"
+import { sleep, waitFor, waitUntilCompleted } from "./utils"
import {
SUBTASK_CHILD_FOLLOWUP_ANSWER,
SUBTASK_FAST_PARENT_PROMPT,
+ SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
+ SUBTASK_INTERRUPT_PARENT_PROMPT,
+ SUBTASK_INTERRUPT_PARENT_RESULT,
SUBTASK_PARENT_PROMPT,
SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT,
SUBTASK_XPROFILE_PARENT_PROMPT,
@@ -69,6 +72,7 @@ suite("Roo Code Subtasks", function () {
while (api.getCurrentTaskStack().length > 0) {
await api.clearCurrentTask()
}
+ await sleep(1_500)
}
})
@@ -318,10 +322,13 @@ suite("Roo Code Subtasks", function () {
// Race mitigation: runDelegationTransition lock + cancelledDelegationChildIds guard
// ensures cancelTask() wins over a concurrent reopenParentFromDelegation() (Race 3).
- test("cancelled child completes in-place and does not reopen parent", async () => {
+ // Before issue #560 was fixed, a cancelled child would have its parent link severed on cancel, so
+ // it would complete in-place without reopening the parent. The correct behavior (post-fix) is that
+ // the cancelled child is marked "interrupted", and when it resumes and completes it reopens the parent.
+ test("cancelled child completes and reopens parent", async () => {
const api = globalThis.api
const asks: Record = {}
- const messages: Record = {}
+ const says: Record = {}
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
if (message.type === "ask") {
@@ -329,26 +336,11 @@ suite("Roo Code Subtasks", function () {
asks[taskId].push(message)
}
if (message.type === "say" && message.partial === false) {
- messages[taskId] = messages[taskId] || []
- messages[taskId].push(message)
+ says[taskId] = says[taskId] || []
+ says[taskId].push(message)
}
}
- const findCompletionText = (taskId: string) =>
- messages[taskId]
- ?.filter(
- (message) =>
- message.type === "say" && (message.say === "completion_result" || message.say === "text"),
- )
- .map((message) => message.text?.trim())
- .find((text): text is string => !!text)
-
- const findErrorText = (taskId: string) =>
- messages[taskId]
- ?.filter((message) => message.type === "say" && message.say === "error")
- .map((message) => message.text?.trim())
- .find((text): text is string => !!text)
-
api.on(RooCodeEventName.Message, messageHandler)
try {
@@ -377,6 +369,7 @@ suite("Roo Code Subtasks", function () {
await waitFor(
() => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false,
)
+ await waitFor(async () => (await api.getTaskApiConversationHistoryLength(spawnedTaskId!)) > 0)
const cancelledChildTaskId = spawnedTaskId!
await api.cancelCurrentTask()
@@ -388,41 +381,25 @@ suite("Roo Code Subtasks", function () {
false,
)
- const resumedChildTaskId = await waitUntilCompleted({
+ // Resume the child — it should complete and reopen the parent (fix for #560)
+ const completedTaskId = await waitUntilCompleted({
api,
start: async () => {
await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER)
- return cancelledChildTaskId
+ return parentTaskId
},
})
assert.strictEqual(
- resumedChildTaskId,
- cancelledChildTaskId,
- "Cancelled child task should be resumed in place",
- )
- assert.strictEqual(
- findErrorText(resumedChildTaskId),
- undefined,
- "Resumed child task should not emit an error",
- )
- assert.strictEqual(
- findCompletionText(resumedChildTaskId),
- "9",
- "Resumed child task should complete with `9`",
+ completedTaskId,
+ parentTaskId,
+ "Parent task should complete after interrupted child reports back",
)
assert.strictEqual(
- api.getCurrentTaskStack().at(-1),
- cancelledChildTaskId,
- "Cancelled child task should remain the active completed task",
- )
- assert.ok(
- messages[parentTaskId]?.find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
- undefined,
- "Parent task should not have resumed after the cancelled child completed",
+ says[parentTaskId]?.find(({ say }) => say === "completion_result")?.text?.trim(),
+ "Parent task resumed",
+ "Parent task should complete with its expected result",
)
-
- await api.clearCurrentTask()
} finally {
api.off(RooCodeEventName.Message, messageHandler)
}
@@ -536,4 +513,101 @@ suite("Roo Code Subtasks", function () {
}
}
})
+
+ // Issue #560: interrupted child resumes and reports back to parent.
+ // Before the fix, cancelTask() severed the parent link, so the resumed child
+ // fell through to "Start New Task" instead of delegating back to the parent.
+ test("interrupted child resumes and reports back to parent", async () => {
+ const api = globalThis.api
+ const asks: Record = {}
+ const says: Record = {}
+
+ const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
+ if (message.type === "ask") {
+ asks[taskId] = asks[taskId] || []
+ asks[taskId].push(message)
+ }
+ if (message.type === "say" && message.partial === false) {
+ says[taskId] = says[taskId] || []
+ says[taskId].push(message)
+ }
+ }
+
+ api.on(RooCodeEventName.Message, messageHandler)
+
+ try {
+ const parentTaskId = await api.startNewTask({
+ configuration: {
+ mode: "ask",
+ alwaysAllowModeSwitch: true,
+ alwaysAllowSubtasks: true,
+ autoApprovalEnabled: true,
+ enableCheckpoints: false,
+ },
+ text: SUBTASK_INTERRUPT_PARENT_PROMPT,
+ })
+
+ // Wait for child to spawn
+ let childTaskId: string | undefined
+ await waitFor(() => {
+ const stack = api.getCurrentTaskStack()
+ const current = stack[stack.length - 1]
+ if (current && current !== parentTaskId) {
+ childTaskId = current
+ return true
+ }
+ return false
+ })
+
+ // Wait for the child's followup question
+ await waitFor(() => asks[childTaskId!]?.some(({ ask }) => ask === "followup") ?? false)
+ await waitFor(async () => (await api.getTaskApiConversationHistoryLength(childTaskId!)) > 0)
+
+ // Cancel the child — it should be marked "interrupted", parent stays "delegated"
+ await api.cancelCurrentTask()
+
+ // Child should be back on the stack (rehydrated as interrupted)
+ await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId)
+ await waitFor(
+ () => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
+ )
+
+ // Parent must not have resumed yet
+ assert.strictEqual(
+ says[parentTaskId]?.find(({ say }) => say === "completion_result"),
+ undefined,
+ "Parent must not have resumed while child is interrupted",
+ )
+
+ // Resume the child and answer the followup — child should complete and reopen parent.
+ const completedParentTaskId = await waitUntilCompleted({
+ api,
+ start: async () => {
+ await api.sendMessage(SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER)
+ return parentTaskId
+ },
+ })
+
+ assert.strictEqual(
+ completedParentTaskId,
+ parentTaskId,
+ "Parent task should be the one that completes after interrupted child reports back",
+ )
+
+ assert.strictEqual(
+ says[parentTaskId]
+ ?.filter(({ say }) => say === "completion_result")
+ .map(({ text }) => text?.trim())
+ .find((text) => text === SUBTASK_INTERRUPT_PARENT_RESULT),
+ SUBTASK_INTERRUPT_PARENT_RESULT,
+ "Parent should complete with expected result after interrupted child reports back",
+ )
+ } finally {
+ api.off(RooCodeEventName.Message, messageHandler)
+ while (api.getCurrentTaskStack().length > 0) {
+ await api.clearCurrentTask()
+ }
+ await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
+ }
+ })
})
diff --git a/packages/core/src/task-history/index.ts b/packages/core/src/task-history/index.ts
index 384bdfc254..f2439ee973 100644
--- a/packages/core/src/task-history/index.ts
+++ b/packages/core/src/task-history/index.ts
@@ -41,7 +41,10 @@ function extractSessionEntry(value: unknown): TaskSessionEntry | undefined {
ts,
workspace: typeof workspace === "string" ? workspace : undefined,
mode: typeof mode === "string" ? mode : undefined,
- status: status === "active" || status === "completed" || status === "delegated" ? status : undefined,
+ status:
+ status === "active" || status === "completed" || status === "delegated" || status === "interrupted"
+ ? status
+ : undefined,
}
}
diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts
index 8fc3034095..2dbaae7920 100644
--- a/packages/types/src/api.ts
+++ b/packages/types/src/api.ts
@@ -45,6 +45,12 @@ export interface RooCodeAPI extends EventEmitter {
* @returns The HistoryItem, or undefined if not found.
*/
getTaskHistoryItem(taskId: string): Promise
+ /**
+ * Returns the persisted API conversation history length for a task. Intended for use in tests only.
+ * @param taskId The ID of the task.
+ * @returns The number of persisted API conversation history entries, or 0 if unavailable.
+ */
+ getTaskApiConversationHistoryLength(taskId: string): Promise
/**
* Returns the current task stack.
* @returns An array of task IDs.
diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts
index a60d1a75b6..5b173c6a6b 100644
--- a/packages/types/src/history.ts
+++ b/packages/types/src/history.ts
@@ -20,7 +20,7 @@ export const historyItemSchema = z.object({
workspace: z.string().optional(),
mode: z.string().optional(),
apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature
- status: z.enum(["active", "completed", "delegated"]).optional(),
+ status: z.enum(["active", "completed", "delegated", "interrupted"]).optional(),
delegatedToId: z.string().optional(), // Last child this parent delegated to
childIds: z.array(z.string()).optional(), // All children spawned by this task
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts
index 7447dc772e..572302861b 100644
--- a/packages/types/src/task.ts
+++ b/packages/types/src/task.ts
@@ -90,7 +90,7 @@ export interface CreateTaskOptions {
experiments?: Record
initialTodos?: TodoItem[]
/** Initial status for the task's history item (e.g., "active" for child tasks) */
- initialStatus?: "active" | "delegated" | "completed"
+ initialStatus?: "active" | "delegated" | "completed" | "interrupted"
/** Whether to start the task loop immediately (default: true).
* When false, the caller must invoke `task.start()` manually. */
startTask?: boolean
diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts
index 6295fec873..c9d77ad877 100644
--- a/src/__tests__/helpers/provider-stub.ts
+++ b/src/__tests__/helpers/provider-stub.ts
@@ -3,8 +3,8 @@ import { ClineProvider } from "../../core/webview/ClineProvider"
/**
* Augments a plain stub object with the instance fields and bound methods that
* ClineProvider methods read from `this` (runDelegationTransition,
- * delegationTransitionLocks, cancelledDelegationChildIds), so tests can call
- * private methods via `(ClineProvider.prototype as any).method.call(stub, …)`
+ * delegationTransitionLocks, cancelledDelegationChildIds, cancellingDelegationChildIds),
+ * so tests can call private methods via `(ClineProvider.prototype as any).method.call(stub, …)`
* without instantiating a real ClineProvider.
*/
export function makeProviderStub(stub: T): T {
@@ -12,7 +12,9 @@ export function makeProviderStub(stub: T): T {
const proto = ClineProvider.prototype as any
s.delegationTransitionLocks ??= new Map()
s.cancelledDelegationChildIds ??= new Set()
+ s.cancellingDelegationChildIds ??= new Set()
s.log ??= vi.fn()
+ s.taskHistoryStore ??= { get: () => undefined }
s.runDelegationTransition = proto.runDelegationTransition.bind(s)
return s
}
diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts
index 23caf6b066..6013d8db99 100644
--- a/src/__tests__/removeClineFromStack-delegation.spec.ts
+++ b/src/__tests__/removeClineFromStack-delegation.spec.ts
@@ -280,4 +280,61 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
// Grandparent A's metadata remains intact (delegated, awaitingChildId: task-B)
// The caller (delegateParentAndOpenChild) will update A to point to C separately.
})
+
+ it("does NOT repair parent when child has 'interrupted' status (cancel already persisted it)", async () => {
+ // cancelTask() writes child status: "interrupted" and leaves parent "delegated".
+ // When rehydrate then calls removeClineFromStack, parent must stay delegated.
+ const childTaskId = "child-1"
+ const parentTaskId = "parent-1"
+
+ const childTask = {
+ taskId: childTaskId,
+ instanceId: "inst-child",
+ parentTaskId,
+ emit: vi.fn(),
+ abortTask: vi.fn().mockResolvedValue(undefined),
+ }
+
+ const updateTaskHistory = vi.fn().mockResolvedValue([])
+ const getTaskWithId = vi.fn().mockImplementation(async (id: string) => {
+ if (id === parentTaskId) {
+ return {
+ historyItem: {
+ id: parentTaskId,
+ task: "Parent task",
+ ts: 1000,
+ number: 1,
+ tokensIn: 0,
+ tokensOut: 0,
+ totalCost: 0,
+ status: "delegated",
+ awaitingChildId: childTaskId,
+ },
+ }
+ }
+ throw new Error("Task not found")
+ })
+
+ const provider = makeProviderStub({
+ clineStack: [childTask] as any[],
+ taskEventListeners: new Map(),
+ log: vi.fn(),
+ getTaskWithId,
+ updateTaskHistory,
+ // Seed the in-memory store with the interrupted child — mirrors what cancelTask
+ // writes before rehydrating, and is what removeClineFromStack now reads directly.
+ taskHistoryStore: { get: (id: string) => (id === childTaskId ? { status: "interrupted" } : undefined) },
+ })
+
+ await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
+
+ // Stack is emptied
+ expect(provider.clineStack).toHaveLength(0)
+
+ // Parent must NOT be transitioned to active — it stays "delegated"
+ // so the interrupted child can resume and report back later
+ expect(updateTaskHistory).not.toHaveBeenCalledWith(
+ expect.objectContaining({ id: parentTaskId, status: "active" }),
+ )
+ })
})
diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts
index a1ce479f24..219918b029 100644
--- a/src/core/task-persistence/TaskHistoryStore.ts
+++ b/src/core/task-persistence/TaskHistoryStore.ts
@@ -12,8 +12,9 @@ import { getStorageBasePath } from "../../utils/storage"
export type HistoryItemStatus = NonNullable
const VALID_TRANSITIONS: Record = {
- active: ["delegated", "completed"],
+ active: ["delegated", "completed", "interrupted"],
delegated: ["active"],
+ interrupted: ["completed"],
completed: [],
}
@@ -355,7 +356,7 @@ export class TaskHistoryStore {
* - Parent `delegated`, child not found → parent → `active` (orphaned delegation)
* - Parent `delegated`, child `completed` → parent → `active` (interrupted handoff)
*
- * A parent awaiting an `active` child is left as-is — the child is resumable.
+ * A parent awaiting an `active`, `interrupted`, or `delegated` child is left as-is — the child is resumable.
*/
private async reconcileDelegationState(): Promise {
return this.withLock(async () => {
@@ -417,7 +418,7 @@ export class TaskHistoryStore {
)
repairsInThisPass++
}
- // child.status === "active" or "delegated" → leave as-is this pass
+ // child.status === "active", "interrupted", or "delegated" → leave as-is this pass
}
} while (repairsInThisPass > 0)
})
diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts
index ee2759725e..2888c0f7b2 100644
--- a/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts
+++ b/src/core/task-persistence/__tests__/TaskHistoryStore.reconciliation.spec.ts
@@ -46,10 +46,18 @@ describe("assertValidTransition", () => {
expect(() => assertValidTransition("active", "completed")).not.toThrow()
})
+ it("active → interrupted", () => {
+ expect(() => assertValidTransition("active", "interrupted")).not.toThrow()
+ })
+
it("delegated → active", () => {
expect(() => assertValidTransition("delegated", "active")).not.toThrow()
})
+ it("interrupted → completed", () => {
+ expect(() => assertValidTransition("interrupted", "completed")).not.toThrow()
+ })
+
it("undefined (implicit active) → delegated", () => {
expect(() => assertValidTransition(undefined, "delegated")).not.toThrow()
})
@@ -84,6 +92,12 @@ describe("assertValidTransition", () => {
)
})
+ it("interrupted → active", () => {
+ expect(() => assertValidTransition("interrupted", "active")).toThrow(
+ "Invalid task status transition: interrupted → active",
+ )
+ })
+
it("active → active (self-loop)", () => {
expect(() => assertValidTransition("active", "active")).toThrow(
"Invalid task status transition: active → active",
@@ -421,6 +435,30 @@ describe("TaskHistoryStore upsert transition guard", () => {
expect(store.get("task-guard-3")?.status).toBe("completed")
})
+ it("rejects interrupted → active transition, preserving the interrupted status", async () => {
+ const item = makeItem({ id: "task-guard-interrupted", status: "interrupted" })
+ await seedItems([item])
+ store.dispose()
+ store = new TaskHistoryStore(tmpDir)
+ await store.initialize()
+
+ await expect(store.upsert({ ...item, status: "active" })).rejects.toThrow(
+ "Invalid task status transition: interrupted → active",
+ )
+ expect(store.get("task-guard-interrupted")?.status).toBe("interrupted")
+ })
+
+ it("allows valid interrupted → completed transition", async () => {
+ const item = makeItem({ id: "task-guard-interrupted-complete", status: "interrupted" })
+ await seedItems([item])
+ store.dispose()
+ store = new TaskHistoryStore(tmpDir)
+ await store.initialize()
+
+ await expect(store.upsert({ ...item, status: "completed" })).resolves.toBeDefined()
+ expect(store.get("task-guard-interrupted-complete")?.status).toBe("completed")
+ })
+
it("allows first insert with status: active (no prior record to transition from)", async () => {
const item = makeItem({ id: "task-guard-new", status: "active" })
// Do NOT seed — this is the very first write for this task
diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts
index 4b77126971..ec2e6cceeb 100644
--- a/src/core/task-persistence/taskMetadata.ts
+++ b/src/core/task-persistence/taskMetadata.ts
@@ -24,7 +24,7 @@ export type TaskMetadataOptions = {
/** Provider profile name for the task (sticky profile feature) */
apiConfigName?: string
/** Initial status for the task (e.g., "active" for child tasks) */
- initialStatus?: "active" | "delegated" | "completed"
+ initialStatus?: "active" | "delegated" | "completed" | "interrupted"
}
export async function taskMetadata({
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index 0bc3130169..7bee3c5e14 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -158,7 +158,7 @@ export interface TaskOptions extends CreateTaskOptions {
initialTodos?: TodoItem[]
workspacePath?: string
/** Initial status for the task's history item (e.g., "active" for child tasks) */
- initialStatus?: "active" | "delegated" | "completed"
+ initialStatus?: "active" | "delegated" | "completed" | "interrupted"
rateLimitClock?: RateLimitClock
diffFuzzyThreshold?: number
}
@@ -431,7 +431,7 @@ export class Task extends EventEmitter implements TaskLike {
private cloudSyncedMessageTimestamps: Set = new Set()
// Initial status for the task's history item (set at creation time to avoid race conditions)
- private readonly initialStatus?: "active" | "delegated" | "completed"
+ private readonly initialStatus?: "active" | "delegated" | "completed" | "interrupted"
// MessageManager for high-level message operations (lazy initialized)
private _messageManager?: MessageManager
@@ -1127,7 +1127,9 @@ export class Task extends EventEmitter implements TaskLike {
// - Final state is emitted when updates stop (trailing: true)
this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage)
- await this.providerRef.deref()?.updateTaskHistory(historyItem)
+ const provider = this.providerRef.deref()
+ const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status
+ await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem)
return true
} catch (error) {
console.error("Failed to save Roo messages:", error)
@@ -1853,13 +1855,10 @@ export class Task extends EventEmitter implements TaskLike {
/**
* Manually start a **new** task when it was created with `startTask: false`.
*
- * This fires `startTask` as a background async operation for the
- * `task/images` code-path only. It does **not** handle the
- * `historyItem` resume path (use the constructor with `startTask: true`
- * for that). The primary use-case is in the delegation flow where the
- * parent's metadata must be persisted to globalState **before** the
- * child task begins writing its own history (avoiding a read-modify-write
- * race on globalState).
+ * This fires task startup as a background async operation after the provider
+ * has installed the task in the stack and wired listeners. The primary
+ * use-case is delegation/rehydration flow where metadata and stack state
+ * must be in place before the task begins writing history or emitting asks.
*/
public start(): void {
if (this._started) {
diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts
index 7deb5a06cf..1761db5bc3 100644
--- a/src/core/task/__tests__/Task.persistence.spec.ts
+++ b/src/core/task/__tests__/Task.persistence.spec.ts
@@ -264,6 +264,7 @@ describe("Task persistence", () => {
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined)
+ mockProvider.log = vi.fn()
})
// ── saveApiConversationHistory (via retrySaveApiConversationHistory) ──
@@ -422,6 +423,51 @@ describe("Task persistence", () => {
// But the content should be the same
expect(callArgs.messages).toEqual(task.clineMessages)
})
+
+ it("preserves an existing lifecycle status during metadata saves", async () => {
+ mockSaveTaskMessages.mockResolvedValueOnce(undefined)
+ mockTaskMetadata.mockResolvedValueOnce({
+ historyItem: {
+ id: "task-with-advanced-status",
+ ts: Date.now(),
+ task: "test",
+ status: "interrupted",
+ tokensIn: 10,
+ },
+ tokenUsage: {
+ totalTokensIn: 10,
+ totalTokensOut: 0,
+ totalCacheWrites: 0,
+ totalCacheReads: 0,
+ totalCost: 0,
+ contextTokens: 0,
+ },
+ })
+
+ const updateTaskHistory = vi.fn().mockResolvedValue([])
+ const taskHistoryStore = {
+ get: vi.fn().mockReturnValue({ id: "task-with-advanced-status", status: "completed" }),
+ }
+ const provider = { ...mockProvider, updateTaskHistory, taskHistoryStore }
+ const task = new Task({
+ provider: provider as any,
+ apiConfiguration: mockApiConfig,
+ taskId: "task-with-advanced-status",
+ task: "test task",
+ startTask: false,
+ initialStatus: "interrupted",
+ })
+
+ await (task as Record).saveClineMessages()
+
+ expect(updateTaskHistory).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: "task-with-advanced-status",
+ status: "completed",
+ tokensIn: 10,
+ }),
+ )
+ })
})
// ── flushPendingToolResultsToHistory — save failure/success ───────────
diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts
index c6c9bc908e..39feae0d9b 100644
--- a/src/core/tools/AttemptCompletionTool.ts
+++ b/src/core/tools/AttemptCompletionTool.ts
@@ -97,7 +97,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
// Fall through to normal completion ask flow below (outside this if block)
// This shows the user the completion result and waits for acceptance
// without injecting another tool_result to the parent
- } else if (status === "active") {
+ } else if (status === "active" || status === "interrupted") {
historyLookupTaskId = task.parentTaskId
const { historyItem: parentHistory } = await provider.getTaskWithId(task.parentTaskId)
@@ -132,7 +132,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
// "delegated" would mean this child has its own grandchild pending (shouldn't reach attempt_completion)
provider.log(
`[AttemptCompletionTool] Unexpected child task status "${status}" for task ${task.taskId}. ` +
- `Expected "active" or "completed". Skipping delegation to prevent data corruption.`,
+ `Expected "active", "interrupted", or "completed". Skipping delegation to prevent data corruption.`,
)
// Fall through to normal completion ask flow
}
diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts
index cf21eeee3e..86ff112585 100644
--- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts
+++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts
@@ -636,6 +636,57 @@ describe("attemptCompletionTool", () => {
expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("child-1")
})
+ it("delegates an interrupted subtask completion when the parent is still delegated and awaiting that child", async () => {
+ const block: AttemptCompletionToolUse = {
+ type: "tool_use",
+ name: "attempt_completion",
+ params: { result: "9" },
+ nativeArgs: { result: "9" },
+ partial: false,
+ }
+ const mockProvider = {
+ log: vi.fn(),
+ getTaskWithId: vi.fn().mockImplementation((id: string) => {
+ if (id === "child-1") {
+ return Promise.resolve({ historyItem: { id, status: "interrupted" } })
+ }
+ if (id === "parent-1") {
+ return Promise.resolve({
+ historyItem: { id, status: "delegated", awaitingChildId: "child-1" },
+ })
+ }
+ throw new Error(`unexpected task id ${id}`)
+ }),
+ reopenParentFromDelegation: vi.fn().mockResolvedValue(true),
+ }
+
+ Object.assign(mockTask, {
+ taskId: "child-1",
+ parentTaskId: "parent-1",
+ providerRef: { deref: () => mockProvider },
+ })
+ mockAskFinishSubTaskApproval.mockResolvedValue(true)
+
+ const callbacks: AttemptCompletionCallbacks = {
+ askApproval: mockAskApproval,
+ handleError: mockHandleError,
+ pushToolResult: mockPushToolResult,
+ askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
+ toolDescription: mockToolDescription,
+ }
+
+ await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
+
+ expect(mockAskFinishSubTaskApproval).toHaveBeenCalled()
+ expect(mockProvider.reopenParentFromDelegation).toHaveBeenCalledWith({
+ parentTaskId: "parent-1",
+ childTaskId: "child-1",
+ completionResultSummary: "9",
+ })
+ expect(mockTask.ask).not.toHaveBeenCalled()
+ expect(mockPushToolResult).toHaveBeenCalledWith("")
+ })
+
it("does not resume the parent when the parent is active but awaiting a different child", async () => {
const block: AttemptCompletionToolUse = {
type: "tool_use",
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index df13327c3e..86d26dfe4c 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -167,6 +167,14 @@ export class ClineProvider
private clineStack: Task[] = []
private delegationTransitionLocks?: Map>
private cancelledDelegationChildIds = new Set()
+ // Marks a child whose cancellation is currently in flight, from the moment cancelTask()
+ // is invoked until its "interrupted" status write lands (or the cancel path bails out).
+ // removeClineFromStack()'s delegation repair must not run against a stale "active" read
+ // while this is set — otherwise a concurrent navigation (e.g. showTaskWithId(parentTaskId)
+ // from the user clicking "back to parent" right after hitting Stop) can win the race
+ // against cancelTask()'s own runDelegationTransition call and repair the parent to
+ // "active" before "interrupted" is ever persisted, permanently severing the delegation link.
+ private cancellingDelegationChildIds = new Set()
private codeIndexStatusSubscription?: vscode.Disposable
private codeIndexManager?: CodeIndexManager
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
@@ -533,6 +541,29 @@ export class ClineProvider
const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId)
if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === childTaskId) {
+ // If the child is "interrupted", cancelTask already persisted that
+ // status and intentionally left the parent delegated so the child
+ // can resume and report back. Do not auto-repair in that case.
+ if (this.taskHistoryStore.get(childTaskId)?.status === "interrupted") {
+ this.log(
+ `[ClineProvider#removeClineFromStack] Skipping parent repair: child ${childTaskId} is interrupted`,
+ )
+ return
+ }
+
+ // A cancellation for this child may be in flight (cancelTask() has
+ // marked it synchronously but its "interrupted" write hasn't landed
+ // yet, since both paths serialize on the same per-parent transition
+ // lock and this call won the race). Repairing here would clear
+ // awaitingChildId based on a stale "active" read and permanently
+ // sever the delegation link. Defer to cancelTask()'s own write instead.
+ if (this.cancellingDelegationChildIds.has(childTaskId)) {
+ this.log(
+ `[ClineProvider#removeClineFromStack] Skipping parent repair: cancellation for child ${childTaskId} is in flight`,
+ )
+ return
+ }
+
assertValidTransition(parentHistory.status, "active")
await this.updateTaskHistory({
...parentHistory,
@@ -1064,10 +1095,12 @@ export class ClineProvider
if (profile?.name) {
try {
- await this.activateProviderProfile(
- { name: profile.name },
- { persistModeConfig: false, persistTaskHistory: false },
- )
+ if (profile.apiProvider) {
+ await this.activateProviderProfile(
+ { name: profile.name },
+ { persistModeConfig: false, persistTaskHistory: false },
+ )
+ }
} catch (error) {
// Log the error but continue with task restoration.
this.log(
@@ -3130,6 +3163,23 @@ export class ClineProvider
console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
+ // Mark this child as "cancellation in flight" synchronously, before any await, so a
+ // concurrent removeClineFromStack() (e.g. from the user navigating back to the parent
+ // right after clicking Stop) cannot win the race against this function's own
+ // runDelegationTransition call below and repair the parent from a stale "active" read
+ // before "interrupted" is persisted (see cancellingDelegationChildIds doc comment).
+ if (task.parentTaskId) {
+ this.cancellingDelegationChildIds.add(task.taskId)
+ }
+
+ try {
+ await this.cancelTaskInternal(task)
+ } finally {
+ this.cancellingDelegationChildIds.delete(task.taskId)
+ }
+ }
+
+ private async cancelTaskInternal(task: Task): Promise {
let historyItem: HistoryItem | undefined
try {
const history = await this.getTaskWithId(task.taskId)
@@ -3159,8 +3209,11 @@ export class ClineProvider
// This ensures the stream fails quickly rather than waiting for network timeout
task.cancelCurrentRequest()
- // Begin abort (non-blocking)
- task.abortTask()
+ // Kick off abort (sets abort flag synchronously; stream exit and final saveClineMessages
+ // happen asynchronously). We capture the promise so we can await its completion below —
+ // this ensures task.initialStatus ("active") cannot overwrite "interrupted" after we
+ // persist it (issue #560).
+ const abortPromise = task.abortTask()
// Immediately mark the original instance as abandoned to prevent any residual activity
task.abandoned = true
@@ -3181,6 +3234,10 @@ export class ClineProvider
console.error("Failed to abort task")
})
+ // Wait for abortTask to fully settle (including its final saveClineMessages write)
+ // before we persist "interrupted", so our write is always the last one.
+ await abortPromise.catch(() => {})
+
// Defensive safeguard: if current instance already changed, skip rehydrate
const current = this.getCurrentTask()
if (current && current.instanceId !== originalInstanceId) {
@@ -3211,27 +3268,21 @@ export class ClineProvider
const { historyItem: parentHistory } = await this.getTaskWithId(task.parentTaskId!)
if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) {
- assertValidTransition(parentHistory.status, "active")
- await this.updateTaskHistory({
- ...parentHistory,
- status: "active",
- awaitingChildId: undefined,
- delegatedToId: undefined,
- })
-
+ // Mark the child interrupted and leave parent delegated with awaitingChildId
+ // intact — the user can resume this child later and it will report back.
+ historyItem = { ...historyItem!, status: "interrupted" }
+ await this.updateTaskHistory(historyItem)
+ // Clear any stale fail-closed entry from a prior failed cancel attempt so
+ // reopenParentFromDelegation is not incorrectly blocked on resume.
+ this.cancelledDelegationChildIds.delete(task.taskId)
this.log(
- `[cancelTask] Detached delegated parent ${task.parentTaskId}: delegated → active (child ${task.taskId} cancelled)`,
+ `[cancelTask] Marked child ${task.taskId} interrupted; parent ${task.parentTaskId} stays delegated`,
)
- parentTask = undefined
- rootTask = undefined
- // Clear any stale fail-closed entry from a prior failed cancel attempt.
- this.cancelledDelegationChildIds.delete(task.taskId)
}
})
} catch (error) {
- // Fail closed: if we cannot prove the parent was detached, make the
- // rehydrated child standalone so later completions cannot reopen a
- // stale delegated parent, even after a provider reload.
+ // Fail closed: if we cannot persist the interrupted status, sever the link
+ // so later completions don't reopen a stale delegated parent.
parentTask = undefined
rootTask = undefined
this.cancelledDelegationChildIds.add(task.taskId)
@@ -3244,14 +3295,14 @@ export class ClineProvider
await this.updateTaskHistory(historyItem)
} catch (historyError) {
this.log(
- `[cancelTask] Failed to persist standalone child state for ${task.taskId}: ${
+ `[cancelTask] Failed to persist interrupted child state for ${task.taskId}: ${
historyError instanceof Error ? historyError.message : String(historyError)
}`,
)
throw historyError
}
this.log(
- `[cancelTask] Failed to detach delegated parent for ${task.taskId}: ${
+ `[cancelTask] Failed to mark child interrupted for ${task.taskId}: ${
error instanceof Error ? error.message : String(error)
}`,
)
diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts
index 3808eb69ca..555253c345 100644
--- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts
+++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts
@@ -49,7 +49,7 @@ vi.mock("../../task/Task", () => ({
return {
taskId: "mock-task-id",
instanceId: "mock-instance-id",
- abortTask: vi.fn(),
+ abortTask: vi.fn().mockResolvedValue(undefined),
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
@@ -509,7 +509,7 @@ describe("ClineProvider flicker-free cancel", () => {
expect((provider as any).clineStack[1]).toBe(mockTask2)
})
- it("detaches runtime parent links for a cancelled delegated child while preserving history lineage", async () => {
+ it("marks a cancelled delegated child as interrupted and keeps parent delegated (preserving resume path)", async () => {
const mockRootTask = { taskId: "root-1" }
const mockParentTask = { taskId: "parent-1" }
const childHistory: HistoryItem = {
@@ -523,6 +523,7 @@ describe("ClineProvider flicker-free cancel", () => {
workspace: "/test/workspace",
parentTaskId: "parent-1",
rootTaskId: "root-1",
+ status: "active",
}
const parentHistory: HistoryItem = {
id: "parent-1",
@@ -545,7 +546,7 @@ describe("ClineProvider flicker-free cancel", () => {
parentTask: mockParentTask,
parentTaskId: "parent-1",
cancelCurrentRequest: vi.fn(),
- abortTask: vi.fn(),
+ abortTask: vi.fn().mockResolvedValue(undefined),
abandoned: false,
isStreaming: false,
didFinishAbortingStream: true,
@@ -569,20 +570,21 @@ describe("ClineProvider flicker-free cancel", () => {
await provider.cancelTask()
+ // Child is marked interrupted, not detached
expect(updateTaskHistorySpy).toHaveBeenCalledWith(
expect.objectContaining({
- id: "parent-1",
- status: "active",
- awaitingChildId: undefined,
+ id: "child-1",
+ status: "interrupted",
}),
)
+ // Parent is NOT transitioned to active — it stays delegated
+ expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ id: "parent-1" }))
+ // Rehydrated child keeps its parent link so it can resume and report back
expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: "child-1",
parentTaskId: "parent-1",
rootTaskId: "root-1",
- parentTask: undefined,
- rootTask: undefined,
}),
)
})
@@ -610,7 +612,7 @@ describe("ClineProvider flicker-free cancel", () => {
parentTask: mockParentTask,
parentTaskId: "parent-1",
cancelCurrentRequest: vi.fn(),
- abortTask: vi.fn(),
+ abortTask: vi.fn().mockResolvedValue(undefined),
abandoned: false,
isStreaming: false,
didFinishAbortingStream: true,
@@ -635,7 +637,7 @@ describe("ClineProvider flicker-free cancel", () => {
await provider.cancelTask()
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
- expect.stringContaining("[cancelTask] Failed to detach delegated parent for child-1: parent lookup failed"),
+ expect.stringContaining("[cancelTask] Failed to mark child interrupted for child-1: parent lookup failed"),
)
expect(updateTaskHistorySpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -675,7 +677,7 @@ describe("ClineProvider flicker-free cancel", () => {
instanceId: "instance-child",
parentTaskId: "parent-1",
cancelCurrentRequest: vi.fn(),
- abortTask: vi.fn(),
+ abortTask: vi.fn().mockResolvedValue(undefined),
abandoned: false,
isStreaming: false,
didFinishAbortingStream: true,
@@ -702,6 +704,188 @@ describe("ClineProvider flicker-free cancel", () => {
expect((provider as any).cancelledDelegationChildIds.has("child-1")).toBe(true)
})
+ it("marks a cancelled delegated child as 'interrupted' and keeps parent delegated", async () => {
+ const childHistory: HistoryItem = {
+ id: "child-1",
+ number: 2,
+ task: "child task",
+ ts: Date.now(),
+ tokensIn: 10,
+ tokensOut: 20,
+ totalCost: 0.001,
+ workspace: "/test/workspace",
+ parentTaskId: "parent-1",
+ rootTaskId: "root-1",
+ status: "active",
+ }
+ const parentHistory: HistoryItem = {
+ id: "parent-1",
+ number: 1,
+ task: "parent task",
+ ts: Date.now(),
+ tokensIn: 10,
+ tokensOut: 20,
+ totalCost: 0.001,
+ workspace: "/test/workspace",
+ status: "delegated",
+ awaitingChildId: "child-1",
+ delegatedToId: "child-1",
+ }
+
+ Object.assign(mockTask1, {
+ taskId: "child-1",
+ instanceId: "instance-child",
+ rootTask: { taskId: "root-1" },
+ parentTask: { taskId: "parent-1" },
+ parentTaskId: "parent-1",
+ cancelCurrentRequest: vi.fn(),
+ abortTask: vi.fn().mockResolvedValue(undefined),
+ abandoned: false,
+ isStreaming: false,
+ didFinishAbortingStream: true,
+ isWaitingForFirstChunk: false,
+ })
+ ;(provider as any).clineStack = [mockTask1]
+ provider.getTaskWithId = vi.fn().mockImplementation((id) => {
+ if (id === "child-1") return Promise.resolve({ historyItem: childHistory })
+ if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory })
+ throw new Error(`unexpected task lookup: ${id}`)
+ }) as any
+
+ const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([])
+ const createTaskWithHistoryItemSpy = vi
+ .spyOn(provider, "createTaskWithHistoryItem")
+ .mockResolvedValue(undefined as any)
+
+ await provider.cancelTask()
+
+ // Child should be marked interrupted, not have its parent link severed
+ expect(updateTaskHistorySpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: "child-1",
+ status: "interrupted",
+ }),
+ )
+
+ // Parent should remain delegated with awaitingChildId intact
+ expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ id: "parent-1" }))
+
+ // Rehydrated child retains parent link
+ expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: "child-1",
+ parentTaskId: "parent-1",
+ rootTaskId: "root-1",
+ }),
+ )
+ })
+
+ it("removeClineFromStack does not repair parent when child is interrupted", async () => {
+ const parentHistory: HistoryItem = {
+ id: "parent-1",
+ number: 1,
+ task: "parent task",
+ ts: Date.now(),
+ tokensIn: 10,
+ tokensOut: 20,
+ totalCost: 0.001,
+ workspace: "/test/workspace",
+ status: "delegated",
+ awaitingChildId: "child-1",
+ delegatedToId: "child-1",
+ }
+
+ const childTask = {
+ taskId: "child-1",
+ instanceId: "inst-child",
+ parentTaskId: "parent-1",
+ emit: vi.fn(),
+ abortTask: vi.fn().mockResolvedValue(undefined),
+ }
+ ;(provider as any).clineStack = [childTask]
+ ;(provider as any).taskEventListeners = new Map()
+ // Seed the in-memory store so taskHistoryStore.get("child-1") returns interrupted
+ vi.spyOn((provider as any).taskHistoryStore, "get").mockImplementation((id: unknown) =>
+ id === "child-1" ? { status: "interrupted" } : undefined,
+ )
+
+ provider.getTaskWithId = vi.fn().mockImplementation((id) => {
+ if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory })
+ throw new Error(`unexpected task lookup: ${id}`)
+ }) as any
+
+ const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([])
+
+ await (provider as any).removeClineFromStack()
+
+ // Parent must NOT be transitioned to active — it stays delegated
+ expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(
+ expect.objectContaining({ id: "parent-1", status: "active" }),
+ )
+ })
+
+ // Regression test for the race where a user clicks Stop on a freshly-delegated
+ // child and immediately navigates back to the parent (showTaskWithId), before
+ // cancelTask()'s own persistence of childHistory.status = "interrupted" has
+ // landed. Both cancelTask() and removeClineFromStack() serialize their parent
+ // writes through runDelegationTransition(parentTaskId, ...), but removeClineFromStack
+ // only skips its repair when taskHistoryStore.get(childTaskId)?.status === "interrupted".
+ // If removeClineFromStack's transition wins the race and runs while the store still
+ // reports "active" (the write from cancelTask() hasn't landed yet), it incorrectly
+ // repairs the parent to "active" and clears awaitingChildId, permanently severing
+ // the delegation link before the child ever gets a chance to report back.
+ it("removeClineFromStack does not repair parent when a cancellation for the child is in flight", async () => {
+ const parentHistory: HistoryItem = {
+ id: "parent-1",
+ number: 1,
+ task: "parent task",
+ ts: Date.now(),
+ tokensIn: 10,
+ tokensOut: 20,
+ totalCost: 0.001,
+ workspace: "/test/workspace",
+ status: "delegated",
+ awaitingChildId: "child-1",
+ delegatedToId: "child-1",
+ }
+
+ const childTask = {
+ taskId: "child-1",
+ instanceId: "inst-child",
+ parentTaskId: "parent-1",
+ emit: vi.fn(),
+ abortTask: vi.fn().mockResolvedValue(undefined),
+ }
+ ;(provider as any).clineStack = [childTask]
+ ;(provider as any).taskEventListeners = new Map()
+
+ // The store still reports "active" — cancelTask()'s write to "interrupted"
+ // has not landed yet. This is the pre-write window of the race.
+ vi.spyOn((provider as any).taskHistoryStore, "get").mockImplementation((id: unknown) =>
+ id === "child-1" ? { status: "active" } : undefined,
+ )
+
+ provider.getTaskWithId = vi.fn().mockImplementation((id) => {
+ if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory })
+ throw new Error(`unexpected task lookup: ${id}`)
+ }) as any
+
+ const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([])
+
+ // Simulate cancelTask() having already synchronously marked this child as
+ // "being cancelled" before its own await chain reaches the history write.
+ ;(provider as any).cancellingDelegationChildIds.add("child-1")
+
+ await (provider as any).removeClineFromStack()
+
+ // Parent must NOT be transitioned to active while the child's cancellation
+ // is still in flight — repairing here would clear awaitingChildId and
+ // permanently sever the delegation link before "interrupted" is persisted.
+ expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(
+ expect.objectContaining({ id: "parent-1", status: "active" }),
+ )
+ })
+
afterAll(() => {
vi.restoreAllMocks()
})
diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
index 4121b42a40..c982cf53c0 100644
--- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
+++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
@@ -526,6 +526,36 @@ describe("ClineProvider - Sticky Provider Profile", () => {
)
})
+ it("should not restore an empty task apiConfigName profile from history", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ const historyItem: HistoryItem = {
+ id: "test-task-id",
+ number: 1,
+ ts: Date.now(),
+ task: "Test task",
+ tokensIn: 100,
+ tokensOut: 200,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0.001,
+ mode: "ask",
+ apiConfigName: "default",
+ }
+
+ const activateProviderProfileSpy = vi
+ .spyOn(provider, "activateProviderProfile")
+ .mockResolvedValue(undefined)
+
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "default", id: "default-id" },
+ ])
+
+ await provider.createTaskWithHistoryItem(historyItem)
+
+ expect(activateProviderProfileSpy).not.toHaveBeenCalled()
+ })
+
it("should skip restoring task apiConfigName from history in CLI runtime", async () => {
await provider.resolveWebviewView(mockWebviewView)
process.env.ROO_CLI_RUNTIME = "1"
diff --git a/src/extension/__tests__/api-task-conversation-history-length.spec.ts b/src/extension/__tests__/api-task-conversation-history-length.spec.ts
new file mode 100644
index 0000000000..4cfd9bbe4b
--- /dev/null
+++ b/src/extension/__tests__/api-task-conversation-history-length.spec.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import * as vscode from "vscode"
+
+import { API } from "../api"
+import { ClineProvider } from "../../core/webview/ClineProvider"
+
+vi.mock("vscode")
+vi.mock("../../core/webview/ClineProvider")
+
+describe("API#getTaskApiConversationHistoryLength", () => {
+ let api: API
+ let mockOutputChannel: vscode.OutputChannel
+ let mockProvider: ClineProvider
+ let mockGetTaskWithId: ReturnType
+
+ beforeEach(() => {
+ mockOutputChannel = {
+ appendLine: vi.fn(),
+ } as unknown as vscode.OutputChannel
+
+ mockGetTaskWithId = vi.fn()
+
+ mockProvider = {
+ context: {} as vscode.ExtensionContext,
+ getTaskWithId: mockGetTaskWithId,
+ on: vi.fn(),
+ } as unknown as ClineProvider
+
+ api = new API(mockOutputChannel, mockProvider, undefined, true)
+ })
+
+ it("returns the persisted api conversation history length", async () => {
+ mockGetTaskWithId.mockResolvedValue({
+ apiConversationHistory: [{ role: "user" }, { role: "assistant" }],
+ })
+
+ await expect(api.getTaskApiConversationHistoryLength("task-1")).resolves.toBe(2)
+ })
+
+ it("returns 0 instead of throwing when the task is unavailable", async () => {
+ mockGetTaskWithId.mockRejectedValue(new Error("Task not found"))
+
+ await expect(api.getTaskApiConversationHistoryLength("missing-task")).resolves.toBe(0)
+ })
+})
diff --git a/src/extension/api.ts b/src/extension/api.ts
index e64b4add82..c140ec8ec0 100644
--- a/src/extension/api.ts
+++ b/src/extension/api.ts
@@ -240,6 +240,15 @@ export class API extends EventEmitter implements RooCodeAPI {
return item ? structuredClone(item) : undefined
}
+ public async getTaskApiConversationHistoryLength(taskId: string): Promise {
+ try {
+ const { apiConversationHistory } = await this.sidebarProvider.getTaskWithId(taskId)
+ return apiConversationHistory.length
+ } catch {
+ return 0
+ }
+ }
+
public getCurrentTaskStack() {
return this.sidebarProvider.getCurrentTaskStack()
}
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx
index 3917d4b556..29b958319c 100644
--- a/webview-ui/src/context/ExtensionStateContext.tsx
+++ b/webview-ui/src/context/ExtensionStateContext.tsx
@@ -1,4 +1,4 @@
-import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
+import React, { createContext, useCallback, useEffect, useState } from "react"
import {
type ProviderSettings,
@@ -638,7 +638,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
}
export const useExtensionState = () => {
- const context = useContext(ExtensionStateContext)
+ const context = React.useContext(ExtensionStateContext)
if (context === undefined) {
throw new Error("useExtensionState must be used within an ExtensionStateContextProvider")
diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx
index 7e7e387865..b9655ace0a 100644
--- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx
+++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx
@@ -1,4 +1,5 @@
import { render, screen, act } from "@/utils/test-utils"
+import React from "react"
import {
type ProviderSettings,
@@ -246,15 +247,15 @@ describe("ExtensionStateContext", () => {
})
it("throws error when used outside provider", () => {
- // Suppress console.error for this test since we expect an error
- const consoleSpy = vi.spyOn(console, "error")
- consoleSpy.mockImplementation(() => {})
+ const useContextSpy = vi.spyOn(React, "useContext").mockReturnValue(undefined)
- expect(() => {
- render()
- }).toThrow("useExtensionState must be used within an ExtensionStateContextProvider")
-
- consoleSpy.mockRestore()
+ try {
+ expect(() => useExtensionState()).toThrow(
+ "useExtensionState must be used within an ExtensionStateContextProvider",
+ )
+ } finally {
+ useContextSpy.mockRestore()
+ }
})
it("updates apiConfiguration through setApiConfiguration", () => {