From 77efcde88a55f7e4a5c7ae8ee6203409714cac48 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 6 Jun 2026 15:03:14 +0000 Subject: [PATCH 1/3] chore: add subtask delegation diagnostics --- apps/vscode-e2e/src/fixtures/subtasks.ts | 52 +++++++ apps/vscode-e2e/src/suite/subtasks.test.ts | 135 +++++++++++++++++- src/core/tools/AttemptCompletionTool.ts | 10 +- .../__tests__/attemptCompletionTool.spec.ts | 4 + src/core/webview/ClineProvider.ts | 5 +- 5 files changed, 202 insertions(+), 4 deletions(-) diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 33bb2c9cbf..10b89cf263 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -5,10 +5,14 @@ import { toolResultContains } from "./tool-result" const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE" const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE" +const SUBTASK_FAST_PARENT_MARKER = "SUBTASK_PARENT_IMMEDIATE_COMPLETION" +const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION" const SUBTASK_CHILD_PROMPT = `${SUBTASK_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_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.` 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 requestContains = (req: ChatCompletionRequest, expected: string[]) => { const rawRequest = JSON.stringify(req) @@ -40,6 +44,54 @@ const completionAfterAnswer = (followupId: string, completionId: string) => ({ }) export function addSubtaskFixtures(mock: InstanceType) { + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_FAST_PARENT_MARKER), + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: SUBTASK_FAST_CHILD_PROMPT, + }), + id: "call_subtasks_fast_parent_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_FAST_CHILD_MARKER), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: "Fast child completed" }), + id: "call_subtasks_fast_child_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + toolCallId: "call_subtasks_fast_parent_new_task_001", + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: "Fast parent resumed" }), + id: "call_subtasks_fast_parent_completion_003", + }, + ], + }, + }) + mock.addFixture({ match: { userMessage: new RegExp(SUBTASK_PARENT_MARKER), diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 51cf24f6a3..121476a077 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -4,11 +4,144 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types" import { setDefaultSuiteTimeout } from "./test-utils" import { waitFor, waitUntilCompleted } from "./utils" -import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_PARENT_PROMPT } from "../fixtures/subtasks" +import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_FAST_PARENT_PROMPT, SUBTASK_PARENT_PROMPT } from "../fixtures/subtasks" suite("Roo Code Subtasks", function () { setDefaultSuiteTimeout(this) + test("child completing on its first response returns to parent", async () => { + const api = globalThis.api + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + try { + const parentTaskId = await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SUBTASK_FAST_PARENT_PROMPT, + }), + }) + + assert.ok( + Object.entries(says).some( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => say === "completion_result" && text?.trim() === "Fast child completed", + ), + ), + "Immediately-completing child should emit its expected result", + ) + assert.strictEqual( + says[parentTaskId] + ?.filter(({ say }) => say === "completion_result") + .map(({ text }) => text?.trim()) + .find((text): text is string => !!text), + "Fast parent resumed", + "Parent should resume after the child completes on its first response", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + } + }) + + // Smoke: child completing normally must resume the parent task. + test("child task returns to parent after normal completion", 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_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, then answer so it can complete. + // Register the completion listener before sending the answer to avoid a race. + await waitFor(() => asks[childTaskId!]?.some(({ ask }) => ask === "followup") ?? false) + await waitUntilCompleted({ + api, + start: async () => { + await api.sendMessage(SUBTASK_CHILD_FOLLOWUP_ANSWER) + return parentTaskId + }, + }) + + const parentCompletionText = says[parentTaskId] + ?.filter(({ say }) => say === "completion_result") + .map(({ text }) => text?.trim()) + .find((t): t is string => !!t) + + assert.strictEqual( + parentCompletionText, + "Parent task resumed", + "Parent should complete with the expected result after child returns", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + // Drain the stack so partially-completed tasks don't leak into the next test. + // On the happy path the parent is already gone; on failure both tasks may still be active. + if (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + if (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {}) + } + }) + // Race mitigation: skipDelegationRepair prevents removeClineFromStack from // auto-resuming the parent when the child is cancelled (Race 2). test("parent stays paused after subtask cancellation", async () => { diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 3347f63f40..18f811452c 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -25,6 +25,7 @@ export interface AttemptCompletionCallbacks extends ToolCallbacks { * Interface for provider methods needed by AttemptCompletionTool for delegation handling. */ interface DelegationProvider { + log(message: string): void getTaskWithId(id: string): Promise<{ historyItem: HistoryItem }> reopenParentFromDelegation(params: { parentTaskId: string @@ -118,12 +119,17 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } else { // Parent already detached, such as when the user cancelled this child. // Fall through to the normal completion ask flow. + provider.log( + `[AttemptCompletionTool] Skipping delegation for child ${task.taskId}: ` + + `parent ${task.parentTaskId} is not awaiting this child. ` + + `Diagnostic: { childStatus: "${status}", parentStatus: "${parentHistory?.status}", awaitingChildId: "${parentHistory?.awaitingChildId}" }`, + ) } } else { // Unexpected status (undefined or "delegated") - log error and skip delegation // undefined indicates a bug in status persistence during child creation // "delegated" would mean this child has its own grandchild pending (shouldn't reach attempt_completion) - console.error( + provider.log( `[AttemptCompletionTool] Unexpected child task status "${status}" for task ${task.taskId}. ` + `Expected "active" or "completed". Skipping delegation to prevent data corruption.`, ) @@ -131,7 +137,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } } catch (err) { // If we can't get the history, log error and skip delegation - console.error( + provider.log( `[AttemptCompletionTool] Failed to get history for task ${historyLookupTaskId}: ${(err as Error)?.message ?? String(err)}. ` + `Skipping delegation.`, ) diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index b64b6c57a0..4815f93741 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -493,6 +493,7 @@ describe("attemptCompletionTool", () => { partial: false, } const mockProvider = { + log: vi.fn(), getTaskWithId: vi.fn().mockImplementation((id: string) => { if (id === "child-1") { return Promise.resolve({ historyItem: { id, status: "active" } }) @@ -543,6 +544,7 @@ describe("attemptCompletionTool", () => { partial: false, } const mockProvider = { + log: vi.fn(), getTaskWithId: vi.fn().mockImplementation((id: string) => { if (id === "child-1") { return Promise.resolve({ historyItem: { id, status: "active" } }) @@ -594,6 +596,7 @@ describe("attemptCompletionTool", () => { partial: false, } const mockProvider = { + log: vi.fn(), getTaskWithId: vi.fn().mockImplementation((id: string) => { if (id === "child-1") { return Promise.resolve({ historyItem: { id, status: "active" } }) @@ -627,6 +630,7 @@ describe("attemptCompletionTool", () => { expect(mockAskFinishSubTaskApproval).not.toHaveBeenCalled() expect(mockProvider.reopenParentFromDelegation).not.toHaveBeenCalled() + expect(mockProvider.log).toHaveBeenCalledWith(expect.stringContaining("Skipping delegation")) expect(mockTask.ask).toHaveBeenCalledWith("completion_result", "", false) expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("child-1") }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b9f897511f..ecbb0c7b40 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -469,6 +469,8 @@ export class ClineProvider // Removes and destroys the top Cline instance (the current finished task), // activating the previous one (resuming the parent task). async removeClineFromStack(options?: { skipDelegationRepair?: boolean }) { + const callerStack = new Error().stack + if (this.clineStack.length === 0) { return } @@ -525,7 +527,8 @@ export class ClineProvider awaitingChildId: undefined, }) this.log( - `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed)`, + `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed). ` + + `Caller stack: ${callerStack?.split("\n").slice(1, 5).join(" | ")}`, ) } }) From d7a98bcb045b7dac25d924686b1c2be80552448b Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 6 Jun 2026 17:17:01 +0000 Subject: [PATCH 2/3] fix(AttempCompletionTool): allow completion of active parents --- apps/vscode-e2e/src/fixtures/subtasks.ts | 100 +++++++++++++++ apps/vscode-e2e/src/suite/subtasks.test.ts | 118 +++++++++++++++++- .../history-resume-delegation.spec.ts | 4 +- src/core/tools/AttemptCompletionTool.ts | 11 +- .../__tests__/attemptCompletionTool.spec.ts | 52 +++++++- src/core/webview/ClineProvider.ts | 9 +- 6 files changed, 280 insertions(+), 14 deletions(-) diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 10b89cf263..7787671b09 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -7,6 +7,9 @@ const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE" const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE" 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" +const SUBTASK_XPROFILE_SAME_CHILD_MARKER = "SUBTASK_CHILD_SAME_PROFILE" +const SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER = "SUBTASK_CHILD_DIFFERENT_PROFILE" const SUBTASK_CHILD_PROMPT = `${SUBTASK_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_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.` @@ -14,6 +17,13 @@ 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_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.` +export const SUBTASK_XPROFILE_SAME_CHILD_RESULT = "Same-profile child completed" +export const SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT = "Different-profile child completed" +export const SUBTASK_XPROFILE_PARENT_RESULT = "Sequential cross-profile parent resumed" + const requestContains = (req: ChatCompletionRequest, expected: string[]) => { const rawRequest = JSON.stringify(req) return expected.every((text) => rawRequest.includes(text)) @@ -144,4 +154,94 @@ export function addSubtaskFixtures(mock: InstanceType) { ], }, }) + + // Issue #457 sequence: a same-profile child returns first, then the resumed + // parent delegates to a child whose mode uses a different API profile. + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_XPROFILE_PARENT_MARKER), + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "code", + message: SUBTASK_XPROFILE_SAME_CHILD_PROMPT, + }), + id: "call_subtasks_xprofile_parent_same_child_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_XPROFILE_SAME_CHILD_MARKER), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_XPROFILE_SAME_CHILD_RESULT }), + id: "call_subtasks_xprofile_same_child_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [SUBTASK_XPROFILE_PARENT_MARKER, SUBTASK_XPROFILE_SAME_CHILD_RESULT]) && + !requestContains(req, [SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT]), + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: SUBTASK_XPROFILE_DIFFERENT_CHILD_PROMPT, + }), + id: "call_subtasks_xprofile_parent_different_child_003", + }, + ], + }, + }) + + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT }), + id: "call_subtasks_xprofile_different_child_completion_004", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [ + SUBTASK_XPROFILE_PARENT_MARKER, + SUBTASK_XPROFILE_SAME_CHILD_RESULT, + SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT, + ]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_XPROFILE_PARENT_RESULT }), + id: "call_subtasks_xprofile_parent_completion_005", + }, + ], + }, + }) } diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 121476a077..9ec3e3079c 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -4,7 +4,15 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types" import { setDefaultSuiteTimeout } from "./test-utils" import { waitFor, waitUntilCompleted } from "./utils" -import { SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_FAST_PARENT_PROMPT, SUBTASK_PARENT_PROMPT } from "../fixtures/subtasks" +import { + SUBTASK_CHILD_FOLLOWUP_ANSWER, + SUBTASK_FAST_PARENT_PROMPT, + SUBTASK_PARENT_PROMPT, + SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT, + SUBTASK_XPROFILE_PARENT_PROMPT, + SUBTASK_XPROFILE_PARENT_RESULT, + SUBTASK_XPROFILE_SAME_CHILD_RESULT, +} from "../fixtures/subtasks" suite("Roo Code Subtasks", function () { setDefaultSuiteTimeout(this) @@ -322,4 +330,112 @@ suite("Roo Code Subtasks", function () { api.off(RooCodeEventName.Message, messageHandler) } }) + + test("same-profile child returns before a different-profile child", async () => { + const api = globalThis.api + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + const aimockUrl = process.env.AIMOCK_URL + const parentProfile = { + apiProvider: "openrouter" as const, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + rateLimitSeconds: 0, + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + } + const childProfile = { + ...parentProfile, + openRouterModelId: "openai/gpt-4.1-mini", + } + const parentProfileId = await api.upsertProfile("subtask-parent-profile", parentProfile, true) + const childProfileId = await api.upsertProfile("subtask-child-profile", childProfile, false) + await api.setConfiguration({ + modeApiConfigs: { + code: parentProfileId!, + ask: childProfileId!, + }, + }) + + try { + let parentTaskId: string + try { + parentTaskId = await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "code", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SUBTASK_XPROFILE_PARENT_PROMPT, + }), + }) + } catch (error) { + const messageSummary = Object.fromEntries( + Object.entries(says).map(([taskId, messages]) => [ + taskId, + messages.map(({ say, text }) => ({ say, text: text?.slice(0, 200) })), + ]), + ) + throw new Error( + `Sequential cross-profile subtasks did not complete. Stack: ${JSON.stringify(api.getCurrentTaskStack())}; ` + + `messages: ${JSON.stringify(messageSummary)}`, + { cause: error }, + ) + } + + const sameProfileChildId = Object.entries(says).find( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === SUBTASK_XPROFILE_SAME_CHILD_RESULT, + ), + )?.[0] + const differentProfileChildId = Object.entries(says).find( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT, + ), + )?.[0] + + assert.ok(sameProfileChildId, "Same-profile child should return to the parent") + assert.ok(differentProfileChildId, "Different-profile child should return to the parent") + assert.notStrictEqual( + sameProfileChildId, + differentProfileChildId, + "Parent should delegate to two distinct child tasks", + ) + assert.strictEqual( + says[parentTaskId] + ?.filter(({ say }) => say === "completion_result") + .map(({ text }) => text?.trim()) + .find((text): text is string => !!text), + SUBTASK_XPROFILE_PARENT_RESULT, + "Parent should resume after both sequential children complete", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + await api.setConfiguration({ modeApiConfigs: {} }) + await api.deleteProfile("subtask-child-profile").catch(() => {}) + await api.deleteProfile("subtask-parent-profile").catch(() => {}) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + } + }) }) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 6fc0686626..587ae917a6 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -45,12 +45,12 @@ describe("History resume delegation - parent metadata transitions", () => { vi.clearAllMocks() }) - it("reopenParentFromDelegation persists parent metadata (delegated → active) before reopen", async () => { + it("reopenParentFromDelegation accepts an active parent awaiting the returning child", async () => { const providerEmit = vi.fn() const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: { id: "parent-1", - status: "delegated", + status: "active", delegatedToId: "child-1", awaitingChildId: "child-1", childIds: ["child-1"], diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 18f811452c..c6c9bc908e 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -102,7 +102,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { const { historyItem: parentHistory } = await provider.getTaskWithId(task.parentTaskId) if ( - parentHistory?.status === "delegated" && + (parentHistory?.status === "delegated" || parentHistory?.status === "active") && parentHistory?.awaitingChildId === task.taskId ) { const delegation = await this.delegateToParent( @@ -119,11 +119,12 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } else { // Parent already detached, such as when the user cancelled this child. // Fall through to the normal completion ask flow. - provider.log( + const msg = `[AttemptCompletionTool] Skipping delegation for child ${task.taskId}: ` + - `parent ${task.parentTaskId} is not awaiting this child. ` + - `Diagnostic: { childStatus: "${status}", parentStatus: "${parentHistory?.status}", awaitingChildId: "${parentHistory?.awaitingChildId}" }`, - ) + `parent ${task.parentTaskId} is not awaiting this child. ` + + `Diagnostic: { childStatus: "${status}", parentStatus: "${parentHistory?.status}", awaitingChildId: "${parentHistory?.awaitingChildId}" }` + provider.log(msg) + console.warn(msg) } } else { // Unexpected status (undefined or "delegated") - log error and skip delegation diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index 4815f93741..fdcd49b282 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -484,7 +484,7 @@ describe("attemptCompletionTool", () => { }) describe("completion lifecycle", () => { - it("delegates an active subtask completion only when the parent is awaiting that child", async () => { + it("delegates an active subtask completion when the active parent awaits that child", async () => { const block: AttemptCompletionToolUse = { type: "tool_use", name: "attempt_completion", @@ -500,7 +500,7 @@ describe("attemptCompletionTool", () => { } if (id === "parent-1") { return Promise.resolve({ - historyItem: { id, status: "delegated", awaitingChildId: "child-1" }, + historyItem: { id, status: "active", awaitingChildId: "child-1" }, }) } throw new Error(`unexpected task id ${id}`) @@ -635,6 +635,54 @@ describe("attemptCompletionTool", () => { expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("child-1") }) + it("does not resume the parent when the parent is active but awaiting a different 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: "active" } }) + } + if (id === "parent-1") { + return Promise.resolve({ + historyItem: { id, status: "active", awaitingChildId: "different-child" }, + }) + } + throw new Error(`unexpected task id ${id}`) + }), + reopenParentFromDelegation: vi.fn().mockResolvedValue(undefined), + } + + 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).not.toHaveBeenCalled() + expect(mockProvider.reopenParentFromDelegation).not.toHaveBeenCalled() + expect(mockProvider.log).toHaveBeenCalledWith(expect.stringContaining("Skipping delegation")) + expect(mockTask.ask).toHaveBeenCalledWith("completion_result", "", false) + expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("child-1") + }) + it("emits TaskCompleted only when completion is accepted", async () => { const block: AttemptCompletionToolUse = { type: "tool_use", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ecbb0c7b40..14b8ed306c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -526,10 +526,11 @@ export class ClineProvider status: "active", awaitingChildId: undefined, }) - this.log( + const repairMsg = `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed). ` + - `Caller stack: ${callerStack?.split("\n").slice(1, 5).join(" | ")}`, - ) + `Caller stack: ${callerStack?.split("\n").slice(1, 5).join(" | ")}` + this.log(repairMsg) + console.warn(repairMsg) } }) } catch (err) { @@ -3541,7 +3542,7 @@ export class ClineProvider // routing output back would corrupt an unrelated task. if ( this.cancelledDelegationChildIds.has(childTaskId) || - historyItem.status !== "delegated" || + (historyItem.status !== "delegated" && historyItem.status !== "active") || historyItem.awaitingChildId !== childTaskId ) { this.log( From 4610768a34f71fbbfe47efab6570379e174e8d0f Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sun, 7 Jun 2026 00:48:30 +0000 Subject: [PATCH 3/3] test(subtasks): harness updates --- apps/vscode-e2e/src/fixtures/subtasks.ts | 2 ++ apps/vscode-e2e/src/suite/subtasks.test.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 7787671b09..e14bf352e1 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -57,6 +57,7 @@ export function addSubtaskFixtures(mock: InstanceType) { mock.addFixture({ match: { userMessage: new RegExp(SUBTASK_FAST_PARENT_MARKER), + sequenceIndex: 0, }, response: { toolCalls: [ @@ -160,6 +161,7 @@ export function addSubtaskFixtures(mock: InstanceType) { mock.addFixture({ match: { userMessage: new RegExp(SUBTASK_XPROFILE_PARENT_MARKER), + sequenceIndex: 0, }, response: { toolCalls: [ diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 9ec3e3079c..d8f6d23299 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -356,6 +356,7 @@ suite("Roo Code Subtasks", function () { ...parentProfile, openRouterModelId: "openai/gpt-4.1-mini", } + const priorModeApiConfigs = api.getConfiguration().modeApiConfigs ?? {} const parentProfileId = await api.upsertProfile("subtask-parent-profile", parentProfile, true) const childProfileId = await api.upsertProfile("subtask-child-profile", childProfile, false) await api.setConfiguration({ @@ -430,7 +431,7 @@ suite("Roo Code Subtasks", function () { ) } finally { api.off(RooCodeEventName.Message, messageHandler) - await api.setConfiguration({ modeApiConfigs: {} }) + await api.setConfiguration({ modeApiConfigs: priorModeApiConfigs }) await api.deleteProfile("subtask-child-profile").catch(() => {}) await api.deleteProfile("subtask-parent-profile").catch(() => {}) while (api.getCurrentTaskStack().length > 0) {