diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 772eb3306a..f4f76716ea 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -24,6 +24,14 @@ export const SUBTASK_INTERRUPT_PARENT_PROMPT = `${SUBTASK_INTERRUPT_PARENT_MARKE export const SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER = "9" export const SUBTASK_INTERRUPT_PARENT_RESULT = "Interrupted parent resumed" +// Abandon-subtask scenario (#559) — separate markers to avoid sequenceIndex collisions with the +// interrupted-child-resumes tests above, which exhaust the sequence count for INTERRUPT markers. +const SUBTASK_ABANDON_PARENT_MARKER = "SUBTASK_PARENT_ABANDON_SEVER" +const SUBTASK_ABANDON_CHILD_MARKER = "SUBTASK_CHILD_ABANDON_SEVER" +const SUBTASK_ABANDON_CHILD_PROMPT = `${SUBTASK_ABANDON_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_ABANDON_PARENT_PROMPT = `${SUBTASK_ABANDON_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_ABANDON_CHILD_PROMPT}" Do not answer directly.` +export const SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER = "9" + 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.` @@ -354,4 +362,67 @@ export function addSubtaskFixtures(mock: InstanceType) { ], }, }) + + // Abandon-subtask scenario (#559) + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_ABANDON_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: SUBTASK_ABANDON_CHILD_PROMPT, + }), + id: "call_abandon_parent_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [SUBTASK_ABANDON_CHILD_MARKER]) && + !requestContains(req, [SUBTASK_ABANDON_PARENT_MARKER]) && + !requestContains(req, ["call_abandon_child_followup_001"]) && + !requestContains(req, [`\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n`]), + }, + response: { + toolCalls: [ + { + name: "ask_followup_question", + arguments: JSON.stringify({ + question: "What is the square root of 81?", + follow_up: [{ text: SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER }], + }), + id: "call_abandon_child_followup_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + toolResultContains(req, "call_abandon_child_followup_001", [SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER]) || + requestContains(req, ["call_abandon_child_followup_001", SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER]) || + requestContains(req, [ + SUBTASK_ABANDON_CHILD_MARKER, + `\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n`, + ]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER }), + id: "call_abandon_child_completion_002", + }, + ], + }, + }) } diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 05b8178b1f..25551d0d9e 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -5,6 +5,8 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types" import { setDefaultSuiteTimeout } from "./test-utils" import { sleep, waitFor, waitUntilCompleted } from "./utils" import { + SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER, + SUBTASK_ABANDON_PARENT_PROMPT, SUBTASK_CHILD_FOLLOWUP_ANSWER, SUBTASK_FAST_PARENT_PROMPT, SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER, @@ -617,4 +619,165 @@ suite("Roo Code Subtasks", function () { await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {}) } }) + + // Issue #559: explicit "Abandon subtask" action. Unlike cancellation alone (which leaves + // the child "interrupted" and the parent "delegated" so the child can still resume and + // report back), abandoning severs the link outright: the parent goes back to "active" and + // the child's parentTaskId/rootTaskId are cleared so a later resume can never reattach it. + test("abandoning an interrupted subtask severs the parent-child link", 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_ABANDON_PARENT_PROMPT, + }) + + 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 + }) + + await waitFor(() => asks[childTaskId!]?.some(({ ask }) => ask === "followup") ?? false) + await waitFor(async () => (await api.getTaskApiConversationHistoryLength(childTaskId!)) > 0) + + // Cancel the child — marked "interrupted", parent stays "delegated". + await api.cancelCurrentTask() + + await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId) + await waitFor( + () => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false, + ) + + const interruptedChild = await api.getTaskHistoryItem(childTaskId!) + assert.strictEqual(interruptedChild?.status, "interrupted", "Child should be marked interrupted") + + const delegatedParent = await api.getTaskHistoryItem(parentTaskId) + assert.strictEqual(delegatedParent?.status, "delegated", "Parent should still be delegated before abandon") + assert.strictEqual( + delegatedParent?.awaitingChildId, + childTaskId, + "Parent should await the interrupted child", + ) + + // The interrupted child is the live/open task at this point (cancelTask rehydrates + // it onto the stack). Abandon must close that live instance before severing the + // persisted link — otherwise a later save on the still-open child would rebuild + // parentTaskId/rootTaskId from its live (readonly) fields and silently reattach it. + const abandoned = await api.abandonSubtask(childTaskId!) + assert.strictEqual(abandoned, true, "abandonSubtask should report the link was severed") + + await waitFor(() => api.getCurrentTaskStack().at(-1) !== childTaskId) + + const parentAfterAbandon = await api.getTaskHistoryItem(parentTaskId) + assert.strictEqual(parentAfterAbandon?.status, "active", "Parent should return to active after abandon") + assert.strictEqual( + parentAfterAbandon?.awaitingChildId, + undefined, + "Parent awaitingChildId should be cleared", + ) + assert.strictEqual(parentAfterAbandon?.delegatedToId, undefined, "Parent delegatedToId should be cleared") + + const childAfterAbandon = await api.getTaskHistoryItem(childTaskId!) + // The child's own status is left untouched (VALID_TRANSITIONS only allows interrupted → completed); + // only its parent/root links are cleared so it can never reattach to the parent again. + assert.strictEqual(childAfterAbandon?.status, "interrupted", "Child status stays interrupted") + assert.strictEqual(childAfterAbandon?.parentTaskId, undefined, "Child parentTaskId should be cleared") + assert.strictEqual(childAfterAbandon?.rootTaskId, undefined, "Child rootTaskId should be cleared") + + // A second abandon call is a no-op since the parent is no longer delegated to this child. + const secondAbandon = await api.abandonSubtask(childTaskId!) + assert.strictEqual(secondAbandon, false, "Second abandonSubtask call should be a no-op") + + // Resume and complete the abandoned child — it must NOT reopen or reattach to the + // parent. Before the abandon fix, a subsequent save on the still-live child could + // silently rewrite its persisted parentTaskId back to the parent; this proves the + // link stays severed all the way through a real resume/save/complete cycle. + // api.resumeTask() re-instantiates the child from history, which re-raises its own + // "resume_task" ask; answering it with the follow-up answer (same pattern the sibling + // "cancelled child completes and reopens parent" test above uses) both resumes the + // task and supplies the answer the re-asked follow-up question is waiting for. + // asks[childTaskId] already holds the earlier resume_task ask from the pre-abandon + // cancellation, so the wait below must look for a NEW one, not just any occurrence. + const askCountBeforeResume = asks[childTaskId!]?.length ?? 0 + await api.resumeTask(childTaskId!) + await waitFor(() => + (asks[childTaskId!] ?? []) + .slice(askCountBeforeResume) + .some(({ type, ask }) => type === "ask" && ask === "resume_task"), + ) + + const completedChildTaskId = await waitUntilCompleted({ + api, + start: async () => { + await api.sendMessage(SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER) + return childTaskId! + }, + }) + + assert.strictEqual( + completedChildTaskId, + childTaskId, + "The abandoned child itself should be the task that completes, not the parent", + ) + assert.strictEqual( + says[parentTaskId]?.find(({ say }) => say === "completion_result"), + undefined, + "Parent must never complete/reopen after its abandoned child resumes and completes", + ) + + const parentAfterChildCompletes = await api.getTaskHistoryItem(parentTaskId) + assert.strictEqual( + parentAfterChildCompletes?.status, + "active", + "Parent status must remain untouched by the abandoned child's completion", + ) + assert.strictEqual( + parentAfterChildCompletes?.awaitingChildId, + undefined, + "Parent must not start awaiting the abandoned child again", + ) + + const childAfterCompletion = await api.getTaskHistoryItem(childTaskId!) + assert.strictEqual( + childAfterCompletion?.parentTaskId, + undefined, + "Child parentTaskId must still be cleared after it completes on its own — " + + "proves the live-instance save did not resurrect the old link", + ) + } 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 2dbaae7920..89e9c8bc2b 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -64,6 +64,14 @@ export interface RooCodeAPI extends EventEmitter { * Cancels the current task. */ cancelCurrentTask(): Promise + /** + * Severs the delegated parent-child link for an interrupted (cancelled, not running) + * subtask, so the parent stops waiting on it and returns to "active". No-op (returns + * false) unless the child is interrupted and its parent is still delegated to it. + * @param childTaskId The ID of the child (subtask) to abandon. + * @returns True if the link was severed, false if there was nothing to abandon. + */ + abandonSubtask(childTaskId: string): Promise /** * Sends a message to the current task. * @param message Optional message to send. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 75a72accde..9bd53f907a 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -460,6 +460,7 @@ export interface WebviewMessage { | "shareCurrentTask" | "showTaskWithId" | "deleteTaskWithId" + | "abandonSubtaskWithId" | "exportTaskWithId" | "importSettings" | "exportSettings" diff --git a/src/__tests__/abandonSubtask.spec.ts b/src/__tests__/abandonSubtask.spec.ts new file mode 100644 index 0000000000..ded593c596 --- /dev/null +++ b/src/__tests__/abandonSubtask.spec.ts @@ -0,0 +1,327 @@ +// npx vitest run __tests__/abandonSubtask.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import type { HistoryItem } from "@roo-code/types" + +import { ClineProvider } from "../core/webview/ClineProvider" +import { makeProviderStub } from "./helpers/provider-stub" + +/** + * Minimal taskHistoryStore stub whose atomicUpdatePair calls both updaters + * against an in-memory item map and resolves, simulating the happy-path atomic write. + */ +function makeTaskHistoryStoreStub(childItem: Record, parentItem: Record) { + const itemMap = new Map>([ + [childItem.id!, childItem], + [parentItem.id!, parentItem], + ]) + + const atomicUpdatePair = vi.fn( + async ( + firstId: string, + secondId: string, + firstUpdater: (h: HistoryItem) => HistoryItem, + secondUpdater: (h: HistoryItem) => HistoryItem, + ) => { + itemMap.set(firstId, firstUpdater(itemMap.get(firstId) as HistoryItem)) + itemMap.set(secondId, secondUpdater(itemMap.get(secondId) as HistoryItem)) + return [] + }, + ) + + return { + atomicUpdatePair, + get: vi.fn((id: string) => itemMap.get(id)), + } +} + +describe("ClineProvider.abandonSubtask()", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("severs the link: parent → active (awaitingChildId/delegatedToId cleared), child loses parentTaskId/rootTaskId", async () => { + const childHistoryItem = { + id: "child-1", + status: "interrupted", + parentTaskId: "parent-1", + rootTaskId: "parent-1", + ts: Date.now(), + task: "Child task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + childIds: ["child-1"], + ts: Date.now(), + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + + const provider = makeProviderStub({ + getTaskWithId, + getCurrentTask: vi.fn(() => undefined), + taskHistoryStore, + isViewLaunched: true, + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(true) + expect(taskHistoryStore.atomicUpdatePair).toHaveBeenCalledTimes(1) + const [firstId, secondId] = taskHistoryStore.atomicUpdatePair.mock.calls[0] + expect(firstId).toBe("child-1") + expect(secondId).toBe("parent-1") + + const updatedChild = taskHistoryStore.get("child-1") + expect(updatedChild).toEqual( + expect.objectContaining({ + id: "child-1", + status: "interrupted", + parentTaskId: undefined, + rootTaskId: undefined, + }), + ) + + const updatedParent = taskHistoryStore.get("parent-1") + expect(updatedParent).toEqual( + expect.objectContaining({ + id: "parent-1", + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }), + ) + + // Guarded against a stale in-flight completion reattaching the child. + expect((provider as any).cancelledDelegationChildIds.has("child-1")).toBe(true) + + // Both updated items broadcast to the webview with the actual severed-link field values, + // not just matching IDs — a stale/pre-abandon payload would still match an id-only assertion. + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskHistoryItemUpdated", + taskHistoryItem: expect.objectContaining({ + id: "child-1", + status: "interrupted", + parentTaskId: undefined, + rootTaskId: undefined, + }), + }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskHistoryItemUpdated", + taskHistoryItem: expect.objectContaining({ + id: "parent-1", + status: "active", + awaitingChildId: undefined, + delegatedToId: undefined, + }), + }) + }) + + it("closes the live child instance before severing the link, so a later save cannot reattach it", async () => { + // An interrupted child is commonly still the live/open task (cancelTask rehydrates it + // onto the stack). If abandon doesn't close it first, Task#saveClineMessages() would + // rebuild parentTaskId/rootTaskId from the live task's readonly fields on its next save + // and silently reattach the child. removeClineFromStack() must run before atomicUpdatePair. + const childHistoryItem = { + id: "child-1", + status: "interrupted", + parentTaskId: "parent-1", + rootTaskId: "parent-1", + ts: Date.now(), + task: "Child task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + childIds: ["child-1"], + ts: Date.now(), + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + getTaskWithId, + getCurrentTask: vi.fn(() => ({ taskId: "child-1" })), + removeClineFromStack, + taskHistoryStore, + isViewLaunched: false, + } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(true) + expect(removeClineFromStack).toHaveBeenCalledWith() + // The live child must be closed before the persisted link is severed. + const closeCallOrder = removeClineFromStack.mock.invocationCallOrder[0] + const atomicCallOrder = taskHistoryStore.atomicUpdatePair.mock.invocationCallOrder[0] + expect(closeCallOrder).toBeLessThan(atomicCallOrder) + }) + + it("does not attempt to close the live instance when the child is not the current task", async () => { + const childHistoryItem = { id: "child-1", status: "interrupted", parentTaskId: "parent-1" } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + getTaskWithId, + getCurrentTask: vi.fn(() => ({ taskId: "some-other-task" })), + removeClineFromStack, + taskHistoryStore, + isViewLaunched: false, + } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(true) + expect(removeClineFromStack).not.toHaveBeenCalled() + }) + + it("returns false and does not modify state when the child is not interrupted (e.g. still active)", async () => { + const childHistoryItem = { id: "child-1", status: "active", parentTaskId: "parent-1" } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const provider = makeProviderStub({ getTaskWithId, taskHistoryStore } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(false) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) + + it("returns false when the child completes between the initial check and lock acquisition (TOCTOU)", async () => { + // The initial status check reads the child as interrupted, but by the time the + // per-parent lock is acquired, a concurrent resume-and-complete has already + // transitioned it. The in-lock re-check must catch this and bail out. + const childHistoryItem = { id: "child-1", status: "interrupted", parentTaskId: "parent-1" } + const parentHistoryItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore: any = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + // Simulate the concurrent completion landing right before the in-lock re-check runs. + taskHistoryStore.get = vi.fn((id: string) => + id === "child-1" ? { ...childHistoryItem, status: "completed" as const } : parentHistoryItem, + ) + + const provider = makeProviderStub({ getTaskWithId, taskHistoryStore } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(false) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) + + it("returns false and does not modify state when the child has no parentTaskId", async () => { + const getTaskWithId = vi.fn().mockResolvedValue({ historyItem: { id: "standalone-1", status: "active" } }) + const provider = makeProviderStub({ getTaskWithId } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "standalone-1") + + expect(result).toBe(false) + }) + + it("returns false and does not touch history when parent is no longer delegated to this child", async () => { + const childHistoryItem = { id: "child-1", status: "interrupted", parentTaskId: "parent-1" } + const parentHistoryItem = { id: "parent-1", status: "active", awaitingChildId: undefined } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const provider = makeProviderStub({ getTaskWithId, taskHistoryStore } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(false) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) + + it("returns false when awaitingChildId points at a different child", async () => { + const childHistoryItem = { id: "child-1", status: "interrupted", parentTaskId: "parent-1" } + const parentHistoryItem = { id: "parent-1", status: "delegated", awaitingChildId: "child-OTHER" } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "child-1") return { historyItem: childHistoryItem } + if (id === "parent-1") return { historyItem: parentHistoryItem } + throw new Error("Task not found") + }) + + const taskHistoryStore = makeTaskHistoryStoreStub(childHistoryItem, parentHistoryItem) + const provider = makeProviderStub({ getTaskWithId, taskHistoryStore } as any) + + const result = await (ClineProvider.prototype as any).abandonSubtask.call(provider, "child-1") + + expect(result).toBe(false) + expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/api-subtask.spec.ts b/src/__tests__/api-subtask.spec.ts new file mode 100644 index 0000000000..2b5a1a86e9 --- /dev/null +++ b/src/__tests__/api-subtask.spec.ts @@ -0,0 +1,102 @@ +// npx vitest run __tests__/api-subtask.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { EventEmitter } from "events" + +vi.mock("vscode", () => ({ + workspace: { workspaceFolders: [] }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + env: { language: "en" }, + Uri: { + file: vi.fn((p: string) => ({ fsPath: p })), + parse: vi.fn((s: string) => ({ toString: () => s })), + }, + commands: { registerCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }) }, +})) + +vi.mock("p-wait-for", () => ({ default: vi.fn().mockResolvedValue(undefined) })) + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class { + listen() {} + on() {} + close() {} + }, +})) + +vi.mock("../services/command/commands", () => ({ getCommands: vi.fn().mockResolvedValue([]) })) + +import { API } from "../extension/api" + +function makeProviderMock() { + const emitter = new EventEmitter() + return { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + emit: emitter.emit.bind(emitter), + context: { + extensionPath: "/test", + globalStorageUri: { fsPath: "/test/storage" }, + subscriptions: [], + }, + cwd: "/test/cwd", + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + abandonSubtask: vi.fn().mockResolvedValue(true), + getCurrentTask: vi.fn().mockReturnValue(undefined), + viewLaunched: false, + cancelTask: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockRejectedValue(new Error("not found")), + taskHistoryStore: { get: vi.fn().mockReturnValue(undefined), getAll: vi.fn().mockReturnValue([]) }, + getCurrentTaskStack: vi.fn().mockReturnValue([]), + getModes: vi.fn().mockResolvedValue([]), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + } +} + +describe("API.clearCurrentTask()", () => { + let provider: ReturnType + let api: API + + beforeEach(() => { + vi.clearAllMocks() + provider = makeProviderMock() + api = new API({} as any, provider as any) + }) + + it("calls evictCurrentTask then postStateToWebview on sidebarProvider", async () => { + await api.clearCurrentTask() + expect(provider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + // evict must come before postState + const evictOrder = provider.evictCurrentTask.mock.invocationCallOrder[0] + const postOrder = provider.postStateToWebview.mock.invocationCallOrder[0] + expect(evictOrder).toBeLessThan(postOrder) + }) +}) + +describe("API.abandonSubtask()", () => { + let provider: ReturnType + let api: API + + beforeEach(() => { + vi.clearAllMocks() + provider = makeProviderMock() + api = new API({} as any, provider as any) + }) + + it("delegates to sidebarProvider.abandonSubtask and returns its result", async () => { + provider.abandonSubtask.mockResolvedValue(true) + const result = await api.abandonSubtask("child-task-1") + expect(provider.abandonSubtask).toHaveBeenCalledWith("child-task-1") + expect(result).toBe(true) + }) + + it("returns false when sidebarProvider.abandonSubtask returns false", async () => { + provider.abandonSubtask.mockResolvedValue(false) + const result = await api.abandonSubtask("child-task-2") + expect(result).toBe(false) + }) +}) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index c9d77ad877..c91016d93a 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -12,9 +12,10 @@ 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) + s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) + s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) return s } diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 0be2d28a3f..c91f4d964a 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -159,7 +159,7 @@ describe("History resume delegation - parent metadata transitions", () => { // Verify child closed and parent reopened with updated metadata expect(removeClineFromStack).toHaveBeenCalledTimes(1) - expect(removeClineFromStack).toHaveBeenCalledWith({ skipDelegationRepair: true }) + expect(removeClineFromStack).toHaveBeenCalledWith() expect(createTaskWithHistoryItem).toHaveBeenCalledWith( expect.objectContaining({ status: "active", diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index ddc6a3c4f4..0a9c34b979 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -213,6 +213,117 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(callOrder).toEqual(["createTask", "atomicReadAndUpdate", "child.start"]) }) + it("implicitly severs interrupted awaited child and re-delegates when parent is already delegated", async () => { + const oldChildId = "old-child" + const oldChild = { id: oldChildId, status: "interrupted" } as unknown as HistoryItem + const alreadyDelegatedParent: HistoryItem = { + ...parentHistoryItem, + status: "delegated", + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + childIds: [oldChildId], + } as unknown as HistoryItem + + const taskHistoryStore = makeStoreStub({ + // store returns: parent (delegated), old child (interrupted) + get: vi.fn((id: string) => + id === "parent-1" ? alreadyDelegatedParent : id === oldChildId ? oldChild : undefined, + ), + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + updater(alreadyDelegatedParent) + return [] + }), + }) + + const provider = { + emit: vi.fn(), + getCurrentTask: vi.fn(() => makeParentTask()), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue({ taskId: "child-2", start: vi.fn() }), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }) + + // The updater must sever the old link and apply the new delegation + const [, updater] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] + const result = updater(alreadyDelegatedParent) + expect(result).toMatchObject({ + status: "delegated", + awaitingChildId: "child-2", + delegatedToId: "child-2", + }) + // Old child ID preserved in childIds (audit trail) + expect(result.childIds).toContain(oldChildId) + expect(result.childIds).toContain("child-2") + }) + + it("rejects with 'Cannot re-delegate' when the existing awaited child is still active", async () => { + const oldChildId = "old-child" + const activeChild = { id: oldChildId, status: "active" } as unknown as HistoryItem + const alreadyDelegatedParent: HistoryItem = { + ...parentHistoryItem, + status: "delegated", + awaitingChildId: oldChildId, + delegatedToId: oldChildId, + } as unknown as HistoryItem + + const child = { taskId: "child-2", start: vi.fn() } + const getCurrentTask = vi.fn().mockReturnValue(makeParentTask()) + const createTask = vi.fn().mockImplementation(async () => { + getCurrentTask.mockReturnValue(child) + return child + }) + + const taskHistoryStore = makeStoreStub({ + get: vi.fn((id: string) => + id === "parent-1" ? alreadyDelegatedParent : id === oldChildId ? activeChild : undefined, + ), + // Real atomicReadAndUpdate behaviour: call the updater and propagate any throw + atomicReadAndUpdate: vi.fn(async (_taskId: string, updater: (h: HistoryItem) => HistoryItem) => { + updater(alreadyDelegatedParent) + return [] + }), + }) + + const provider = { + emit: vi.fn(), + getCurrentTask, + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + deleteTaskWithId: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: alreadyDelegatedParent }), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await expect( + (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Continue", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow("Cannot re-delegate") + + // Rollback: child must not have started, and must be cleaned up + expect(child.start).not.toHaveBeenCalled() + expect((provider as any).deleteTaskWithId).toHaveBeenCalledWith("child-2", false) + }) + it("rolls back the paused child and restores the parent when atomicReadAndUpdate fails", async () => { const persistError = new Error("parent metadata persist failed") const parentTask = makeParentTask() @@ -260,8 +371,8 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { ).rejects.toThrow(persistError) expect(childStart).not.toHaveBeenCalled() - expect(removeClineFromStack).toHaveBeenNthCalledWith(1, { skipDelegationRepair: true }) - expect(removeClineFromStack).toHaveBeenNthCalledWith(2, { skipDelegationRepair: true }) + expect(removeClineFromStack).toHaveBeenNthCalledWith(1) + expect(removeClineFromStack).toHaveBeenNthCalledWith(2) expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) }) diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index 6013d8db99..a91900c38a 100644 --- a/src/__tests__/removeClineFromStack-delegation.spec.ts +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -4,289 +4,358 @@ import { describe, it, expect, vi } from "vitest" import { ClineProvider } from "../core/webview/ClineProvider" import { makeProviderStub } from "./helpers/provider-stub" -describe("ClineProvider.removeClineFromStack() delegation awareness", () => { - /** - * Helper to build a minimal mock provider with a single task on the stack. - * The task's parentTaskId and taskId are configurable. - */ - function buildMockProvider(opts: { - childTaskId: string - parentTaskId?: string - parentHistoryItem?: Record - getTaskWithIdError?: Error - }) { - const childTask = { - taskId: opts.childTaskId, - instanceId: "inst-1", - parentTaskId: opts.parentTaskId, - emit: vi.fn(), - abortTask: vi.fn().mockResolvedValue(undefined), +// After the refactor: removeClineFromStack() is pure lifecycle — it pops, aborts, and +// cleans up listeners. It does NOT mutate delegation metadata. All delegated→active +// transitions are owned by reopenParentFromDelegation() (normal child completion) or +// markDelegatedChildInterrupted() (live eviction via navigation / new-task / clear). + +function buildMockProvider(opts: { + childTaskId: string + parentTaskId?: string + parentHistoryItem?: Record + childStatus?: string +}) { + const childTask = { + taskId: opts.childTaskId, + instanceId: "inst-1", + parentTaskId: opts.parentTaskId, + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === opts.parentTaskId && opts.parentHistoryItem) { + return { historyItem: { ...opts.parentHistoryItem } } } + throw new Error("Task not found") + }) - const updateTaskHistory = vi.fn().mockResolvedValue([]) - const getTaskWithId = opts.getTaskWithIdError - ? vi.fn().mockRejectedValue(opts.getTaskWithIdError) - : vi.fn().mockImplementation(async (id: string) => { - if (id === opts.parentTaskId && opts.parentHistoryItem) { - return { historyItem: { ...opts.parentHistoryItem } } - } - throw new Error("Task not found") - }) + const taskHistoryStoreData: Record = {} + if (opts.childStatus) { + taskHistoryStoreData[opts.childTaskId] = { status: opts.childStatus } + } - const provider = makeProviderStub({ - clineStack: [childTask] as any[], - taskEventListeners: new Map(), - log: vi.fn(), - getTaskWithId, - updateTaskHistory, - }) + const provider = makeProviderStub({ + clineStack: [childTask] as any[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + taskHistoryStore: { get: (id: string) => taskHistoryStoreData[id] }, + }) - return { provider, childTask, updateTaskHistory, getTaskWithId } - } + return { provider, childTask, updateTaskHistory, getTaskWithId } +} + +describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation side effects", () => { + it("pops the task, aborts it, and clears listeners", async () => { + const { provider, childTask } = buildMockProvider({ childTaskId: "child-1" }) + expect(provider.clineStack).toHaveLength(1) + + await (ClineProvider.prototype as any).removeClineFromStack.call(provider) - it("repairs parent metadata (delegated → active) when a delegated child is removed", async () => { + expect(provider.clineStack).toHaveLength(0) + expect(childTask.abortTask).toHaveBeenCalledWith(true) + expect(childTask.emit).toHaveBeenCalledWith(expect.stringContaining("taskUnfocused")) + }) + + it("does NOT mutate parent metadata when a delegated child is popped (repair removed)", async () => { const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ childTaskId: "child-1", parentTaskId: "parent-1", parentHistoryItem: { id: "parent-1", - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, status: "delegated", awaitingChildId: "child-1", delegatedToId: "child-1", - childIds: ["child-1"], }, }) await (ClineProvider.prototype as any).removeClineFromStack.call(provider) - // Stack should be empty after pop - expect(provider.clineStack).toHaveLength(0) - - // Parent lookup should have been called - expect(getTaskWithId).toHaveBeenCalledWith("parent-1") - - // Parent metadata should be repaired - expect(updateTaskHistory).toHaveBeenCalledTimes(1) - const updatedParent = updateTaskHistory.mock.calls[0][0] - expect(updatedParent).toEqual( - expect.objectContaining({ - id: "parent-1", - status: "active", - awaitingChildId: undefined, - delegatedToId: undefined, - }), - ) - - // Log the repair - expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Repaired parent parent-1 metadata")) - }) - - it("does NOT modify parent metadata when the task has no parentTaskId (non-delegated)", async () => { - const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ - childTaskId: "standalone-1", - // No parentTaskId — this is a top-level task - }) - - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) - - // Stack should be empty expect(provider.clineStack).toHaveLength(0) - - // No parent lookup or update should happen + // Navigation/disposal must never silently flip the parent to active expect(getTaskWithId).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("does NOT modify parent metadata when awaitingChildId does not match the popped child", async () => { + it("does NOT mutate parent metadata when the child is interrupted", async () => { const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ childTaskId: "child-1", parentTaskId: "parent-1", parentHistoryItem: { id: "parent-1", - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, status: "delegated", - awaitingChildId: "child-OTHER", // different child - delegatedToId: "child-OTHER", - childIds: ["child-OTHER"], + awaitingChildId: "child-1", }, + childStatus: "interrupted", }) await (ClineProvider.prototype as any).removeClineFromStack.call(provider) - // Parent was looked up but should NOT be updated - expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(provider.clineStack).toHaveLength(0) + expect(getTaskWithId).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("does NOT modify parent metadata when parent status is not 'delegated'", async () => { + it("does NOT mutate parent metadata for a non-delegated (top-level) task", async () => { const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ - childTaskId: "child-1", - parentTaskId: "parent-1", - parentHistoryItem: { - id: "parent-1", - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - status: "completed", // already completed - awaitingChildId: "child-1", - childIds: ["child-1"], - }, + childTaskId: "standalone-1", }) await (ClineProvider.prototype as any).removeClineFromStack.call(provider) - expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(provider.clineStack).toHaveLength(0) + expect(getTaskWithId).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("catches and logs errors during parent metadata repair without blocking the pop", async () => { - const { provider, childTask, updateTaskHistory, getTaskWithId } = buildMockProvider({ - childTaskId: "child-1", - parentTaskId: "parent-1", - getTaskWithIdError: new Error("Storage unavailable"), + it("handles empty stack gracefully", async () => { + const provider = makeProviderStub({ + clineStack: [] as any[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId: vi.fn(), + updateTaskHistory: vi.fn(), }) - // Should NOT throw - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + await expect((ClineProvider.prototype as any).removeClineFromStack.call(provider)).resolves.not.toThrow() - // Stack should still be empty (pop was not blocked) - expect(provider.clineStack).toHaveLength(0) + expect((provider as any).getTaskWithId).not.toHaveBeenCalled() + expect((provider as any).updateTaskHistory).not.toHaveBeenCalled() + }) +}) - // The abort should still have been called - expect(childTask.abortTask).toHaveBeenCalledWith(true) +describe("ClineProvider.markDelegatedChildInterrupted() — live eviction path", () => { + it("marks an active delegated child interrupted and leaves parent delegated", async () => { + const childTaskId = "child-1" + const parentTaskId = "parent-1" - // Error should be logged as non-fatal - expect(provider.log).toHaveBeenCalledWith( - expect.stringContaining("Failed to repair parent metadata for parent-1 (non-fatal)"), - ) + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === parentTaskId) { + return { + historyItem: { + id: parentTaskId, + status: "delegated", + awaitingChildId: childTaskId, + delegatedToId: childTaskId, + }, + } + } + if (id === childTaskId) { + return { + historyItem: { + id: childTaskId, + status: "active", + parentTaskId, + }, + } + } + throw new Error("Not found") + }) - // No update should have been attempted - expect(updateTaskHistory).not.toHaveBeenCalled() + const postMessageToWebview = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + clineStack: [] as any[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + postMessageToWebview, + taskHistoryStore: { + get: (id: string) => (id === childTaskId ? { id: childTaskId, status: "active" } : undefined), + }, + }) + + await (ClineProvider.prototype as any).markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) + + // Child must be marked interrupted + expect(updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ id: childTaskId, status: "interrupted" }), + ) + // Parent must NOT be touched at all — stays delegated + expect(updateTaskHistory).not.toHaveBeenCalledWith(expect.objectContaining({ id: parentTaskId })) + // Webview must receive correct field name: taskHistoryItem (not historyItem) + expect(postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "taskHistoryItemUpdated", + taskHistoryItem: expect.objectContaining({ id: childTaskId, status: "interrupted" }), + }), + ) + expect(postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "taskHistoryItemUpdated", + taskHistoryItem: expect.objectContaining({ id: parentTaskId, status: "delegated" }), + }), + ) }) - it("handles empty stack gracefully", async () => { - const provider = { + it("is a no-op when the child is already interrupted", async () => { + const childTaskId = "child-1" + const parentTaskId = "parent-1" + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + + const provider = makeProviderStub({ clineStack: [] as any[], taskEventListeners: new Map(), log: vi.fn(), getTaskWithId: vi.fn(), - updateTaskHistory: vi.fn(), - } + updateTaskHistory, + taskHistoryStore: { + get: (id: string) => (id === childTaskId ? { id: childTaskId, status: "interrupted" } : undefined), + }, + }) - // Should not throw - await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + await (ClineProvider.prototype as any).markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) - expect(provider.clineStack).toHaveLength(0) - expect(provider.getTaskWithId).not.toHaveBeenCalled() - expect(provider.updateTaskHistory).not.toHaveBeenCalled() + expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("skips delegation repair when skipDelegationRepair option is true", async () => { - const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ - childTaskId: "child-1", - parentTaskId: "parent-1", - parentHistoryItem: { - id: "parent-1", - task: "Parent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - status: "delegated", - awaitingChildId: "child-1", - delegatedToId: "child-1", - childIds: ["child-1"], + it("is a no-op when parent is no longer delegated to this child", async () => { + const childTaskId = "child-1" + const parentTaskId = "parent-1" + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = vi.fn().mockResolvedValue({ + historyItem: { + id: parentTaskId, + status: "active", // already repaired by another path + awaitingChildId: undefined, }, }) - // Call with skipDelegationRepair: true (as delegateParentAndOpenChild would) - await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true }) + const provider = makeProviderStub({ + clineStack: [] as any[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + taskHistoryStore: { + get: (id: string) => (id === childTaskId ? { id: childTaskId, status: "active" } : undefined), + }, + }) - // Stack should be empty after pop - expect(provider.clineStack).toHaveLength(0) + await (ClineProvider.prototype as any).markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) - // Parent lookup should NOT have been called — repair was skipped entirely - expect(getTaskWithId).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() }) - it("does NOT reset grandparent during A→B→C nested delegation transition", async () => { - // Scenario: A delegated to B, B is now delegating to C. - // delegateParentAndOpenChild() pops B via removeClineFromStack({ skipDelegationRepair: true }). - // Grandparent A should remain "delegated" — its metadata must not be repaired. - const grandparentHistory = { - id: "task-A", - task: "Grandparent task", - ts: 1000, - number: 1, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - status: "delegated", - awaitingChildId: "task-B", - delegatedToId: "task-B", - childIds: ["task-B"], - } + it("skips the update when cancelTask marks the child interrupted between outer check and lock (TOCTOU)", async () => { + // Outer store returns "active" (fast path passes), but inside the lock the store + // now returns "interrupted" (cancelTask beat us). The in-lock re-check must bail. + const childTaskId = "child-toctou" + const parentTaskId = "parent-1" - const taskB = { - taskId: "task-B", - instanceId: "inst-B", - parentTaskId: "task-A", - emit: vi.fn(), - abortTask: vi.fn().mockResolvedValue(undefined), - } + const updateTaskHistory = vi.fn().mockResolvedValue([]) + let lockAcquired = false const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { - if (id === "task-A") { - return { historyItem: { ...grandparentHistory } } + if (id === parentTaskId) { + return { historyItem: { id: parentTaskId, status: "delegated", awaitingChildId: childTaskId } } } - throw new Error("Task not found") + // Child history fetch inside lock returns interrupted — cancelTask beat us + return { historyItem: { id: childTaskId, status: "interrupted", parentTaskId } } }) - const updateTaskHistory = vi.fn().mockResolvedValue([]) - const provider = { - clineStack: [taskB] as any[], + const provider = makeProviderStub({ + clineStack: [] as any[], taskEventListeners: new Map(), log: vi.fn(), getTaskWithId, updateTaskHistory, - } + taskHistoryStore: { + // Outer check: "active" (pre-lock); in-lock check reads from taskHistoryStore too + // but the code falls back to getTaskWithId inside the lock when the store shows active. + get: vi.fn((id: string) => { + if (id === childTaskId) { + // After lock acquired, simulate cancelTask flipping to interrupted + return lockAcquired + ? { id: childTaskId, status: "interrupted" } + : { id: childTaskId, status: "active" } + } + return undefined + }), + }, + }) - // Simulate what delegateParentAndOpenChild does: pop B with skipDelegationRepair - await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true }) + // Patch runDelegationTransition to set lockAcquired before calling fn + const realRunDelegation = (provider as any).runDelegationTransition.bind(provider) + ;(provider as any).runDelegationTransition = async (_parentId: string, fn: () => Promise) => { + lockAcquired = true + return realRunDelegation(_parentId, fn) + } - // B was popped - expect(provider.clineStack).toHaveLength(0) + await (ClineProvider.prototype as any).markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) - // Grandparent A should NOT have been looked up or modified - expect(getTaskWithId).not.toHaveBeenCalled() + // Since the in-lock store check returns "interrupted", the code skips updateTaskHistory expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("logs and swallows errors from runDelegationTransition", async () => { + const childTaskId = "child-err" + const parentTaskId = "parent-1" + + const log = vi.fn() + const getTaskWithId = vi.fn().mockRejectedValue(new Error("store unavailable")) + + const provider = makeProviderStub({ + clineStack: [] as any[], + taskEventListeners: new Map(), + log, + getTaskWithId, + updateTaskHistory: vi.fn(), + taskHistoryStore: { + get: (id: string) => (id === childTaskId ? { id: childTaskId, status: "active" } : undefined), + }, + }) + + await expect( + (ClineProvider.prototype as any).markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }), + ).resolves.not.toThrow() - // Grandparent A's metadata remains intact (delegated, awaitingChildId: task-B) - // The caller (delegateParentAndOpenChild) will update A to point to C separately. + expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed for child")) }) +}) - 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. +describe("createTaskWithHistoryItem() navigation — does not mutate delegation state", () => { + it("navigating to a delegated parent while its interrupted child is current leaves parent delegated", async () => { + // This is the core regression: previously removeClineFromStack's repair fired here + // and flipped the parent to active, hiding the Abandon button. const childTaskId = "child-1" const parentTaskId = "parent-1" + const parentHistoryItem = { + id: parentTaskId, + status: "delegated", + awaitingChildId: childTaskId, + delegatedToId: childTaskId, + } + + const childHistoryItem = { + id: childTaskId, + status: "interrupted", + parentTaskId, + } + const childTask = { taskId: childTaskId, instanceId: "inst-child", @@ -297,44 +366,304 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { 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") + if (id === parentTaskId) return { historyItem: { ...parentHistoryItem } } + if (id === childTaskId) return { historyItem: { ...childHistoryItem } } + throw new Error("Not found") }) + const markDelegatedChildInterrupted = vi.fn().mockResolvedValue(undefined) + 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) }, + markDelegatedChildInterrupted, + taskHistoryStore: { + get: (id: string) => + id === childTaskId + ? { ...childHistoryItem } + : id === parentTaskId + ? { ...parentHistoryItem } + : undefined, + }, }) + // Simulate the navigation logic from createTaskWithHistoryItem: + // when the target is a delegated parent and current task is its interrupted child, + // removeClineFromStack must NOT repair parent to active. await (ClineProvider.prototype as any).removeClineFromStack.call(provider) - // Stack is emptied - expect(provider.clineStack).toHaveLength(0) + // Parent must stay delegated — no write at all + expect(updateTaskHistory).not.toHaveBeenCalledWith(expect.objectContaining({ id: parentTaskId })) + }) + + it("navigating away from an active delegated child marks the child interrupted", async () => { + // Option A: live eviction of an active delegated child → child becomes interrupted, + // parent stays delegated, user can resume or abandon later. + const childTaskId = "child-active" + const parentTaskId = "parent-1" + + const childHistoryItem = { + id: childTaskId, + status: "active", + parentTaskId, + } + + const parentHistoryItem = { + id: parentTaskId, + status: "delegated", + awaitingChildId: childTaskId, + delegatedToId: childTaskId, + } + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === parentTaskId) return { historyItem: { ...parentHistoryItem } } + if (id === childTaskId) return { historyItem: { ...childHistoryItem } } + throw new Error("Not found") + }) + + const postMessageToWebview = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + clineStack: [] as any[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + postMessageToWebview, + taskHistoryStore: { + get: (id: string) => + id === childTaskId + ? { ...childHistoryItem } + : id === parentTaskId + ? { ...parentHistoryItem } + : undefined, + }, + }) - // Parent must NOT be transitioned to active — it stays "delegated" - // so the interrupted child can resume and report back later + await (ClineProvider.prototype as any).markDelegatedChildInterrupted.call(provider, { + childTaskId, + parentTaskId, + }) + + // Child becomes interrupted + expect(updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ id: childTaskId, status: "interrupted" }), + ) + // Parent stays delegated — awaitingChildId preserved expect(updateTaskHistory).not.toHaveBeenCalledWith( expect.objectContaining({ id: parentTaskId, status: "active" }), ) + expect(updateTaskHistory).not.toHaveBeenCalledWith( + expect.objectContaining({ id: parentTaskId, awaitingChildId: undefined }), + ) + }) +}) + +describe("ClineProvider.evictCurrentTask() — active delegated child path", () => { + it("calls markDelegatedChildInterrupted when current task is an active delegated child", async () => { + const childTaskId = "child-active" + const parentTaskId = "parent-1" + + const childTask = { + taskId: childTaskId, + instanceId: "inst-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const childHistoryItem = { id: childTaskId, status: "active", parentTaskId } + + const markDelegatedChildInterrupted = vi.fn().mockResolvedValue(undefined) + + const provider = makeProviderStub({ + clineStack: [childTask] as any[], + taskEventListeners: new Map(), + getCurrentTask: vi.fn(() => childTask), + taskHistoryStore: { get: vi.fn((id: string) => (id === childTaskId ? childHistoryItem : undefined)) }, + markDelegatedChildInterrupted, + log: vi.fn(), + }) + + await (ClineProvider.prototype as any).evictCurrentTask.call(provider) + + expect(provider.clineStack).toHaveLength(0) + expect(markDelegatedChildInterrupted).toHaveBeenCalledWith({ childTaskId, parentTaskId }) + }) + + it("does not call markDelegatedChildInterrupted when there is no current task", async () => { + const markDelegatedChildInterrupted = vi.fn() + + const provider = makeProviderStub({ + clineStack: [] as any[], + taskEventListeners: new Map(), + getCurrentTask: vi.fn(() => undefined), + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted, + log: vi.fn(), + }) + + await (ClineProvider.prototype as any).evictCurrentTask.call(provider) + + expect(markDelegatedChildInterrupted).not.toHaveBeenCalled() + }) + + it("does not call markDelegatedChildInterrupted for a task with no parentTaskId", async () => { + const childTask = { + taskId: "standalone-1", + instanceId: "inst-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const markDelegatedChildInterrupted = vi.fn() + + const provider = makeProviderStub({ + clineStack: [childTask] as any[], + taskEventListeners: new Map(), + getCurrentTask: vi.fn(() => childTask), + taskHistoryStore: { + get: vi.fn(() => ({ id: "standalone-1", status: "active", parentTaskId: undefined })), + }, + markDelegatedChildInterrupted, + log: vi.fn(), + }) + + await (ClineProvider.prototype as any).evictCurrentTask.call(provider) + + expect(markDelegatedChildInterrupted).not.toHaveBeenCalled() + }) + + it("propagates markDelegatedChildInterrupted errors (method swallows internally, not caller)", async () => { + // evictCurrentTask no longer has a caller-level .catch(); errors propagate + // from markDelegatedChildInterrupted directly. The real implementation swallows + // inside its own try/catch (after the guard reads); a mock that rejects bypasses + // that catch and exercises the propagation path. + const childTask = { + taskId: "child-err", + instanceId: "inst-1", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const markDelegatedChildInterrupted = vi.fn().mockRejectedValue(new Error("lock contention")) + + const provider = makeProviderStub({ + clineStack: [childTask] as any[], + taskEventListeners: new Map(), + getCurrentTask: vi.fn(() => childTask), + taskHistoryStore: { + get: vi.fn(() => ({ id: "child-err", status: "active", parentTaskId: "parent-1" })), + }, + markDelegatedChildInterrupted, + log: vi.fn(), + }) + + await expect((ClineProvider.prototype as any).evictCurrentTask.call(provider)).rejects.toThrow( + "lock contention", + ) + }) +}) + +describe("onTaskCompleted callback — writes completed status before re-emitting", () => { + function buildCallbackProvider(taskHistoryStoreGet: (id: string) => any) { + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const emit = vi.fn() + const log = vi.fn() + + // Wire up the real taskCreationCallback by calling the closure that ClineProvider + // sets on `this.taskCreationCallback` during construction. We extract it from the + // prototype's init code by calling the relevant portion directly. + const listeners: Record unknown)[]> = {} + const fakeTask = { + taskId: "task-1", + on: vi.fn((event: string, fn: (...args: unknown[]) => unknown) => { + listeners[event] = listeners[event] || [] + listeners[event].push(fn) + }), + emit: vi.fn((event: string, ...args: any[]) => { + listeners[event]?.forEach((fn) => fn(...args)) + }), + } + + const provider = makeProviderStub({ + taskHistoryStore: { get: taskHistoryStoreGet }, + updateTaskHistory, + emit, + log, + }) + + // Extract the real onTaskCompleted by simulating taskCreationCallback invocation. + // ClineProvider.prototype doesn't expose taskCreationCallback as a testable method, + // so we replicate the closure binding by calling the static block directly. + // The real callback is set in the constructor body; we replicate the relevant portion. + const onTaskCompleted = async (taskId: string) => { + try { + const existing = (provider as any).taskHistoryStore.get(taskId) + if (existing && existing.status !== "completed") { + await (provider as any).updateTaskHistory({ ...existing, status: "completed" }) + } + } catch (err) { + ;(provider as any).log( + `[onTaskCompleted] Failed to write completed status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + ;(provider as any).emit("TaskCompleted", taskId, {}, {}) + } + + return { onTaskCompleted, updateTaskHistory, emit, log } + } + + it("writes status:completed when existing record is not already completed", async () => { + const existingItem = { + id: "task-1", + status: "interrupted", + task: "T", + ts: 0, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const { onTaskCompleted, updateTaskHistory } = buildCallbackProvider((id) => + id === "task-1" ? existingItem : undefined, + ) + + await onTaskCompleted("task-1") + + expect(updateTaskHistory).toHaveBeenCalledWith(expect.objectContaining({ id: "task-1", status: "completed" })) + }) + + it("skips the write when existing record is already completed", async () => { + const existingItem = { id: "task-1", status: "completed" } + const { onTaskCompleted, updateTaskHistory } = buildCallbackProvider((id) => + id === "task-1" ? existingItem : undefined, + ) + + await onTaskCompleted("task-1") + + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("skips the write when taskHistoryStore has no entry for the task", async () => { + const { onTaskCompleted, updateTaskHistory } = buildCallbackProvider(() => undefined) + + await onTaskCompleted("task-1") + + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("logs and swallows errors from updateTaskHistory", async () => { + const existingItem = { id: "task-1", status: "active" } + const { onTaskCompleted, updateTaskHistory, log } = buildCallbackProvider((id) => + id === "task-1" ? existingItem : undefined, + ) + ;(updateTaskHistory as any).mockRejectedValue(new Error("disk full")) + + await expect(onTaskCompleted("task-1")).resolves.not.toThrow() + + expect(log).toHaveBeenCalledWith(expect.stringContaining("[onTaskCompleted] Failed to write")) }) }) diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index af5b429a80..29aab5d15d 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -39,9 +39,16 @@ describe("Single-open-task invariant", () => { const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const addClineToStack = vi.fn().mockResolvedValue(undefined) + const existingTask = { taskId: "existing-1" } const provider = { // Simulate an existing task present in stack - clineStack: [{ taskId: "existing-1" }], + clineStack: [existingTask], + getCurrentTask: vi.fn(() => existingTask), + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), + get evictCurrentTask() { + return (ClineProvider.prototype as any).evictCurrentTask.bind(this) + }, setValues: vi.fn(), getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, @@ -120,6 +127,11 @@ describe("Single-open-task invariant", () => { const provider = { getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), + get evictCurrentTask() { + return (ClineProvider.prototype as any).evictCurrentTask.bind(this) + }, removeClineFromStack, addClineToStack, updateGlobalState, @@ -173,6 +185,12 @@ describe("Single-open-task invariant", () => { const createTask = vi.fn().mockResolvedValue({ taskId: "ipc-1" }) const provider = { context: {} as any, + getCurrentTask: vi.fn(() => undefined), + taskHistoryStore: { get: vi.fn(() => undefined) }, + markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), + get evictCurrentTask() { + return (ClineProvider.prototype as any).evictCurrentTask.bind(this) + }, removeClineFromStack, postStateToWebview: vi.fn(), postMessageToWebview: vi.fn(), diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 6e25f62eaf..fc43feae76 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -348,4 +348,22 @@ describe("registerCommands handlers", () => { `[toggleAutoApprove] postMessageToWebview failed: ${boom}`, ) }) + + it("plusButtonClicked calls evictCurrentTask on the visible provider", async () => { + const evictCurrentTask = vi.fn().mockResolvedValue(undefined) + const refreshWorkspace = vi.fn().mockResolvedValue(undefined) + ;(mockVisibleProvider as any).evictCurrentTask = evictCurrentTask + ;(mockVisibleProvider as any).refreshWorkspace = refreshWorkspace + + await handlers["zoo-code.plusButtonClicked"]() + + expect(evictCurrentTask).toHaveBeenCalledTimes(1) + }) + + it("plusButtonClicked is a no-op when no visible provider", async () => { + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + + // Should not throw even with no visible provider + await handlers["zoo-code.plusButtonClicked"]() + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index db50edcee1..36b7c5d153 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -100,7 +100,7 @@ const getCommandsMap = ({ TelemetryService.instance.captureTitleButtonClicked("plus") - await visibleProvider.removeClineFromStack() + await visibleProvider.evictCurrentTask() await visibleProvider.refreshWorkspace() await visibleProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) // Send focusInput action immediately after chatButtonClicked diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6e5545a1f2..042e0247dd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -167,14 +167,6 @@ 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 @@ -288,7 +280,22 @@ export class ClineProvider // Create named listener functions so we can remove them later. const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId) - const onTaskCompleted = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { + const onTaskCompleted = async (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { + // Explicitly transition the task to "completed" so that any prior terminal + // status (e.g. "interrupted" from a previous cancel) is correctly overwritten. + // saveClineMessages() omits the status field for top-level tasks, which causes + // the store's merge to preserve a stale "interrupted" status after completion. + // interrupted → completed is a valid VALID_TRANSITIONS path. + try { + const existing = this.taskHistoryStore.get(taskId) + if (existing && existing.status !== "completed") { + await this.updateTaskHistory({ ...existing, status: "completed" }) + } + } catch (err) { + this.log( + `[onTaskCompleted] Failed to write completed status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ) + } this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) } const onTaskAborted = async () => { @@ -488,9 +495,7 @@ 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 - + async removeClineFromStack() { if (this.clineStack.length === 0) { return } @@ -499,11 +504,6 @@ export class ClineProvider let task = this.clineStack.pop() if (task) { - // Capture delegation metadata before abort/dispose, since abortTask(true) - // is async and the task reference is cleared afterwards. - const childTaskId = task.taskId - const parentTaskId = task.parentTaskId - task.emit(RooCodeEventName.TaskUnfocused) try { @@ -527,66 +527,94 @@ export class ClineProvider // Make sure no reference kept, once promises end it will be // 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. - // 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. - if (parentTaskId && childTaskId && !options?.skipDelegationRepair) { - try { - await this.runDelegationTransition(parentTaskId, async () => { - 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 - } + /** + * Evicts the current task from the stack and, if it was an active delegated child, + * marks it interrupted so the parent stays delegated (rather than silently losing the link). + * + * Use this in place of bare removeClineFromStack() at any call site that is not itself + * part of a delegation transition (i.e. everywhere except delegateParentAndOpenChild, + * createTask with a parentTask, and reopenParentFromDelegation). + */ + public async evictCurrentTask(): Promise { + const current = this.getCurrentTask() + const storedHistory = current ? this.taskHistoryStore.get(current.taskId) : undefined + await this.removeClineFromStack() + if (storedHistory?.status === "active" && storedHistory.parentTaskId) { + await this.markDelegatedChildInterrupted({ + childTaskId: storedHistory.id, + parentTaskId: storedHistory.parentTaskId, + }) + } + } - // 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 - } + /** + * Marks a live delegated child as "interrupted" when it is evicted without completing + * (e.g. user hits + for a new task, or navigates away while the child is still active). + * + * This preserves the delegation link — the parent stays "delegated" with awaitingChildId + * intact — so the user can later resume or abandon the interrupted child. It is the live- + * eviction counterpart to cancelTask()'s interruption path and to reopenParentFromDelegation() + * (which handles normal child completion). + * + * Must be called AFTER removeClineFromStack() so the live Task's final saveClineMessages() + * does not reattach the child's parentTaskId/rootTaskId over the interrupted status. + */ + private async markDelegatedChildInterrupted({ + childTaskId, + parentTaskId, + }: { + childTaskId: string + parentTaskId: string + }): Promise { + // Fast path: already interrupted (cancelTask beat us to it), nothing to do. + if (this.taskHistoryStore.get(childTaskId)?.status === "interrupted") { + this.log(`[markDelegatedChildInterrupted] Child ${childTaskId} already interrupted — skipping`) + return + } - assertValidTransition(parentHistory.status, "active") - await this.updateTaskHistory({ - ...parentHistory, - status: "active", - awaitingChildId: undefined, - delegatedToId: undefined, - }) - const repairMsg = - `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed). ` + - `Caller stack: ${callerStack?.split("\n").slice(1, 5).join(" | ")}` - this.log(repairMsg) - console.warn(repairMsg) - } - }) - } catch (err) { - // Non-fatal: log but do not block the pop operation. + try { + await this.runDelegationTransition(parentTaskId, async () => { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + + if (parentHistory?.status !== "delegated" || parentHistory?.awaitingChildId !== childTaskId) { this.log( - `[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${ - err instanceof Error ? err.message : String(err) - }`, + `[markDelegatedChildInterrupted] Parent ${parentTaskId} no longer delegated to child ${childTaskId} — skipping`, ) + return } - } + + // Prefer the in-memory store entry: it is written by delegateParentAndOpenChild + // with the correct parentTaskId before the child saves its first message. + // getTaskWithId reads from disk and may return an incomplete record (missing + // parentTaskId) if the child was evicted before its first saveClineMessages(). + const childHistory = + this.taskHistoryStore.get(childTaskId) ?? (await this.getTaskWithId(childTaskId)).historyItem + + // Re-check inside the lock to close the TOCTOU window with cancelTask() or + // a concurrent completion. Only proceed when the child is still "active"; + // any other terminal status (interrupted, completed) must not be overwritten. + if (childHistory?.status !== "active") { + this.log( + `[markDelegatedChildInterrupted] Child ${childTaskId} is no longer active (status=${childHistory?.status}) — skipping`, + ) + return + } + + const interruptedChild = { ...childHistory, status: "interrupted" as const } + await this.updateTaskHistory(interruptedChild) + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: interruptedChild }) + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: parentHistory }) + this.log( + `[markDelegatedChildInterrupted] Marked child ${childTaskId} interrupted; parent ${parentTaskId} stays delegated`, + ) + }) + } catch (err) { + this.log( + `[markDelegatedChildInterrupted] Failed for child ${childTaskId}: ${err instanceof Error ? err.message : String(err)}`, + ) } } @@ -650,7 +678,12 @@ export class ClineProvider this._disposed = true this.log("Disposing ClineProvider...") - // Clear all tasks from the stack. + // Clear all tasks from the stack. The first pop goes through evictCurrentTask() + // so an active delegated child is marked interrupted before the extension shuts down, + // rather than being left persisted as "active" across the reload. + if (this.clineStack.length > 0) { + await this.evictCurrentTask() + } while (this.clineStack.length > 0) { await this.removeClineFromStack() } @@ -1021,7 +1054,7 @@ export class ClineProvider const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id if (!isRehydratingCurrentTask) { - await this.removeClineFromStack() + await this.evictCurrentTask() } // If the history item has a saved mode, restore it and its associated API configuration. @@ -3107,13 +3140,11 @@ export class ClineProvider diffFuzzyThreshold, } = await this.getState() - // Single-open-task invariant: always enforce for user-initiated top-level tasks + // Single-open-task invariant: always enforce for user-initiated top-level tasks. if (!parentTask) { - try { - await this.removeClineFromStack() - } catch { + await this.evictCurrentTask().catch(() => { // Non-fatal - } + }) } if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { @@ -3162,21 +3193,7 @@ 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) - } + await this.cancelTaskInternal(task) } private async cancelTaskInternal(task: Task): Promise { @@ -3532,7 +3549,7 @@ export class ClineProvider // This ensures we never have >1 tasks open at any time during delegation. // Await abort completion to ensure clean disposal and prevent unhandled rejections. try { - await this.removeClineFromStack({ skipDelegationRepair: true }) + await this.removeClineFromStack() } catch (error) { this.log( `[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${ @@ -3578,13 +3595,44 @@ export class ClineProvider // single lock acquisition — no concurrent writer can slip between the read and // write, and the pure updater cannot re-enter the lock (no deadlock). // Broadcast and cache invalidation happen outside the lock after it releases. + // + // If the parent is already "delegated" to a previous interrupted child (the user + // navigated back to the parent and continued working), we implicitly sever the old + // link here (delegated → active → delegated) so no explicit Abandon step is needed. + // The old awaited child's status is re-read INSIDE the updater (which runs + // synchronously under the store lock) so a concurrent abandon or completion cannot + // slip between the status snapshot and the write. An active child must never be + // silently detached. try { await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => { - assertValidTransition(historyItem.status, "delegated") - const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) + let base = historyItem + if (historyItem.status === "delegated") { + // Re-read the awaited child's current status under the store lock. + const awaitedChildStatus = historyItem.awaitingChildId + ? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status + : undefined + // Only sever the stale link when the old child is confirmed interrupted. + // If it is still active, throw so the rollback path cleans up the new child + // rather than silently detaching a live task. + if (awaitedChildStatus !== "interrupted") { + throw new Error( + `[delegateParentAndOpenChild] Cannot re-delegate: existing child ${historyItem.awaitingChildId} is ${awaitedChildStatus}, not interrupted`, + ) + } + // Implicit sever of the stale interrupted-child link. + // The old child keeps its interrupted status; we just clear the parent's pointer. + base = { + ...historyItem, + status: "active" as const, + awaitingChildId: undefined, + delegatedToId: undefined, + } + } + assertValidTransition(base.status, "delegated") + const childIds = Array.from(new Set([...(base.childIds ?? []), child.taskId])) return { - ...historyItem, - status: "delegated", + ...base, + status: "delegated" as const, delegatedToId: child.taskId, awaitingChildId: child.taskId, childIds, @@ -3607,7 +3655,7 @@ export class ClineProvider // Only pop the stack if the child we just created is still on top. // A concurrent delegation could have pushed another child since we created ours. if (this.getCurrentTask()?.taskId === child.taskId) { - await this.removeClineFromStack({ skipDelegationRepair: true }) + await this.removeClineFromStack() } } catch (cleanupError) { this.log( @@ -3818,7 +3866,7 @@ export class ClineProvider // overwrite a "completed" status set later. const current = this.getCurrentTask() if (current?.taskId === childTaskId) { - await this.removeClineFromStack({ skipDelegationRepair: true }) + await this.removeClineFromStack() } // 3+5) Atomically mark child completed and parent active in one lock acquisition. @@ -3905,6 +3953,106 @@ export class ClineProvider }) } + /** + * Explicitly sever a delegated parent-child link, e.g. when the user gives up on + * an "interrupted" subtask instead of resuming it. Unlike removeClineFromStack()'s + * automatic repair, this is user-initiated and works even while the child is + * "interrupted" (which removeClineFromStack intentionally leaves alone so the child + * can still resume and report back). Only interrupted children can be abandoned — a + * still-running child must be cancelled first, so its link is never severed mid-stream. + * + * Parent transitions delegated → active (its normal "no longer awaiting a child" + * state). The child's own status is left untouched (interrupted stays interrupted; + * VALID_TRANSITIONS only allows interrupted → completed) — only its parent/root + * links are cleared so a later resume-and-complete cannot reattach it. + */ + public async abandonSubtask(childTaskId: string): Promise { + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + const parentTaskId = childHistory.parentTaskId + + if (!parentTaskId) { + return false + } + + // Only an interrupted (cancelled, not running) child may be abandoned. A still-running + // child must be cancelled first — severing the link out from under a live stream would + // orphan it silently instead of giving the user the normal cancel/resume flow. + if (childHistory.status !== "interrupted") { + this.log( + `[abandonSubtask] Aborting: child ${childTaskId} is not interrupted (status=${childHistory.status})`, + ) + return false + } + + return this.runDelegationTransition(parentTaskId, async () => { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + + if (parentHistory?.status !== "delegated" || parentHistory?.awaitingChildId !== childTaskId) { + this.log( + `[abandonSubtask] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + + `(status=${parentHistory?.status}, awaitingChildId=${parentHistory?.awaitingChildId})`, + ) + return false + } + + // Re-check inside the lock: the child may have been resumed (and be streaming again, + // or have completed) between the check above and acquiring the delegation transition lock. + const freshChild = this.taskHistoryStore.get(childTaskId) + if (freshChild?.status !== "interrupted") { + this.log( + `[abandonSubtask] Aborting: child ${childTaskId} is no longer interrupted (status=${freshChild?.status})`, + ) + return false + } + + assertValidTransition(parentHistory.status, "active") + + // Close the live child instance (if it's still the open task — the common case, + // since an interrupted child is rehydrated onto the stack after cancelTask) BEFORE + // clearing its persisted links. Task#saveClineMessages() rebuilds parentTaskId/ + // rootTaskId from the live (readonly) Task fields on every save, so any save that + // happens after we clear the persisted links — including abortTask's own final + // save — would silently reattach the child to its old parent. + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack() + } + + await this.taskHistoryStore.atomicUpdatePair( + childTaskId, + parentTaskId, + (child) => ({ ...child, parentTaskId: undefined, rootTaskId: undefined }), + (parent) => ({ + ...parent, + status: "active" as const, + awaitingChildId: undefined, + delegatedToId: undefined, + }), + ) + this.recentTasksCache = undefined + + // Guard against a stale in-flight resume/completion (e.g. a resume that was already + // in progress when abandon was clicked) reattaching the child after the link above + // was cleared. AttemptCompletionTool re-reads parent status from the persisted store, + // not the live task's readonly parentTaskId field, so this is the authoritative gate. + this.cancelledDelegationChildIds.add(childTaskId) + + if (this.isViewLaunched) { + const updatedChild = this.taskHistoryStore.get(childTaskId) + const updatedParent = this.taskHistoryStore.get(parentTaskId) + if (updatedChild) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedChild }) + } + if (updatedParent) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedParent }) + } + } + + this.log(`[abandonSubtask] Severed link between parent ${parentTaskId} and child ${childTaskId}`) + return true + }) + } + /** * Convert a file path to a webview-accessible URI * This method safely converts file paths to URIs that can be loaded in the webview 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 555253c345..37aae2ddb7 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -780,75 +780,9 @@ describe("ClineProvider flicker-free cancel", () => { ) }) - it("removeClineFromStack does not repair parent when child is interrupted", async () => { - const parentHistory: HistoryItem = { - id: "parent-1", - number: 1, - task: "parent task", - ts: Date.now(), - tokensIn: 10, - tokensOut: 20, - totalCost: 0.001, - workspace: "/test/workspace", - status: "delegated", - awaitingChildId: "child-1", - delegatedToId: "child-1", - } - - const childTask = { - taskId: "child-1", - instanceId: "inst-child", - parentTaskId: "parent-1", - emit: vi.fn(), - abortTask: vi.fn().mockResolvedValue(undefined), - } - ;(provider as any).clineStack = [childTask] - ;(provider as any).taskEventListeners = new Map() - // Seed the in-memory store so taskHistoryStore.get("child-1") returns interrupted - vi.spyOn((provider as any).taskHistoryStore, "get").mockImplementation((id: unknown) => - id === "child-1" ? { status: "interrupted" } : undefined, - ) - - provider.getTaskWithId = vi.fn().mockImplementation((id) => { - if (id === "parent-1") return Promise.resolve({ historyItem: parentHistory }) - throw new Error(`unexpected task lookup: ${id}`) - }) as any - - const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) - - await (provider as any).removeClineFromStack() - - // Parent must NOT be transitioned to active — it stays delegated - expect(updateTaskHistorySpy).not.toHaveBeenCalledWith( - expect.objectContaining({ id: "parent-1", status: "active" }), - ) - }) - - // Regression test for the race where a user clicks Stop on a freshly-delegated - // child and immediately navigates back to the parent (showTaskWithId), before - // cancelTask()'s own persistence of childHistory.status = "interrupted" has - // landed. Both cancelTask() and removeClineFromStack() serialize their parent - // writes through runDelegationTransition(parentTaskId, ...), but removeClineFromStack - // only skips its repair when taskHistoryStore.get(childTaskId)?.status === "interrupted". - // If removeClineFromStack's transition wins the race and runs while the store still - // reports "active" (the write from cancelTask() hasn't landed yet), it incorrectly - // repairs the parent to "active" and clears awaitingChildId, permanently severing - // the delegation link before the child ever gets a chance to report back. - it("removeClineFromStack does not repair parent when a cancellation for the child is in flight", async () => { - const parentHistory: HistoryItem = { - id: "parent-1", - number: 1, - task: "parent task", - ts: Date.now(), - tokensIn: 10, - tokensOut: 20, - totalCost: 0.001, - workspace: "/test/workspace", - status: "delegated", - awaitingChildId: "child-1", - delegatedToId: "child-1", - } - + it("removeClineFromStack never mutates delegation metadata (pure lifecycle after refactor)", async () => { + // After the refactor, removeClineFromStack() is pure lifecycle: pop, abort, clean up. + // Delegation state is owned by reopenParentFromDelegation() and markDelegatedChildInterrupted(). const childTask = { taskId: "child-1", instanceId: "inst-child", @@ -859,31 +793,16 @@ describe("ClineProvider flicker-free cancel", () => { ;(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 - + provider.getTaskWithId = vi.fn() 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" }), - ) + expect((provider as any).clineStack).toHaveLength(0) + expect(childTask.abortTask).toHaveBeenCalledWith(true) + // No history writes — lifecycle only + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + expect(provider.getTaskWithId).not.toHaveBeenCalled() }) afterAll(() => { diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 4a76ab4858..fe1eac8e20 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" import type { HistoryItem, ExtensionMessage } from "@roo-code/types" +import { RooCodeEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { ContextProxy } from "../../config/ContextProxy" @@ -780,4 +781,70 @@ describe("ClineProvider Task History Synchronization", () => { expect(item!.tokensIn).toBe(222) }) }) + + describe("taskCreationCallback — onTaskCompleted listener", () => { + function makeFakeTask(taskId: string) { + const listeners: Record unknown)[]> = {} + return { + taskId, + on: (event: string, fn: (...args: unknown[]) => unknown) => { + listeners[event] = listeners[event] ?? [] + listeners[event].push(fn) + }, + // Returns a promise that resolves when all async listeners have settled. + emit: async (event: string, ...args: unknown[]) => { + await Promise.all((listeners[event] ?? []).map((fn) => Promise.resolve(fn(...args)))) + }, + } + } + + it("writes completed status when task is not already completed", async () => { + const existing = createHistoryItem({ id: "task-cb-1", task: "T" }) + await provider.updateTaskHistory(existing, { broadcast: false }) + + const fakeTask = makeFakeTask("task-cb-1") + ;(provider as any).taskCreationCallback(fakeTask) + + await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-1", {}, {}) + + const stored = provider.taskHistoryStore.get("task-cb-1") + expect(stored?.status).toBe("completed") + }) + + it("skips the write when task is already completed", async () => { + const existing = createHistoryItem({ id: "task-cb-2", task: "T", status: "completed" }) + await provider.updateTaskHistory(existing, { broadcast: false }) + + const updateSpy = vi.spyOn(provider, "updateTaskHistory") + + const fakeTask = makeFakeTask("task-cb-2") + ;(provider as any).taskCreationCallback(fakeTask) + + await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-2", {}, {}) + + // updateTaskHistory is called initially to store the item, but should NOT be + // called again by onTaskCompleted since it's already completed. + const onTaskCompletedCalls = updateSpy.mock.calls.filter((c) => { + const item = c[0] as HistoryItem + return item?.id === "task-cb-2" && item?.status === "completed" + }) + // It was written with completed status already; the callback must not re-write. + expect(onTaskCompletedCalls.length).toBe(0) + }) + + it("logs and does not throw when updateTaskHistory rejects", async () => { + const existing = createHistoryItem({ id: "task-cb-3", task: "T" }) + await provider.updateTaskHistory(existing, { broadcast: false }) + + vi.spyOn(provider, "updateTaskHistory").mockRejectedValueOnce(new Error("disk full")) + const logSpy = vi.spyOn(provider as any, "log") + + const fakeTask = makeFakeTask("task-cb-3") + ;(provider as any).taskCreationCallback(fakeTask) + + await fakeTask.emit(RooCodeEventName.TaskCompleted, "task-cb-3", {}, {}) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("[onTaskCompleted] Failed to write")) + }) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.abandonSubtask.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.abandonSubtask.spec.ts new file mode 100644 index 0000000000..c1b6cf4dcf --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.abandonSubtask.spec.ts @@ -0,0 +1,53 @@ +// npx vitest run src/core/webview/__tests__/webviewMessageHandler.abandonSubtask.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), + changeLanguage: vi.fn(), +})) + +vi.mock("vscode", () => ({ + window: { showErrorMessage: vi.fn() }, + workspace: { workspaceFolders: undefined }, +})) + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +describe("webviewMessageHandler — abandonSubtaskWithId", () => { + let provider: { abandonSubtask: ReturnType; log: (msg: string) => void } + + beforeEach(() => { + vi.clearAllMocks() + provider = { + abandonSubtask: vi.fn().mockResolvedValue(true), + log: vi.fn(), + } + }) + + it("calls provider.abandonSubtask with the message text", async () => { + await webviewMessageHandler(provider as unknown as ClineProvider, { + type: "abandonSubtaskWithId", + text: "child-task-99", + }) + + expect(provider.abandonSubtask).toHaveBeenCalledWith("child-task-99") + }) + + it("catches and logs errors from provider.abandonSubtask", async () => { + const err = new Error("sever failed") + provider.abandonSubtask!.mockRejectedValue(err) + + // webviewMessageHandler calls .catch() on the promise — it should not throw + await webviewMessageHandler(provider as unknown as ClineProvider, { + type: "abandonSubtaskWithId", + text: "child-task-99", + }) + + // Give the microtask queue a tick so the .catch() fires + await new Promise((r) => setTimeout(r, 0)) + + expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("sever failed")) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 01abc5db98..a4a1e86cb3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -833,6 +833,15 @@ export const webviewMessageHandler = async ( case "deleteTaskWithId": provider.deleteTaskWithId(message.text!) break + case "abandonSubtaskWithId": + provider + .abandonSubtask(message.text!) + .catch((error) => + provider.log( + `[abandonSubtaskWithId] Failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ) + break case "deleteMultipleTasksWithIds": { const ids = message.ids diff --git a/src/extension/api.ts b/src/extension/api.ts index c140ec8ec0..2d0d5a6975 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -192,7 +192,7 @@ export class API extends EventEmitter implements RooCodeAPI { provider = this.sidebarProvider } - await provider.removeClineFromStack() + await provider.evictCurrentTask() await provider.postStateToWebview() await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images }) @@ -255,7 +255,7 @@ export class API extends EventEmitter implements RooCodeAPI { public async clearCurrentTask(_lastMessage?: string) { // Legacy finishSubTask removed; clear current by closing active task instance. - await this.sidebarProvider.removeClineFromStack() + await this.sidebarProvider.evictCurrentTask() await this.sidebarProvider.postStateToWebview() } @@ -263,6 +263,10 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.cancelTask() } + public async abandonSubtask(childTaskId: string): Promise { + return this.sidebarProvider.abandonSubtask(childTaskId) + } + public async sendMessage(text?: string, images?: string[]) { const currentTask = this.sidebarProvider.getCurrentTask() diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 8687c8e3fa..0941a22e2b 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,6 +1,14 @@ import { memo, useRef, useState, useMemo } from "react" import { useTranslation } from "react-i18next" -import { ChevronUp, ChevronDown, HardDriveDownload, HardDriveUpload, ListChevronsDownUp, ArrowLeft } from "lucide-react" +import { + ChevronUp, + ChevronDown, + HardDriveDownload, + HardDriveUpload, + ListChevronsDownUp, + ArrowLeft, + ArrowRight, +} from "lucide-react" import prettyBytes from "pretty-bytes" import type { ClineMessage } from "@roo-code/types" @@ -15,7 +23,6 @@ import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" import { vscode } from "@src/utils/vscode" import Thumbnails from "../common/Thumbnails" - import { TaskActions } from "./TaskActions" import { ContextWindowProgress } from "./ContextWindowProgress" import { Mention } from "./Mention" @@ -99,6 +106,15 @@ const TaskHeader = ({ } } + // Is this task itself a delegated parent still waiting on a subtask? See #559. + const awaitingChildId = currentTaskItem?.status === "delegated" ? currentTaskItem.awaitingChildId : undefined + + const handleGoToSubtask = () => { + if (awaitingChildId) { + vscode.postMessage({ type: "showTaskWithId", text: awaitingChildId }) + } + } + return (
{isSubtask && ( @@ -113,6 +129,19 @@ const TaskHeader = ({
)} + {awaitingChildId && ( +
e.stopPropagation()}> + +
+ )}
{ }) }) + describe("Delegated parent waiting-on-subtask banner", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + afterEach(() => { + mockExtensionState.currentTaskItem = { id: "test-task-id" } + mockExtensionState.taskHistory = [] + }) + + it("does not show the banner when currentTaskItem is not delegated", () => { + mockExtensionState.currentTaskItem = { id: "test-task-id", status: "active" } as any + renderTaskHeader() + expect(screen.queryByText("chat:task.waitingOnSubtask")).not.toBeInTheDocument() + }) + + it("shows the banner when currentTaskItem is delegated with an awaitingChildId", () => { + mockExtensionState.currentTaskItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + } as any + renderTaskHeader() + expect(screen.getByText("chat:task.waitingOnSubtask")).toBeInTheDocument() + }) + + it("navigates to the awaited child when the banner is clicked", () => { + mockExtensionState.currentTaskItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + } as any + renderTaskHeader() + + fireEvent.click(screen.getByText("chat:task.waitingOnSubtask")) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "showTaskWithId", text: "child-1" }) + }) + + it("does not show an Abandon button (removed: implicit sever on re-delegation)", () => { + mockExtensionState.currentTaskItem = { + id: "parent-1", + status: "delegated", + awaitingChildId: "child-1", + } as any + renderTaskHeader() + + expect(screen.getByText("chat:task.waitingOnSubtask")).toBeInTheDocument() + expect(screen.queryByText("history:abandonSubtask")).not.toBeInTheDocument() + }) + }) + describe("Context window percentage calculation", () => { // The percentage should be calculated as: // contextTokens / (contextWindow - reservedForOutput) * 100 diff --git a/webview-ui/src/components/history/SubtaskRow.tsx b/webview-ui/src/components/history/SubtaskRow.tsx index 0089e1f81d..c0aa88489a 100644 --- a/webview-ui/src/components/history/SubtaskRow.tsx +++ b/webview-ui/src/components/history/SubtaskRow.tsx @@ -6,6 +6,7 @@ import type { SubtaskTreeNode } from "./types" import { countAllSubtasks } from "./types" import { StandardTooltip } from "../ui" import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" +import { TaskStatusBadge } from "./TaskStatusBadge" interface SubtaskRowProps { /** The subtask tree node to display */ @@ -52,6 +53,9 @@ const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) {item.task} + {(item.status === "delegated" || item.status === "interrupted") && ( + + )}
diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx index d0dc367e64..72c6b64420 100644 --- a/webview-ui/src/components/history/TaskItemFooter.tsx +++ b/webview-ui/src/components/history/TaskItemFooter.tsx @@ -7,6 +7,7 @@ import { DeleteButton } from "./DeleteButton" import { StandardTooltip } from "../ui/standard-tooltip" import { useAppTranslation } from "@/i18n/TranslationContext" import { Split } from "lucide-react" +import { TaskStatusBadge } from "./TaskStatusBadge" export interface TaskItemFooterProps { item: HistoryItem @@ -36,6 +37,13 @@ const TaskItemFooter: React.FC = ({ · )} + {/* Delegation status (delegated parent waiting on a child, or interrupted child) */} + {(item.status === "delegated" || item.status === "interrupted") && ( + <> + + · + + )} {/* Datetime with time-ago format */} {formatTimeAgo(item.ts)} diff --git a/webview-ui/src/components/history/TaskStatusBadge.tsx b/webview-ui/src/components/history/TaskStatusBadge.tsx new file mode 100644 index 0000000000..5278f1422e --- /dev/null +++ b/webview-ui/src/components/history/TaskStatusBadge.tsx @@ -0,0 +1,36 @@ +import { useAppTranslation } from "@/i18n/TranslationContext" +import { cn } from "@/lib/utils" +import { StandardTooltip } from "../ui" + +interface TaskStatusBadgeProps { + status: "delegated" | "interrupted" + className?: string +} + +/** + * Small inline badge for a task's delegation status: a parent "delegated" and + * waiting on a subtask, or a child that was "interrupted" mid-execution and + * can be resumed rather than silently detached. See #559. + */ +export const TaskStatusBadge = ({ status, className }: TaskStatusBadgeProps) => { + const { t } = useAppTranslation() + + const isInterrupted = status === "interrupted" + const icon = isInterrupted ? "codicon-warning" : "codicon-sync" + const label = isInterrupted ? t("history:interruptedTag") : t("history:delegatedTag") + + return ( + + + + {label} + + + ) +} diff --git a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx index aa334d94c2..fc8d7edcca 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx @@ -94,4 +94,24 @@ describe("TaskItemFooter", () => { expect(screen.queryByText("history:subtaskTag")).not.toBeInTheDocument() }) + + it("shows a delegated status badge when status is delegated", () => { + render( + , + ) + + expect(screen.getByTestId("task-status-badge-delegated")).toBeInTheDocument() + }) + + it("shows an interrupted status badge when status is interrupted", () => { + render() + + expect(screen.getByTestId("task-status-badge-interrupted")).toBeInTheDocument() + }) + + it("does not show a status badge for a completed task", () => { + render() + + expect(screen.queryByTestId(/task-status-badge-/)).not.toBeInTheDocument() + }) }) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 50cf831543..c52283b5a2 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condensar context de forma intel·ligent", "openApiHistory": "Obrir historial d'API", "openUiHistory": "Obrir historial d'UI", - "backToParentTask": "Tasca principal" + "backToParentTask": "Tasca principal", + "waitingOnSubtask": "Esperant subtasca", + "goToSubtask": "Anar a la subtasca" }, "unpin": "Desfixar", "pin": "Fixar", diff --git a/webview-ui/src/i18n/locales/ca/history.json b/webview-ui/src/i18n/locales/ca/history.json index ab1bba7582..a872651d23 100644 --- a/webview-ui/src/i18n/locales/ca/history.json +++ b/webview-ui/src/i18n/locales/ca/history.json @@ -54,5 +54,7 @@ "subtaskTag": "Subtasca", "deleteWithSubtasks": "Això també eliminarà {{count}} subtasca(s). Estàs segur?", "expandSubtasks": "Expandir subtasques", - "collapseSubtasks": "Contreure subtasques" + "collapseSubtasks": "Contreure subtasques", + "delegatedTag": "Esperant subtasca", + "interruptedTag": "Interrompuda" } diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index be45daca7a..d9fa06a078 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Kontext intelligent komprimieren", "openApiHistory": "API-Verlauf öffnen", "openUiHistory": "UI-Verlauf öffnen", - "backToParentTask": "Übergeordnete Aufgabe" + "backToParentTask": "Übergeordnete Aufgabe", + "waitingOnSubtask": "Wartet auf Unteraufgabe", + "goToSubtask": "Zur Unteraufgabe" }, "unpin": "Lösen von oben", "pin": "Anheften", diff --git a/webview-ui/src/i18n/locales/de/history.json b/webview-ui/src/i18n/locales/de/history.json index 46064d6e2e..b10fcd445e 100644 --- a/webview-ui/src/i18n/locales/de/history.json +++ b/webview-ui/src/i18n/locales/de/history.json @@ -54,5 +54,7 @@ "subtaskTag": "Teilaufgabe", "deleteWithSubtasks": "Dies löscht auch {{count}} Teilaufgabe(n). Bist du sicher?", "expandSubtasks": "Teilaufgaben erweitern", - "collapseSubtasks": "Teilaufgaben einklappen" + "collapseSubtasks": "Teilaufgaben einklappen", + "delegatedTag": "Wartet auf Unteraufgabe", + "interruptedTag": "Unterbrochen" } diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index f217fbf0b8..ab966628f5 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -17,7 +17,9 @@ "delete": "Delete Task (Shift + Click to skip confirmation)", "openApiHistory": "Open API History", "openUiHistory": "Open UI History", - "backToParentTask": "Parent task" + "backToParentTask": "Parent task", + "waitingOnSubtask": "Waiting on subtask", + "goToSubtask": "Go to subtask" }, "unpin": "Unpin", "pin": "Pin", diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 85174890e1..6d53cd3663 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Subtask", "deleteWithSubtasks": "This will also delete {{count}} subtask(s). Are you sure?", "expandSubtasks": "Expand subtasks", - "collapseSubtasks": "Collapse subtasks" + "collapseSubtasks": "Collapse subtasks", + "delegatedTag": "Waiting on subtask", + "interruptedTag": "Interrupted" } diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 2e09e6cc78..eeb744d611 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condensar contexto de forma inteligente", "openApiHistory": "Abrir historial de API", "openUiHistory": "Abrir historial de UI", - "backToParentTask": "Tarea principal" + "backToParentTask": "Tarea principal", + "waitingOnSubtask": "Esperando subtarea", + "goToSubtask": "Ir a la subtarea" }, "unpin": "Desfijar", "pin": "Fijar", diff --git a/webview-ui/src/i18n/locales/es/history.json b/webview-ui/src/i18n/locales/es/history.json index 820f003d40..91d28abb99 100644 --- a/webview-ui/src/i18n/locales/es/history.json +++ b/webview-ui/src/i18n/locales/es/history.json @@ -54,5 +54,7 @@ "subtaskTag": "Subtarea", "deleteWithSubtasks": "Esto también eliminará {{count}} subtarea(s). ¿Estás seguro?", "expandSubtasks": "Expandir subtareas", - "collapseSubtasks": "Contraer subtareas" + "collapseSubtasks": "Contraer subtareas", + "delegatedTag": "Esperando subtarea", + "interruptedTag": "Interrumpida" } diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 363977ddce..b5992f0a7e 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condenser intelligemment le contexte", "openApiHistory": "Ouvrir l'historique de l'API", "openUiHistory": "Ouvrir l'historique de l'UI", - "backToParentTask": "Tâche parente" + "backToParentTask": "Tâche parente", + "waitingOnSubtask": "En attente de sous-tâche", + "goToSubtask": "Aller à la sous-tâche" }, "unpin": "Désépingler", "pin": "Épingler", diff --git a/webview-ui/src/i18n/locales/fr/history.json b/webview-ui/src/i18n/locales/fr/history.json index d84cfcb190..443bb0eb3e 100644 --- a/webview-ui/src/i18n/locales/fr/history.json +++ b/webview-ui/src/i18n/locales/fr/history.json @@ -54,5 +54,7 @@ "subtaskTag": "Sous-tâche", "deleteWithSubtasks": "Cela supprimera aussi {{count}} sous-tâche(s). Êtes-vous sûr ?", "expandSubtasks": "Développer les sous-tâches", - "collapseSubtasks": "Réduire les sous-tâches" + "collapseSubtasks": "Réduire les sous-tâches", + "delegatedTag": "En attente de sous-tâche", + "interruptedTag": "Interrompue" } diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 39f74b6309..81e4d66ee6 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -17,7 +17,9 @@ "condenseContext": "संदर्भ को बुद्धिमानी से संघनित करें", "openApiHistory": "API इतिहास खोलें", "openUiHistory": "UI इतिहास खोलें", - "backToParentTask": "मूल कार्य" + "backToParentTask": "मूल कार्य", + "waitingOnSubtask": "उपकार्य की प्रतीक्षा", + "goToSubtask": "उपकार्य पर जाएं" }, "unpin": "पिन करें", "pin": "अवपिन करें", diff --git a/webview-ui/src/i18n/locales/hi/history.json b/webview-ui/src/i18n/locales/hi/history.json index 0d4cb40dd9..3dd7cca9a9 100644 --- a/webview-ui/src/i18n/locales/hi/history.json +++ b/webview-ui/src/i18n/locales/hi/history.json @@ -47,5 +47,7 @@ "subtaskTag": "उप-कार्य", "deleteWithSubtasks": "यह {{count}} उप-कार्य(कों) को भी हटा देगा। क्या आप निश्चित हैं?", "expandSubtasks": "उप-कार्य विस्तारित करें", - "collapseSubtasks": "उप-कार्य संपीड़ित करें" + "collapseSubtasks": "उप-कार्य संपीड़ित करें", + "delegatedTag": "उपकार्य की प्रतीक्षा", + "interruptedTag": "बाधित" } diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 6eb35738f3..eaf53e9c1f 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -17,7 +17,9 @@ "delete": "Hapus Tugas (Shift + Klik untuk lewati konfirmasi)", "openApiHistory": "Buka Riwayat API", "openUiHistory": "Buka Riwayat UI", - "backToParentTask": "Tugas Induk" + "backToParentTask": "Tugas Induk", + "waitingOnSubtask": "Menunggu subtugas", + "goToSubtask": "Pergi ke subtugas" }, "history": { "title": "Riwayat" diff --git a/webview-ui/src/i18n/locales/id/history.json b/webview-ui/src/i18n/locales/id/history.json index 7796061107..772ca25384 100644 --- a/webview-ui/src/i18n/locales/id/history.json +++ b/webview-ui/src/i18n/locales/id/history.json @@ -56,5 +56,7 @@ "subtaskTag": "Subtask", "deleteWithSubtasks": "Ini juga akan menghapus {{count}} subtask. Apakah Anda yakin?", "expandSubtasks": "Perluas subtask", - "collapseSubtasks": "Tutup subtask" + "collapseSubtasks": "Tutup subtask", + "delegatedTag": "Menunggu subtugas", + "interruptedTag": "Terganggu" } diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index caa84b54c0..ec9e2171ab 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condensa contesto in modo intelligente", "openApiHistory": "Apri cronologia API", "openUiHistory": "Apri cronologia UI", - "backToParentTask": "Attività principale" + "backToParentTask": "Attività principale", + "waitingOnSubtask": "In attesa di sottoattività", + "goToSubtask": "Vai alla sottoattività" }, "unpin": "Rilascia", "pin": "Fissa", diff --git a/webview-ui/src/i18n/locales/it/history.json b/webview-ui/src/i18n/locales/it/history.json index aa728ef8f6..4097d43ce2 100644 --- a/webview-ui/src/i18n/locales/it/history.json +++ b/webview-ui/src/i18n/locales/it/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Sottoattività", "deleteWithSubtasks": "Questo eliminerà anche {{count}} sottoattività. Sei sicuro?", "expandSubtasks": "Espandi sottoattività", - "collapseSubtasks": "Comprimi sottoattività" + "collapseSubtasks": "Comprimi sottoattività", + "delegatedTag": "In attesa di sottoattività", + "interruptedTag": "Interrotta" } diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 9a28dc9f37..c4624748c6 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -17,7 +17,9 @@ "condenseContext": "コンテキストをインテリジェントに圧縮", "openApiHistory": "API履歴を開く", "openUiHistory": "UI履歴を開く", - "backToParentTask": "親タスク" + "backToParentTask": "親タスク", + "waitingOnSubtask": "サブタスク待ち", + "goToSubtask": "サブタスクへ" }, "unpin": "ピン留めを解除", "pin": "ピン留め", diff --git a/webview-ui/src/i18n/locales/ja/history.json b/webview-ui/src/i18n/locales/ja/history.json index b73baa3a76..be4897ba34 100644 --- a/webview-ui/src/i18n/locales/ja/history.json +++ b/webview-ui/src/i18n/locales/ja/history.json @@ -47,5 +47,7 @@ "subtaskTag": "サブタスク", "deleteWithSubtasks": "これにより {{count}} サブタスクも削除されます。よろしいですか?", "expandSubtasks": "サブタスクを展開", - "collapseSubtasks": "サブタスクを折りたたむ" + "collapseSubtasks": "サブタスクを折りたたむ", + "delegatedTag": "サブタスク待ち", + "interruptedTag": "中断" } diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 56b03165a7..a47401a350 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -17,7 +17,9 @@ "condenseContext": "컨텍스트 지능적으로 압축", "openApiHistory": "API 기록 열기", "openUiHistory": "UI 기록 열기", - "backToParentTask": "상위 작업" + "backToParentTask": "상위 작업", + "waitingOnSubtask": "하위 작업 대기 중", + "goToSubtask": "하위 작업으로 이동" }, "unpin": "고정 해제하기", "pin": "고정하기", diff --git a/webview-ui/src/i18n/locales/ko/history.json b/webview-ui/src/i18n/locales/ko/history.json index 0363feaaff..8a13ff12cd 100644 --- a/webview-ui/src/i18n/locales/ko/history.json +++ b/webview-ui/src/i18n/locales/ko/history.json @@ -47,5 +47,7 @@ "subtaskTag": "부분작업", "deleteWithSubtasks": "이는 {{count}} 부분작업도 삭제합니다. 확실하십니까?", "expandSubtasks": "부분작업 확장", - "collapseSubtasks": "부분작업 축소" + "collapseSubtasks": "부분작업 축소", + "delegatedTag": "하위 작업 대기 중", + "interruptedTag": "중단됨" } diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 357f5ca676..a83fe3c4d8 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Context intelligent samenvatten", "openApiHistory": "API-geschiedenis openen", "openUiHistory": "UI-geschiedenis openen", - "backToParentTask": "Bovenliggende taak" + "backToParentTask": "Bovenliggende taak", + "waitingOnSubtask": "Wacht op subtaak", + "goToSubtask": "Ga naar subtaak" }, "unpin": "Losmaken", "pin": "Vastmaken", diff --git a/webview-ui/src/i18n/locales/nl/history.json b/webview-ui/src/i18n/locales/nl/history.json index 012059ed04..db1515bfe5 100644 --- a/webview-ui/src/i18n/locales/nl/history.json +++ b/webview-ui/src/i18n/locales/nl/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Subtaak", "deleteWithSubtasks": "Dit zal ook {{count}} subtaak(en) verwijderen. Weet je het zeker?", "expandSubtasks": "Subtaken uitvouwen", - "collapseSubtasks": "Subtaken samenvouwen" + "collapseSubtasks": "Subtaken samenvouwen", + "delegatedTag": "Wacht op subtaak", + "interruptedTag": "Onderbroken" } diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 0a04fe70ce..a626d52d43 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Inteligentnie skondensuj kontekst", "openApiHistory": "Otwórz historię API", "openUiHistory": "Otwórz historię UI", - "backToParentTask": "Zadanie nadrzędne" + "backToParentTask": "Zadanie nadrzędne", + "waitingOnSubtask": "Oczekuje na podzadanie", + "goToSubtask": "Przejdź do podzadania" }, "unpin": "Odepnij", "pin": "Przypnij", diff --git a/webview-ui/src/i18n/locales/pl/history.json b/webview-ui/src/i18n/locales/pl/history.json index 7ec4b40d8f..2924d4710e 100644 --- a/webview-ui/src/i18n/locales/pl/history.json +++ b/webview-ui/src/i18n/locales/pl/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Podzadanie", "deleteWithSubtasks": "Spowoduje to usunięcie {{count}} podzadania(ń). Jesteś pewny?", "expandSubtasks": "Rozwiń podzadania", - "collapseSubtasks": "Zwiń podzadania" + "collapseSubtasks": "Zwiń podzadania", + "delegatedTag": "Oczekuje na podzadanie", + "interruptedTag": "Przerwane" } diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 2673c2aba0..a78667da79 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Condensar contexto de forma inteligente", "openApiHistory": "Abrir histórico da API", "openUiHistory": "Abrir histórico da UI", - "backToParentTask": "Tarefa pai" + "backToParentTask": "Tarefa pai", + "waitingOnSubtask": "Aguardando subtarefa", + "goToSubtask": "Ir para subtarefa" }, "unpin": "Desfixar", "pin": "Fixar", diff --git a/webview-ui/src/i18n/locales/pt-BR/history.json b/webview-ui/src/i18n/locales/pt-BR/history.json index 7966df1f46..79c84b70ef 100644 --- a/webview-ui/src/i18n/locales/pt-BR/history.json +++ b/webview-ui/src/i18n/locales/pt-BR/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Subtarefa", "deleteWithSubtasks": "Isso também excluirá {{count}} subtarefa(s). Tem certeza?", "expandSubtasks": "Expandir subtarefas", - "collapseSubtasks": "Recolher subtarefas" + "collapseSubtasks": "Recolher subtarefas", + "delegatedTag": "Aguardando subtarefa", + "interruptedTag": "Interrompida" } diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 74c69b2907..8fc86eb801 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Интеллектуально сжать контекст", "openApiHistory": "Открыть историю API", "openUiHistory": "Открыть историю UI", - "backToParentTask": "Родительская задача" + "backToParentTask": "Родительская задача", + "waitingOnSubtask": "Ожидание подзадачи", + "goToSubtask": "Перейти к подзадаче" }, "unpin": "Открепить", "pin": "Закрепить", diff --git a/webview-ui/src/i18n/locales/ru/history.json b/webview-ui/src/i18n/locales/ru/history.json index 7852362348..3035ec59d9 100644 --- a/webview-ui/src/i18n/locales/ru/history.json +++ b/webview-ui/src/i18n/locales/ru/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Подзадача", "deleteWithSubtasks": "Это также удалит {{count}} подзадачу(и). Вы уверены?", "expandSubtasks": "Развернуть подзадачи", - "collapseSubtasks": "Свернуть подзадачи" + "collapseSubtasks": "Свернуть подзадачи", + "delegatedTag": "Ожидание подзадачи", + "interruptedTag": "Прервано" } diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index b1f0a248e2..06b8ce2467 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Bağlamı akıllıca yoğunlaştır", "openApiHistory": "API Geçmişini Aç", "openUiHistory": "UI Geçmişini Aç", - "backToParentTask": "Üst görev" + "backToParentTask": "Üst görev", + "waitingOnSubtask": "Alt görev bekleniyor", + "goToSubtask": "Alt göreve git" }, "unpin": "Sabitlemeyi iptal et", "pin": "Sabitle", diff --git a/webview-ui/src/i18n/locales/tr/history.json b/webview-ui/src/i18n/locales/tr/history.json index fb7b6c6832..2ebc1a0154 100644 --- a/webview-ui/src/i18n/locales/tr/history.json +++ b/webview-ui/src/i18n/locales/tr/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Alt görev", "deleteWithSubtasks": "Bu, {{count}} alt görev(i) de silecektir. Emin misiniz?", "expandSubtasks": "Alt görevleri genişlet", - "collapseSubtasks": "Alt görevleri daralt" + "collapseSubtasks": "Alt görevleri daralt", + "delegatedTag": "Alt görev bekleniyor", + "interruptedTag": "Kesintiye uğradı" } diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index b9a0a36b4f..d04ff9b181 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -17,7 +17,9 @@ "condenseContext": "Cô đọng ngữ cảnh thông minh", "openApiHistory": "Mở lịch sử API", "openUiHistory": "Mở lịch sử UI", - "backToParentTask": "Nhiệm vụ cha" + "backToParentTask": "Nhiệm vụ cha", + "waitingOnSubtask": "Đang chờ nhiệm vụ con", + "goToSubtask": "Đến nhiệm vụ con" }, "unpin": "Bỏ ghim khỏi đầu", "pin": "Ghim lên đầu", diff --git a/webview-ui/src/i18n/locales/vi/history.json b/webview-ui/src/i18n/locales/vi/history.json index 779953e540..a6efa0671e 100644 --- a/webview-ui/src/i18n/locales/vi/history.json +++ b/webview-ui/src/i18n/locales/vi/history.json @@ -47,5 +47,7 @@ "subtaskTag": "Tác vụ con", "deleteWithSubtasks": "Điều này cũng sẽ xóa {{count}} tác vụ con. Bạn có chắc không?", "expandSubtasks": "Mở rộng tác vụ con", - "collapseSubtasks": "Thu gọn tác vụ con" + "collapseSubtasks": "Thu gọn tác vụ con", + "delegatedTag": "Đang chờ nhiệm vụ con", + "interruptedTag": "Bị gián đoạn" } diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 504662b4d3..414ccad5c0 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -17,7 +17,9 @@ "condenseContext": "智能压缩上下文", "openApiHistory": "打开 API 历史", "openUiHistory": "打开 UI 历史", - "backToParentTask": "父任务" + "backToParentTask": "父任务", + "waitingOnSubtask": "等待子任务", + "goToSubtask": "前往子任务" }, "unpin": "取消置顶", "pin": "置顶", diff --git a/webview-ui/src/i18n/locales/zh-CN/history.json b/webview-ui/src/i18n/locales/zh-CN/history.json index 20a73240ea..6b6bd03300 100644 --- a/webview-ui/src/i18n/locales/zh-CN/history.json +++ b/webview-ui/src/i18n/locales/zh-CN/history.json @@ -47,5 +47,7 @@ "subtaskTag": "子任务", "deleteWithSubtasks": "这也将删除 {{count}} 个子任务。您确定吗?", "expandSubtasks": "展开子任务", - "collapseSubtasks": "收起子任务" + "collapseSubtasks": "收起子任务", + "delegatedTag": "等待子任务", + "interruptedTag": "已中断" } diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 9d31c51e9f..9b35c6a12a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -17,7 +17,9 @@ "delete": "刪除工作(按住 Shift 並點選可跳過確認)", "openApiHistory": "開啟 API 歷史紀錄", "openUiHistory": "開啟 UI 歷史紀錄", - "backToParentTask": "上層工作" + "backToParentTask": "上層工作", + "waitingOnSubtask": "等待子任務", + "goToSubtask": "前往子任務" }, "unpin": "取消釘選", "pin": "釘選", diff --git a/webview-ui/src/i18n/locales/zh-TW/history.json b/webview-ui/src/i18n/locales/zh-TW/history.json index 1e12190a69..5fb3230c80 100644 --- a/webview-ui/src/i18n/locales/zh-TW/history.json +++ b/webview-ui/src/i18n/locales/zh-TW/history.json @@ -47,5 +47,7 @@ "subtaskTag": "子工作", "deleteWithSubtasks": "這也將刪除 {{count}} 個子工作。您確定嗎?", "expandSubtasks": "展開子工作", - "collapseSubtasks": "收起子工作" + "collapseSubtasks": "收起子工作", + "delegatedTag": "等待子任務", + "interruptedTag": "已中斷" }