From e0700a2246286930350e28f71a59fec6bd1bc0e5 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 3 Jul 2026 00:03:42 +0000 Subject: [PATCH 1/5] fix(task-lifecycle): preserve parent-child link when delegated subtask is interrupted --- packages/core/src/task-history/index.ts | 5 +- packages/types/src/history.ts | 2 +- packages/types/src/task.ts | 2 +- src/__tests__/helpers/provider-stub.ts | 1 + .../removeClineFromStack-delegation.spec.ts | 57 +++++++ src/core/task-persistence/TaskHistoryStore.ts | 3 +- src/core/task-persistence/taskMetadata.ts | 2 +- src/core/task/Task.ts | 4 +- src/core/tools/AttemptCompletionTool.ts | 4 +- .../__tests__/attemptCompletionTool.spec.ts | 51 +++++++ src/core/webview/ClineProvider.ts | 51 ++++--- .../ClineProvider.flicker-free-cancel.spec.ts | 144 ++++++++++++++++-- 12 files changed, 286 insertions(+), 40 deletions(-) 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/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..54af0e6c58 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -13,6 +13,7 @@ export function makeProviderStub(stub: T): T { s.delegationTransitionLocks ??= new Map() s.cancelledDelegationChildIds ??= 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..9200bc8c37 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: ["active", "completed"], completed: [], } 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..924e7101ed 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 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..ba799ea60b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -533,6 +533,16 @@ 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 + } + assertValidTransition(parentHistory.status, "active") await this.updateTaskHistory({ ...parentHistory, @@ -3159,8 +3169,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 +3194,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 +3228,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 +3255,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..20bcab4ab1 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,126 @@ 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" }), + ) + }) + afterAll(() => { vi.restoreAllMocks() }) From 5800e0d4a6700072e8811f13ad0d8b59793f2ddb Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 3 Jul 2026 00:04:15 +0000 Subject: [PATCH 2/5] test(e2e): interrupted child resumes and reports back to parent --- .../autocomplete/triggers/HistoryTrigger.tsx | 22 ++- .../__tests__/HistoryTrigger.test.tsx | 18 ++ apps/cli/src/ui/types.ts | 2 +- apps/vscode-e2e/src/fixtures/subtasks.ts | 92 ++++++++++ apps/vscode-e2e/src/suite/subtasks.test.ts | 163 +++++++++++++----- packages/types/src/api.ts | 6 + src/core/task/Task.ts | 11 +- src/extension/api.ts | 5 + 8 files changed, 263 insertions(+), 56 deletions(-) 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..82a6d9d506 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.` @@ -252,4 +259,89 @@ 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. + 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, [SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER]), + }, + 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: "9" }), + id: "call_interrupt_child_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + toolCallId: "call_interrupt_parent_new_task_001", + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: "Interrupted parent resumed" }), + 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..ec3689485d 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -7,6 +7,9 @@ import { 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, @@ -318,10 +321,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 +335,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 +368,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 +380,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 +512,103 @@ 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", + ) + + const parentCompletionText = says[parentTaskId] + ?.filter(({ say }) => say === "completion_result") + .map(({ text }) => text?.trim()) + .find((t): t is string => !!t) + + assert.strictEqual( + parentCompletionText, + 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/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/src/core/task/Task.ts b/src/core/task/Task.ts index 924e7101ed..56fccf5e66 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1853,13 +1853,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/extension/api.ts b/src/extension/api.ts index e64b4add82..90e7574a6b 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -240,6 +240,11 @@ export class API extends EventEmitter implements RooCodeAPI { return item ? structuredClone(item) : undefined } + public async getTaskApiConversationHistoryLength(taskId: string) { + const { apiConversationHistory } = await this.sidebarProvider.getTaskWithId(taskId) + return apiConversationHistory.length + } + public getCurrentTaskStack() { return this.sidebarProvider.getCurrentTaskStack() } From fff4bd08bf65fb1c0d05156f5cd9a650999c9922 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 3 Jul 2026 17:03:28 +0000 Subject: [PATCH 3/5] Fix interrupted subtask resume state restoration --- apps/vscode-e2e/src/fixtures/subtasks.ts | 34 +++++++++----- apps/vscode-e2e/src/suite/subtasks.test.ts | 15 +++--- src/core/task-persistence/TaskHistoryStore.ts | 6 +-- .../TaskHistoryStore.reconciliation.spec.ts | 38 +++++++++++++++ src/core/task/Task.ts | 4 +- .../task/__tests__/Task.persistence.spec.ts | 46 +++++++++++++++++++ src/core/webview/ClineProvider.ts | 10 ++-- .../ClineProvider.sticky-profile.spec.ts | 30 ++++++++++++ .../src/context/ExtensionStateContext.tsx | 4 +- .../__tests__/ExtensionStateContext.spec.tsx | 17 +++---- 10 files changed, 166 insertions(+), 38 deletions(-) diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 82a6d9d506..082f61d369 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -39,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: [ @@ -97,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: [ @@ -150,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: [ @@ -282,13 +286,18 @@ export function addSubtaskFixtures(mock: InstanceType) { // 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, [SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER]), + !requestContains(req, [ + `\\n${SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER}\\n`, + ]), }, response: { toolCalls: [ @@ -332,7 +341,8 @@ export function addSubtaskFixtures(mock: InstanceType) { mock.addFixture({ match: { - toolCallId: "call_interrupt_parent_new_task_001", + predicate: (req: ChatCompletionRequest) => + requestContains(req, [SUBTASK_INTERRUPT_PARENT_MARKER, "call_interrupt_parent_new_task_001"]), }, response: { toolCalls: [ diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index ec3689485d..95d74970b9 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -3,7 +3,7 @@ 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, @@ -72,6 +72,7 @@ suite("Roo Code Subtasks", function () { while (api.getCurrentTaskStack().length > 0) { await api.clearCurrentTask() } + await sleep(1_500) } }) @@ -578,7 +579,7 @@ suite("Roo Code Subtasks", function () { "Parent must not have resumed while child is interrupted", ) - // Resume the child and answer the followup — child should complete and reopen parent + // Resume the child and answer the followup — child should complete and reopen parent. const completedParentTaskId = await waitUntilCompleted({ api, start: async () => { @@ -593,13 +594,11 @@ suite("Roo Code Subtasks", function () { "Parent task should be the one that completes after interrupted child reports back", ) - const parentCompletionText = says[parentTaskId] - ?.filter(({ say }) => say === "completion_result") - .map(({ text }) => text?.trim()) - .find((t): t is string => !!t) - assert.strictEqual( - parentCompletionText, + 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", ) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 9200bc8c37..219918b029 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -14,7 +14,7 @@ export type HistoryItemStatus = NonNullable const VALID_TRANSITIONS: Record = { active: ["delegated", "completed", "interrupted"], delegated: ["active"], - interrupted: ["active", "completed"], + interrupted: ["completed"], completed: [], } @@ -356,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 () => { @@ -418,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/Task.ts b/src/core/task/Task.ts index 56fccf5e66..7bee3c5e14 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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) 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/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ba799ea60b..20be6f2323 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1074,10 +1074,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( 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/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", () => { From 46c1889a52c64dffb998ec32585881d78147f290 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 3 Jul 2026 23:34:41 +0000 Subject: [PATCH 4/5] fix(task-lifecycle): prevent parent repair from winning race against child cancellation --- src/__tests__/helpers/provider-stub.ts | 5 +- src/core/webview/ClineProvider.ts | 38 ++++++++++++ .../ClineProvider.flicker-free-cancel.spec.ts | 62 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 54af0e6c58..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,6 +12,7 @@ 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) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 20be6f2323..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 @@ -543,6 +551,19 @@ export class ClineProvider 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, @@ -3142,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) 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 20bcab4ab1..555253c345 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -824,6 +824,68 @@ describe("ClineProvider flicker-free cancel", () => { ) }) + // 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() }) From 601d3693cde4aa8d137944f6e7b393251ca34172 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 4 Jul 2026 02:29:07 +0000 Subject: [PATCH 5/5] fix(api): return 0 instead of throwing for unavailable task history length --- apps/vscode-e2e/src/fixtures/subtasks.ts | 4 +- ...i-task-conversation-history-length.spec.ts | 45 +++++++++++++++++++ src/extension/api.ts | 10 +++-- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 src/extension/__tests__/api-task-conversation-history-length.spec.ts diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 082f61d369..772eb3306a 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -332,7 +332,7 @@ export function addSubtaskFixtures(mock: InstanceType) { toolCalls: [ { name: "attempt_completion", - arguments: JSON.stringify({ result: "9" }), + arguments: JSON.stringify({ result: SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER }), id: "call_interrupt_child_completion_002", }, ], @@ -348,7 +348,7 @@ export function addSubtaskFixtures(mock: InstanceType) { toolCalls: [ { name: "attempt_completion", - arguments: JSON.stringify({ result: "Interrupted parent resumed" }), + arguments: JSON.stringify({ result: SUBTASK_INTERRUPT_PARENT_RESULT }), id: "call_interrupt_parent_completion_003", }, ], 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 90e7574a6b..c140ec8ec0 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -240,9 +240,13 @@ export class API extends EventEmitter implements RooCodeAPI { return item ? structuredClone(item) : undefined } - public async getTaskApiConversationHistoryLength(taskId: string) { - const { apiConversationHistory } = await this.sidebarProvider.getTaskWithId(taskId) - return apiConversationHistory.length + public async getTaskApiConversationHistoryLength(taskId: string): Promise { + try { + const { apiConversationHistory } = await this.sidebarProvider.getTaskWithId(taskId) + return apiConversationHistory.length + } catch { + return 0 + } } public getCurrentTaskStack() {