diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx index 443fdfa979..86e2fad9b4 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" } /** @@ -178,7 +178,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/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/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index d8f6d23299..15bb40e382 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -219,9 +219,10 @@ 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 () => { + // Issue #560: interrupted child resumes and reports back to parent. + // cancelTask() marks the child as "interrupted" but preserves the parent-child link, + // so when the child resumes and calls attempt_completion, it delegates back to the parent. + test("interrupted child resumes and reports back to parent", async () => { const api = globalThis.api const asks: Record = {} const messages: Record = {} @@ -237,24 +238,10 @@ suite("Roo Code Subtasks", function () { } } - 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 { + // 1) Start parent, wait for child to spawn const parentTaskId = await api.startNewTask({ configuration: { mode: "ask", @@ -277,55 +264,39 @@ suite("Roo Code Subtasks", function () { return false }) + // 2) Wait for child to reach a stable point (followup ask) await waitFor( () => asks[spawnedTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false, ) - const cancelledChildTaskId = spawnedTaskId! + // 3) Cancel the child — it becomes "interrupted", parent stays "delegated" + const interruptedChildTaskId = spawnedTaskId! await api.cancelCurrentTask() - await waitFor(() => api.getCurrentTaskStack().at(-1) === cancelledChildTaskId) + // 4) Wait for the child to show resume_task ask + await waitFor(() => api.getCurrentTaskStack().at(-1) === interruptedChildTaskId) await waitFor( () => - asks[cancelledChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? + asks[interruptedChildTaskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false, ) - const resumedChildTaskId = await waitUntilCompleted({ - api, - start: async () => { - await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER) - return cancelledChildTaskId - }, - }) + // 5) Resume the child by answering — it should complete and delegate back to parent + await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER) - 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`", - ) - 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", + // 6) Wait for the parent to complete (child reports back, parent resumes and finishes) + await waitFor( + () => + messages[parentTaskId]?.some( + ({ type, say, text }) => + type === "say" && say === "completion_result" && text === "Parent task resumed", + ) ?? false, ) - await api.clearCurrentTask() + // 7) Drain the task stack + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } } finally { api.off(RooCodeEventName.Message, messageHandler) } 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..1b5ffa4331 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -89,8 +89,8 @@ export interface CreateTaskOptions { consecutiveMistakeLimit?: number experiments?: Record initialTodos?: TodoItem[] - /** Initial status for the task's history item (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" + /** Initial status for the task's history item (e.g., "active" for child tasks, "interrupted" for cancelled subtasks) */ + 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__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index 6ed2a5c221..3163f1f102 100644 --- a/src/__tests__/removeClineFromStack-delegation.spec.ts +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -13,6 +13,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { childTaskId: string parentTaskId?: string parentHistoryItem?: Record + childHistoryItem?: Record getTaskWithIdError?: Error }) { const childTask = { @@ -30,6 +31,9 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { if (id === opts.parentTaskId && opts.parentHistoryItem) { return { historyItem: { ...opts.parentHistoryItem } } } + if (id === opts.childTaskId && opts.childHistoryItem) { + return { historyItem: { ...opts.childHistoryItem } } + } throw new Error("Task not found") }) @@ -44,7 +48,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { return { provider, childTask, updateTaskHistory, getTaskWithId } } - it("repairs parent metadata (delegated → active) when a delegated child is removed", async () => { + it("marks child as interrupted (preserving parent link) when a delegated child is removed", async () => { const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ childTaskId: "child-1", parentTaskId: "parent-1", @@ -61,6 +65,16 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { delegatedToId: "child-1", childIds: ["child-1"], }, + childHistoryItem: { + id: "child-1", + task: "Child task", + ts: 2000, + number: 2, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + parentTaskId: "parent-1", + }, }) await (ClineProvider.prototype as any).removeClineFromStack.call(provider) @@ -68,22 +82,24 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { // Stack should be empty after pop expect(provider.clineStack).toHaveLength(0) - // Parent lookup should have been called + // Both parent and child should have been looked up expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(getTaskWithId).toHaveBeenCalledWith("child-1") - // Parent metadata should be repaired + // Child should be marked as interrupted (parent stays delegated) expect(updateTaskHistory).toHaveBeenCalledTimes(1) - const updatedParent = updateTaskHistory.mock.calls[0][0] - expect(updatedParent).toEqual( + const updatedChild = updateTaskHistory.mock.calls[0][0] + expect(updatedChild).toEqual( expect.objectContaining({ - id: "parent-1", - status: "active", - awaitingChildId: undefined, + id: "child-1", + status: "interrupted", }), ) + // Parent should NOT have been updated + expect(updateTaskHistory).not.toHaveBeenCalledWith(expect.objectContaining({ id: "parent-1" })) - // Log the repair - expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Repaired parent parent-1 metadata")) + // Log the action + expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Marked child child-1 as interrupted")) }) it("does NOT modify parent metadata when the task has no parentTaskId (non-delegated)", async () => { @@ -152,7 +168,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("catches and logs errors during parent metadata repair without blocking the pop", async () => { + it("catches and logs errors during child interrupt marking without blocking the pop", async () => { const { provider, childTask, updateTaskHistory, getTaskWithId } = buildMockProvider({ childTaskId: "child-1", parentTaskId: "parent-1", @@ -170,7 +186,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { // Error should be logged as non-fatal expect(provider.log).toHaveBeenCalledWith( - expect.stringContaining("Failed to repair parent metadata for parent-1 (non-fatal)"), + expect.stringContaining("Failed to mark child as interrupted for parent-1 (non-fatal)"), ) // No update should have been attempted diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 4b77126971..702bab599b 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -23,8 +23,8 @@ export type TaskMetadataOptions = { mode?: string /** 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" + /** Initial status for the task (e.g., "active" for child tasks, "interrupted" for cancelled subtasks) */ + initialStatus?: "active" | "delegated" | "completed" | "interrupted" } export async function taskMetadata({ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4de3e595fc..c2ef01cefe 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -157,7 +157,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" } export class Task extends EventEmitter implements TaskLike { @@ -413,7 +413,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..6d967f0308 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -536,6 +536,99 @@ describe("attemptCompletionTool", () => { expect(mockPushToolResult).toHaveBeenCalledWith("") }) + it("delegates an interrupted subtask completion back to the parent that still awaits it", async () => { + const block: AttemptCompletionToolUse = { + type: "tool_use", + name: "attempt_completion", + params: { result: "resumed result" }, + nativeArgs: { result: "resumed result" }, + 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: "resumed result", + }) + expect(mockTask.ask).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("") + }) + + it("skips delegation and logs when child has unexpected status like delegated", async () => { + const block: AttemptCompletionToolUse = { + type: "tool_use", + name: "attempt_completion", + params: { result: "unexpected" }, + nativeArgs: { result: "unexpected" }, + partial: false, + } + const mockProvider = { + log: vi.fn(), + getTaskWithId: vi.fn().mockImplementation((id: string) => { + if (id === "child-1") { + return Promise.resolve({ historyItem: { id, status: "delegated" } }) + } + throw new Error(`unexpected task id ${id}`) + }), + reopenParentFromDelegation: vi.fn(), + } + + Object.assign(mockTask, { + taskId: "child-1", + parentTaskId: "parent-1", + providerRef: { deref: () => mockProvider }, + }) + mockTask.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: "", images: [] }) + + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) + + expect(mockProvider.log).toHaveBeenCalledWith( + expect.stringContaining('Unexpected child task status "delegated"'), + ) + expect(mockProvider.reopenParentFromDelegation).not.toHaveBeenCalled() + }) + it("falls through to standalone completion when parent delegation becomes stale after approval", async () => { const block: AttemptCompletionToolUse = { type: "tool_use", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index dc19a58ce2..208001180a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -508,10 +508,10 @@ export class ClineProvider // garbage collected. task = undefined - // Delegation-aware parent metadata repair: - // If the popped task was a delegated child, repair the parent's metadata - // so it transitions from "delegated" back to "active" and becomes resumable - // from the task history list. + // Delegation-aware child interrupt marking: + // If the popped task was a delegated child, mark it as "interrupted" + // while preserving the parent-child link so the child can still + // report back to the parent when resumed. // Skip when called from delegateParentAndOpenChild() during nested delegation // transitions (A→B→C), where the caller intentionally replaces the active // child and will update the parent to point at the new child. @@ -521,22 +521,24 @@ export class ClineProvider const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === childTaskId) { + // Mark child as interrupted but preserve the parent-child link + // so the child can still report back when resumed. + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) await this.updateTaskHistory({ - ...parentHistory, - status: "active", - awaitingChildId: undefined, + ...childHistory, + status: "interrupted", }) - const repairMsg = - `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed). ` + + const logMsg = + `[ClineProvider#removeClineFromStack] Marked child ${childTaskId} as interrupted (parent ${parentTaskId} stays delegated). ` + `Caller stack: ${callerStack?.split("\n").slice(1, 5).join(" | ")}` - this.log(repairMsg) - console.warn(repairMsg) + this.log(logMsg) + console.warn(logMsg) } }) } catch (err) { // Non-fatal: log but do not block the pop operation. this.log( - `[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${ + `[ClineProvider#removeClineFromStack] Failed to mark child as interrupted for ${parentTaskId} (non-fatal): ${ err instanceof Error ? err.message : String(err) }`, ) @@ -3168,23 +3170,26 @@ export class ClineProvider const { historyItem: parentHistory } = await this.getTaskWithId(task.parentTaskId!) if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) { - await this.updateTaskHistory({ - ...parentHistory, - status: "active", - awaitingChildId: undefined, - }) + // Mark the child as interrupted but preserve the parent-child link. + // The parent stays "delegated" with awaitingChildId intact so that + // when the user resumes the child and it calls attempt_completion, + // it can still report back to the parent. + // historyItem is guaranteed non-null by the early return guard above. + historyItem = { + ...historyItem!, + status: "interrupted", + } + await this.updateTaskHistory(historyItem) this.log( - `[cancelTask] Detached delegated parent ${task.parentTaskId}: delegated → active (child ${task.taskId} cancelled)`, + `[cancelTask] Marked child ${task.taskId} as 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 + // Fail closed: if we cannot prove the child was marked interrupted, make the // rehydrated child standalone so later completions cannot reopen a // stale delegated parent, even after a provider reload. parentTask = undefined @@ -3206,7 +3211,7 @@ export class ClineProvider throw historyError } this.log( - `[cancelTask] Failed to detach delegated parent for ${task.taskId}: ${ + `[cancelTask] Failed to mark child as interrupted for ${task.taskId}: ${ error instanceof Error ? error.message : String(error) }`, ) @@ -3555,10 +3560,10 @@ export class ClineProvider const { historyItem } = await this.getTaskWithId(parentTaskId) // Guard: re-validate delegation state after the async approval gap. - // cancelTask() or removeClineFromStack() may have already detached the parent - // (setting status → "active", awaitingChildId → undefined) while the user was - // approving the subtask finish. If the parent no longer awaits this child, - // routing output back would corrupt an unrelated task. + // The cancelledDelegationChildIds set (fail-closed fallback) or an explicit + // abandon action may have detached the parent while the user was approving + // the subtask finish. If the parent no longer awaits this child, routing + // output back would corrupt an unrelated task. if ( this.cancelledDelegationChildIds.has(childTaskId) || (historyItem.status !== "delegated" && historyItem.status !== "active") || 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 2710136978..274e015245 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -321,7 +321,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 cancelled delegated child as interrupted while preserving parent-child link", async () => { const mockRootTask = { taskId: "root-1" } const mockParentTask = { taskId: "parent-1" } const childHistory: HistoryItem = { @@ -381,25 +381,27 @@ describe("ClineProvider flicker-free cancel", () => { await provider.cancelTask() + // Child should be marked as interrupted (not detached from parent) expect(updateTaskHistorySpy).toHaveBeenCalledWith( expect.objectContaining({ - id: "parent-1", - status: "active", - awaitingChildId: undefined, + id: "child-1", + status: "interrupted", + parentTaskId: "parent-1", + rootTaskId: "root-1", }), ) + // Parent links should be preserved for rehydration expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith( expect.objectContaining({ id: "child-1", + status: "interrupted", parentTaskId: "parent-1", rootTaskId: "root-1", - parentTask: undefined, - rootTask: undefined, }), ) }) - it("detaches runtime parent links when delegated parent detach fails", async () => { + it("detaches runtime parent links when marking child as interrupted fails", async () => { const mockRootTask = { taskId: "root-1" } const mockParentTask = { taskId: "parent-1" } const childHistory: HistoryItem = { @@ -447,7 +449,9 @@ 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 as interrupted for child-1: parent lookup failed", + ), ) expect(updateTaskHistorySpy).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 6850532e04..c1b0a3df2b 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -254,7 +254,7 @@ describe("McpHub", () => { // Create McpHub and let it initialize const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Find the connection const connection = mcpHub.connections.find((conn) => conn.server.name === "union-test-server") @@ -286,7 +286,7 @@ describe("McpHub", () => { // Create McpHub and let it initialize const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Find the connection const connection = mcpHub.connections.find((conn) => conn.server.name === "disabled-union-server") @@ -315,7 +315,7 @@ describe("McpHub", () => { const mcpHub = new McpHub(mockProvider as ClineProvider) // Wait for initialization - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Clear any connections that might have been created mcpHub.connections = [] @@ -426,7 +426,7 @@ describe("McpHub", () => { ) const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Verify watcher was created expect(chokidar.watch).toHaveBeenCalledWith(["/path/to/watch"], expect.any(Object)) @@ -503,7 +503,7 @@ describe("McpHub", () => { ) const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Verify watchers were created expect(chokidar.watch).toHaveBeenCalled() @@ -537,7 +537,7 @@ describe("McpHub", () => { vi.mocked(chokidar.watch).mockClear() const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Verify no watcher was created for disabled server expect(chokidar.watch).not.toHaveBeenCalled() @@ -561,7 +561,7 @@ describe("McpHub", () => { ) const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Find the connection const connection = mcpHub.connections.find((conn) => conn.server.name === "mcp-disabled-server") @@ -587,7 +587,7 @@ describe("McpHub", () => { ) const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Find the connection const connection = mcpHub.connections.find((conn) => conn.server.name === "server-disabled-server") @@ -614,7 +614,7 @@ describe("McpHub", () => { ) const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Find the connection const connection = mcpHub.connections.find((conn) => conn.server.name === "both-reasons-server") @@ -645,7 +645,7 @@ describe("McpHub", () => { const mcpHub = new McpHub(mockProvider as ClineProvider) // Wait for initialization - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // The server should be created as a disconnected connection with null client/transport const connection = mcpHub.connections.find((conn) => conn.server.name === "null-safety-server") @@ -715,7 +715,7 @@ describe("McpHub", () => { ) const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Get the connection const connection = mcpHub.connections.find((conn) => conn.server.name === "type-check-server") @@ -733,7 +733,7 @@ describe("McpHub", () => { it("should handle missing connections safely", async () => { const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Try operations on non-existent server await expect(mcpHub.callTool("non-existent-server", "test-tool", {})).rejects.toThrow( @@ -791,7 +791,7 @@ describe("McpHub", () => { ) const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Delete the connection await mcpHub.deleteConnection("delete-safety-server") @@ -1414,7 +1414,7 @@ describe("McpHub", () => { const mcpHub = new McpHub(mockProvider as ClineProvider) // Wait for initialization - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // The server should be created as a disconnected connection const connection = mcpHub.connections.find((conn) => conn.server.name === "disabled-server") @@ -1445,7 +1445,7 @@ describe("McpHub", () => { const mcpHub = new McpHub(mockProvider as ClineProvider) // Wait for initialization - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // The server should be created as a disconnected connection const connection = mcpHub.connections.find((conn) => conn.server.name === "disabled-server") @@ -1831,7 +1831,7 @@ describe("McpHub", () => { // Create McpHub and let it initialize with MCP enabled const mcpHub = new McpHub(mockProvider as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Verify server is connected const connectedServer = mcpHub.connections.find((conn) => conn.server.name === "toggle-test-server") @@ -1889,7 +1889,7 @@ describe("McpHub", () => { const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider) // Wait for initialization - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Find the disabled-test-server const disabledServer = mcpHub.connections.find((conn) => conn.server.name === "disabled-test-server") @@ -1961,7 +1961,7 @@ describe("McpHub", () => { const mcpHub = new McpHub(enabledMockProvider as unknown as ClineProvider) // Wait for initialization - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Find the enabled-test-server const enabledServer = mcpHub.connections.find((conn) => conn.server.name === "enabled-test-server") @@ -2000,7 +2000,7 @@ describe("McpHub", () => { // Create McpHub with disabled MCP const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Clear previous calls vi.clearAllMocks() @@ -2047,7 +2047,7 @@ describe("McpHub", () => { // Create McpHub with disabled MCP const mcpHub = new McpHub(disabledMockProvider as unknown as ClineProvider) - await new Promise((resolve) => setTimeout(resolve, 100)) + await mcpHub.waitUntilReady() // Set isConnecting to false to ensure it's properly reset mcpHub.isConnecting = false