diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index 91371551df..26fce96228 100644 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ b/packages/cloud/src/bridge/ExtensionChannel.ts @@ -188,18 +188,46 @@ export class ExtensionChannel extends BaseChannel< { from: RooCodeEventName.TaskPaused, to: ExtensionBridgeEventName.TaskPaused }, { from: RooCodeEventName.TaskUnpaused, to: ExtensionBridgeEventName.TaskUnpaused }, { from: RooCodeEventName.TaskSpawned, to: ExtensionBridgeEventName.TaskSpawned }, + { from: RooCodeEventName.TaskDelegated, to: ExtensionBridgeEventName.TaskDelegated }, + { from: RooCodeEventName.TaskDelegationCompleted, to: ExtensionBridgeEventName.TaskDelegationCompleted }, + { from: RooCodeEventName.TaskDelegationResumed, to: ExtensionBridgeEventName.TaskDelegationResumed }, { from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage }, { from: RooCodeEventName.TaskTokenUsageUpdated, to: ExtensionBridgeEventName.TaskTokenUsageUpdated }, ] as const eventMapping.forEach(({ from, to }) => { // Create and store the listener function for cleanup. - const listener = async (..._args: unknown[]) => { - this.publish(ExtensionSocketEvents.EVENT, { + const listener = async (...args: unknown[]) => { + const baseEvent: { + type: ExtensionBridgeEventName + instance: ExtensionInstance + timestamp: number + } = { type: to, instance: await this.updateInstance(), timestamp: Date.now(), - }) + } + + let eventToPublish: ExtensionBridgeEvent + + // Add payload for delegation events while avoiding `any` + if (to === ExtensionBridgeEventName.TaskDelegationCompleted) { + const [parentTaskId, childTaskId, summary] = args as [string, string, string] + eventToPublish = { + ...(baseEvent as unknown as ExtensionBridgeEvent), + payload: { parentTaskId, childTaskId, summary }, + } as unknown as ExtensionBridgeEvent + } else if (to === ExtensionBridgeEventName.TaskDelegationResumed) { + const [parentTaskId, childTaskId] = args as [string, string] + eventToPublish = { + ...(baseEvent as unknown as ExtensionBridgeEvent), + payload: { parentTaskId, childTaskId }, + } as unknown as ExtensionBridgeEvent + } else { + eventToPublish = baseEvent as unknown as ExtensionBridgeEvent + } + + this.publish(ExtensionSocketEvents.EVENT, eventToPublish) } this.eventListeners.set(from, listener) diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts index 3cccdc7844..188e2cc029 100644 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts @@ -120,6 +120,10 @@ describe("ExtensionChannel", () => { RooCodeEventName.TaskPaused, RooCodeEventName.TaskUnpaused, RooCodeEventName.TaskSpawned, + RooCodeEventName.TaskDelegated, + RooCodeEventName.TaskDelegationCompleted, + RooCodeEventName.TaskDelegationResumed, + RooCodeEventName.TaskUserMessage, RooCodeEventName.TaskTokenUsageUpdated, ] @@ -246,6 +250,116 @@ describe("ExtensionChannel", () => { undefined, ) }) + + it("should forward delegation events to socket", async () => { + await extensionChannel.onConnect(mockSocket) + ;(mockSocket.emit as any).mockClear() + + const delegatedListeners = eventListeners.get(RooCodeEventName.TaskDelegated) + expect(delegatedListeners).toBeDefined() + expect(delegatedListeners!.size).toBe(1) + + const listener = Array.from(delegatedListeners!)[0] + if (listener) { + await (listener as any)("parent-id", "child-id") + } + + expect(mockSocket.emit).toHaveBeenCalledWith( + ExtensionSocketEvents.EVENT, + expect.objectContaining({ + type: ExtensionBridgeEventName.TaskDelegated, + instance: expect.any(Object), + timestamp: expect.any(Number), + }), + undefined, + ) + }) + + it("should forward TaskDelegationCompleted with correct payload", async () => { + await extensionChannel.onConnect(mockSocket) + ;(mockSocket.emit as any).mockClear() + + const completedListeners = eventListeners.get(RooCodeEventName.TaskDelegationCompleted) + expect(completedListeners).toBeDefined() + + const listener = Array.from(completedListeners!)[0] + if (listener) { + await (listener as any)("parent-1", "child-1", "Summary text") + } + + expect(mockSocket.emit).toHaveBeenCalledWith( + ExtensionSocketEvents.EVENT, + expect.objectContaining({ + type: ExtensionBridgeEventName.TaskDelegationCompleted, + instance: expect.any(Object), + timestamp: expect.any(Number), + payload: expect.objectContaining({ + parentTaskId: "parent-1", + childTaskId: "child-1", + summary: "Summary text", + }), + }), + undefined, + ) + }) + + it("should forward TaskDelegationResumed with correct payload", async () => { + await extensionChannel.onConnect(mockSocket) + ;(mockSocket.emit as any).mockClear() + + const resumedListeners = eventListeners.get(RooCodeEventName.TaskDelegationResumed) + expect(resumedListeners).toBeDefined() + + const listener = Array.from(resumedListeners!)[0] + if (listener) { + await (listener as any)("parent-2", "child-2") + } + + expect(mockSocket.emit).toHaveBeenCalledWith( + ExtensionSocketEvents.EVENT, + expect.objectContaining({ + type: ExtensionBridgeEventName.TaskDelegationResumed, + instance: expect.any(Object), + timestamp: expect.any(Number), + payload: expect.objectContaining({ + parentTaskId: "parent-2", + childTaskId: "child-2", + }), + }), + undefined, + ) + }) + + it("should propagate all three delegation events in order", async () => { + await extensionChannel.onConnect(mockSocket) + ;(mockSocket.emit as any).mockClear() + + // Trigger TaskDelegated + const delegatedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegated)!)[0] + await (delegatedListener as any)("p1", "c1") + + // Trigger TaskDelegationCompleted + const completedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationCompleted)!)[0] + await (completedListener as any)("p1", "c1", "result") + + // Trigger TaskDelegationResumed + const resumedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationResumed)!)[0] + await (resumedListener as any)("p1", "c1") + + // Verify all three events were emitted + const emittedEvents = (mockSocket.emit as any).mock.calls.map((call: any[]) => call[1]?.type) + expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegated) + expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationCompleted) + expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationResumed) + + // Verify correct order: Delegated → Completed → Resumed + const delegatedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegated) + const completedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationCompleted) + const resumedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationResumed) + + expect(delegatedIdx).toBeLessThan(completedIdx) + expect(completedIdx).toBeLessThan(resumedIdx) + }) }) describe("Memory Leak Prevention", () => { @@ -257,7 +371,7 @@ describe("ExtensionChannel", () => { } // Listeners should still be the same count (not accumulated) - expect(eventListeners.size).toBe(15) + expect(eventListeners.size).toBe(18) // Each event should have exactly 1 listener eventListeners.forEach((listeners) => { diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 53870f34d4..a90d342c35 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -440,6 +440,9 @@ export enum ExtensionBridgeEventName { TaskPaused = RooCodeEventName.TaskPaused, TaskUnpaused = RooCodeEventName.TaskUnpaused, TaskSpawned = RooCodeEventName.TaskSpawned, + TaskDelegated = RooCodeEventName.TaskDelegated, + TaskDelegationCompleted = RooCodeEventName.TaskDelegationCompleted, + TaskDelegationResumed = RooCodeEventName.TaskDelegationResumed, TaskUserMessage = RooCodeEventName.TaskUserMessage, @@ -520,6 +523,21 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ instance: extensionInstanceSchema, timestamp: z.number(), }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskDelegated), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskDelegationCompleted), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskDelegationResumed), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), z.object({ type: z.literal(ExtensionBridgeEventName.TaskUserMessage), diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index b33320c7be..5e4415db20 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -26,6 +26,9 @@ export enum RooCodeEventName { TaskPaused = "taskPaused", TaskUnpaused = "taskUnpaused", TaskSpawned = "taskSpawned", + TaskDelegated = "taskDelegated", + TaskDelegationCompleted = "taskDelegationCompleted", + TaskDelegationResumed = "taskDelegationResumed", // Task Execution Message = "message", @@ -73,6 +76,19 @@ export const rooCodeEventsSchema = z.object({ [RooCodeEventName.TaskPaused]: z.tuple([z.string()]), [RooCodeEventName.TaskUnpaused]: z.tuple([z.string()]), [RooCodeEventName.TaskSpawned]: z.tuple([z.string(), z.string()]), + [RooCodeEventName.TaskDelegated]: z.tuple([ + z.string(), // parentTaskId + z.string(), // childTaskId + ]), + [RooCodeEventName.TaskDelegationCompleted]: z.tuple([ + z.string(), // parentTaskId + z.string(), // childTaskId + z.string(), // completionResultSummary + ]), + [RooCodeEventName.TaskDelegationResumed]: z.tuple([ + z.string(), // parentTaskId + z.string(), // childTaskId + ]), [RooCodeEventName.Message]: z.tuple([ z.object({ @@ -169,6 +185,21 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskSpawned], taskId: z.number().optional(), }), + z.object({ + eventName: z.literal(RooCodeEventName.TaskDelegated), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegated], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.TaskDelegationCompleted), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegationCompleted], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.TaskDelegationResumed), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegationResumed], + taskId: z.number().optional(), + }), // Task Execution z.object({ diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 395ec5986f..e5b6f5418f 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -19,6 +19,12 @@ export const historyItemSchema = z.object({ size: z.number().optional(), workspace: z.string().optional(), mode: z.string().optional(), + status: z.enum(["active", "completed", "delegated"]).optional(), + delegatedToId: z.string().optional(), // Last child this parent delegated to + childIds: z.array(z.string()).optional(), // All children spawned by this task + awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated) + completedByChildId: z.string().optional(), // Child that completed and resumed this parent + completionResultSummary: z.string().optional(), // Summary from completed child }) export type HistoryItem = z.infer diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index d3db937c66..0067bfb96d 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -72,6 +72,9 @@ export type TaskProviderEvents = { [RooCodeEventName.TaskPaused]: [taskId: string] [RooCodeEventName.TaskUnpaused]: [taskId: string] [RooCodeEventName.TaskSpawned]: [taskId: string] + [RooCodeEventName.TaskDelegated]: [parentTaskId: string, childTaskId: string] + [RooCodeEventName.TaskDelegationCompleted]: [parentTaskId: string, childTaskId: string, summary: string] + [RooCodeEventName.TaskDelegationResumed]: [parentTaskId: string, childTaskId: string] [RooCodeEventName.TaskUserMessage]: [taskId: string] @@ -92,6 +95,8 @@ export interface CreateTaskOptions { consecutiveMistakeLimit?: number experiments?: Record initialTodos?: TodoItem[] + /** Initial status for the task's history item (e.g., "active" for child tasks) */ + initialStatus?: "active" | "delegated" | "completed" } export enum TaskStatus { diff --git a/src/__tests__/delegation-events.spec.ts b/src/__tests__/delegation-events.spec.ts new file mode 100644 index 0000000000..15ea5b7c46 --- /dev/null +++ b/src/__tests__/delegation-events.spec.ts @@ -0,0 +1,48 @@ +// npx vitest run __tests__/delegation-events.spec.ts + +import { RooCodeEventName, rooCodeEventsSchema, taskEventSchema } from "@roo-code/types" + +describe("delegation event schemas", () => { + test("rooCodeEventsSchema validates tuples", () => { + expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegated].parse(["p", "c"])).not.toThrow() + expect(() => + (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationCompleted].parse(["p", "c", "s"]), + ).not.toThrow() + expect(() => + (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationResumed].parse(["p", "c"]), + ).not.toThrow() + + // invalid shapes + expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegated].parse(["p"])).toThrow() + expect(() => + (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationCompleted].parse(["p", "c"]), + ).toThrow() + expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationResumed].parse(["p"])).toThrow() + }) + + test("taskEventSchema discriminated union includes delegation events", () => { + expect(() => + taskEventSchema.parse({ + eventName: RooCodeEventName.TaskDelegated, + payload: ["p", "c"], + taskId: 1, + }), + ).not.toThrow() + + expect(() => + taskEventSchema.parse({ + eventName: RooCodeEventName.TaskDelegationCompleted, + payload: ["p", "c", "s"], + taskId: 1, + }), + ).not.toThrow() + + expect(() => + taskEventSchema.parse({ + eventName: RooCodeEventName.TaskDelegationResumed, + payload: ["p", "c"], + taskId: 1, + }), + ).not.toThrow() + }) +}) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts new file mode 100644 index 0000000000..1f95d0f6dd --- /dev/null +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -0,0 +1,494 @@ +// npx vitest run __tests__/history-resume-delegation.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { RooCodeEventName } from "@roo-code/types" + +/* vscode mock for Task/Provider imports */ +vi.mock("vscode", () => { + const window = { + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + } + const workspace = { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_key: string, defaultValue: any) => defaultValue), + update: vi.fn(), + })), + workspaceFolders: [], + } + const env = { machineId: "test-machine", uriScheme: "vscode", appName: "VSCode", language: "en", sessionId: "sess" } + const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }) } + const commands = { executeCommand: vi.fn() } + const ExtensionMode = { Development: 2 } + const version = "1.0.0-test" + return { window, workspace, env, Uri, commands, ExtensionMode, version } +}) + +// Mock persistence BEFORE importing provider +vi.mock("../core/task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) +vi.mock("../core/task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), +})) + +import { ClineProvider } from "../core/webview/ClineProvider" +import { readTaskMessages } from "../core/task-persistence/taskMessages" +import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" + +describe("History resume delegation - parent metadata transitions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("reopenParentFromDelegation persists parent metadata (delegated → active) before reopen", async () => { + const providerEmit = vi.fn() + const getTaskWithId = vi.fn().mockResolvedValue({ + historyItem: { + id: "parent-1", + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: ["child-1"], + ts: Date.now(), + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + }, + }) + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue({ + taskId: "parent-1", + skipPrevResponseIdOnce: false, + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + }) + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId, + emit: providerEmit, + getCurrentTask: vi.fn(() => ({ taskId: "child-1" })), + removeClineFromStack, + createTaskWithHistoryItem, + updateTaskHistory, + } as unknown as ClineProvider + + // Mock persistence reads to return empty arrays + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "Child done", + }) + + // Assert: metadata updated BEFORE createTaskWithHistoryItem + expect(updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "parent-1", + status: "active", + completedByChildId: "child-1", + completionResultSummary: "Child done", + awaitingChildId: undefined, + childIds: ["child-1"], + }), + ) + + // Verify call ordering: updateTaskHistory before createTaskWithHistoryItem + const updateCall = updateTaskHistory.mock.invocationCallOrder[0] + const createCall = createTaskWithHistoryItem.mock.invocationCallOrder[0] + expect(updateCall).toBeLessThan(createCall) + + // Verify child closed and parent reopened with updated metadata + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(createTaskWithHistoryItem).toHaveBeenCalledWith( + expect.objectContaining({ + status: "active", + completedByChildId: "child-1", + }), + { startTask: false }, + ) + }) + + it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => { + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p1", + status: "delegated", + awaitingChildId: "c1", + childIds: [], + ts: 100, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "c1" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + taskId: "p1", + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + // Start with existing messages in history + const existingUiMessages = [{ type: "ask", ask: "tool", text: "Old tool", ts: 50 }] + const existingApiMessages = [{ role: "user", content: [{ type: "text", text: "Old request" }], ts: 50 }] + + vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any) + vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p1", + childTaskId: "c1", + completionResultSummary: "Subtask completed successfully", + }) + + // Verify UI history injection (say: subtask_result) + expect(saveTaskMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + type: "say", + say: "subtask_result", + text: "Subtask completed successfully", + }), + ]), + taskId: "p1", + globalStoragePath: "/storage", + }), + ) + + // Verify API history injection (user role message) + expect(saveApiMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Subtask c1 completed"), + }), + ]), + }), + ]), + taskId: "p1", + globalStoragePath: "/storage", + }), + ) + + // Verify both include original messages + const uiCall = vi.mocked(saveTaskMessages).mock.calls[0][0] + expect(uiCall.messages).toHaveLength(2) // 1 original + 1 injected + + const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0] + expect(apiCall.messages).toHaveLength(2) // 1 original + 1 injected + }) + + it("reopenParentFromDelegation injects tool_result when new_task tool_use exists in API history", async () => { + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p-tool", + status: "delegated", + awaitingChildId: "c-tool", + childIds: [], + ts: 100, + task: "Parent with tool_use", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "c-tool" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + taskId: "p-tool", + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + // Include an assistant message with new_task tool_use to exercise the tool_result path + const existingUiMessages = [{ type: "ask", ask: "tool", text: "new_task request", ts: 50 }] + const existingApiMessages = [ + { role: "user", content: [{ type: "text", text: "Create a subtask" }], ts: 40 }, + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "new_task", + id: "toolu_abc123", + input: { mode: "code", message: "Do something" }, + }, + ], + ts: 50, + }, + ] + + vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any) + vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p-tool", + childTaskId: "c-tool", + completionResultSummary: "Subtask completed via tool_result", + }) + + // Verify API history injection uses tool_result (not text fallback) + expect(saveApiMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "toolu_abc123", + content: expect.stringContaining("Subtask c-tool completed"), + }), + ]), + }), + ]), + taskId: "p-tool", + globalStoragePath: "/storage", + }), + ) + + // Verify total message count: 2 original + 1 injected user message with tool_result + const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0] + expect(apiCall.messages).toHaveLength(3) + + // Verify the injected message is a user message with tool_result type + const injectedMsg = apiCall.messages[2] + expect(injectedMsg.role).toBe("user") + expect((injectedMsg.content[0] as any).type).toBe("tool_result") + expect((injectedMsg.content[0] as any).tool_use_id).toBe("toolu_abc123") + }) + + it("reopenParentFromDelegation sets skipPrevResponseIdOnce via resumeAfterDelegation", async () => { + const parentInstance: any = { + skipPrevResponseIdOnce: false, + resumeAfterDelegation: vi.fn().mockImplementation(async function (this: any) { + // Simulate what the real resumeAfterDelegation does + this.skipPrevResponseIdOnce = true + }), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + } + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "parent-2", + status: "delegated", + awaitingChildId: "child-2", + childIds: [], + ts: 200, + task: "P", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "child-2" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-2", + childTaskId: "child-2", + completionResultSummary: "Done", + }) + + // Critical: verify skipPrevResponseIdOnce set to true by resumeAfterDelegation + expect(parentInstance.skipPrevResponseIdOnce).toBe(true) + expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) + }) + + it("reopenParentFromDelegation emits events in correct order: TaskDelegationCompleted → TaskDelegationResumed", async () => { + const emitSpy = vi.fn() + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p3", + status: "delegated", + awaitingChildId: "c3", + childIds: [], + ts: 300, + task: "P3", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: emitSpy, + getCurrentTask: vi.fn(() => ({ taskId: "c3" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p3", + childTaskId: "c3", + completionResultSummary: "Summary", + }) + + // Verify both events emitted + const eventNames = emitSpy.mock.calls.map((c) => c[0]) + expect(eventNames).toContain(RooCodeEventName.TaskDelegationCompleted) + expect(eventNames).toContain(RooCodeEventName.TaskDelegationResumed) + + // CRITICAL: verify ordering (TaskDelegationCompleted before TaskDelegationResumed) + const completedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationCompleted) + const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed) + expect(completedIdx).toBeGreaterThanOrEqual(0) + expect(resumedIdx).toBeGreaterThan(completedIdx) + }) + + it("reopenParentFromDelegation does NOT emit TaskPaused or TaskUnpaused (new flow only)", async () => { + const emitSpy = vi.fn() + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p4", + status: "delegated", + awaitingChildId: "c4", + childIds: [], + ts: 400, + task: "P4", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: emitSpy, + getCurrentTask: vi.fn(() => ({ taskId: "c4" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p4", + childTaskId: "c4", + completionResultSummary: "S", + }) + + // CRITICAL: verify legacy pause/unpause events NOT emitted + const eventNames = emitSpy.mock.calls.map((c) => c[0]) + expect(eventNames).not.toContain(RooCodeEventName.TaskPaused) + expect(eventNames).not.toContain(RooCodeEventName.TaskUnpaused) + expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned) + }) + + it("handles empty history gracefully when injecting synthetic messages", async () => { + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p5", + status: "delegated", + awaitingChildId: "c5", + childIds: [], + ts: 500, + task: "P5", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "c5" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + // Mock read failures or empty returns + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p5", + childTaskId: "c5", + completionResultSummary: "Result", + }), + ).resolves.toBeUndefined() + + // Verify saves still occurred with just the injected message + expect(saveTaskMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + expect.objectContaining({ + type: "say", + say: "subtask_result", + }), + ], + }), + ) + + expect(saveApiMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + expect.objectContaining({ + role: "user", + }), + ], + }), + ) + }) +}) diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts new file mode 100644 index 0000000000..61e61c8838 --- /dev/null +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -0,0 +1,264 @@ +// npx vitest run __tests__/nested-delegation-resume.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { RooCodeEventName } from "@roo-code/types" + +// Mock safe-stable-stringify to avoid runtime error +vi.mock("safe-stable-stringify", () => ({ + default: (obj: any) => JSON.stringify(obj), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCompleted: vi.fn(), + }, + }, +})) + +// vscode mock for Task/Provider imports +vi.mock("vscode", () => { + const window = { + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + } + const workspace = { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_key: string, defaultValue: any) => defaultValue), + update: vi.fn(), + })), + workspaceFolders: [], + } + const env = { machineId: "test-machine", uriScheme: "vscode", appName: "VSCode", language: "en", sessionId: "sess" } + const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }) } + const commands = { executeCommand: vi.fn() } + const ExtensionMode = { Development: 2 } + const version = "1.0.0-test" + return { window, workspace, env, Uri, commands, ExtensionMode, version } +}) + +// Mock persistence helpers used by provider reopen flow BEFORE importing provider +vi.mock("../core/task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) +vi.mock("../core/task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), +})) + +import { attemptCompletionTool } from "../core/tools/AttemptCompletionTool" +import { ClineProvider } from "../core/webview/ClineProvider" +import type { Task } from "../core/task/Task" +import { readTaskMessages } from "../core/task-persistence/taskMessages" +import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" + +describe("Nested delegation resume (A → B → C)", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("C completes → reopens B; then B completes → reopens A; emits correct events; no resume_task asks", async () => { + // Track which task is "current" to satisfy provider.reopenParentFromDelegation() child-close logic + let currentActiveId: string | undefined = "C" + + // History index: A is parent of B, B is parent of C + const historyIndex: Record = { + A: { + id: "A", + status: "delegated", + delegatedToId: "B", + awaitingChildId: "B", + childIds: ["B"], + parentTaskId: undefined, + ts: 1, + task: "Task A", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + }, + B: { + id: "B", + status: "delegated", + delegatedToId: "C", + awaitingChildId: "C", + childIds: ["C"], + parentTaskId: "A", + ts: 2, + task: "Task B", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + }, + C: { + id: "C", + status: "active", + parentTaskId: "B", + ts: 3, + task: "Task C", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + }, + } + + const emitSpy = vi.fn() + const removeClineFromStack = vi.fn().mockImplementation(async () => { + // Simulate closing current child + currentActiveId = undefined + }) + const createTaskWithHistoryItem = vi + .fn() + .mockImplementation(async (historyItem: any, opts?: { startTask?: boolean }) => { + // Assert startTask:false to avoid resume asks + expect(opts).toEqual(expect.objectContaining({ startTask: false })) + // Reopen the parent + currentActiveId = historyItem.id + // Return minimal parent instance with resumeAfterDelegation + return { + taskId: historyItem.id, + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + } + }) + + const getTaskWithId = vi.fn(async (id: string) => { + if (!historyIndex[id]) throw new Error("Task not found") + return { + historyItem: historyIndex[id], + apiConversationHistory: [], + taskDirPath: "/tmp", + apiConversationHistoryFilePath: "/tmp/api.json", + uiMessagesFilePath: "/tmp/ui.json", + } + }) + + const updateTaskHistory = vi.fn(async (updated: any) => { + // Persist updated history back into index (simulate) + historyIndex[updated.id] = updated + return Object.values(historyIndex) + }) + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId, + emit: emitSpy, + getCurrentTask: vi.fn(() => (currentActiveId ? ({ taskId: currentActiveId } as any) : undefined)), + removeClineFromStack, + createTaskWithHistoryItem, + updateTaskHistory, + // Wire through provider method so attemptCompletionTool can call it + reopenParentFromDelegation: vi.fn(async (params: any) => { + return await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, params) + }), + } as unknown as ClineProvider + + // Empty histories for simplicity + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + // Step 1: C completes -> should reopen B automatically + const clineC = { + taskId: "C", + parentTask: undefined, // parent ref may or may not exist; metadata path should still work + parentTaskId: "B", + historyItem: { parentTaskId: "B" }, + providerRef: { deref: () => provider }, + say: vi.fn().mockResolvedValue(undefined), + emit: vi.fn(), + getTokenUsage: vi.fn(() => ({})), + toolUsage: {}, + clineMessages: [], + userMessageContent: [], + consecutiveMistakeCount: 0, + } as unknown as Task + + const blockC = { + type: "tool_use", + name: "attempt_completion", + params: { result: "C finished" }, + partial: false, + } as any + + const askFinishSubTaskApproval = vi.fn(async () => true) + + await attemptCompletionTool.handle(clineC, blockC, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: vi.fn(), + removeClosingTag: vi.fn((_, v?: string) => v ?? ""), + askFinishSubTaskApproval, + toolProtocol: "xml", + toolDescription: () => "desc", + } as any) + + // After C completes, B must be current + expect(currentActiveId).toBe("B") + + // Events emitted: C -> B hop + const eventNamesAfterC = emitSpy.mock.calls.map((c: any[]) => c[0]) + expect(eventNamesAfterC).toContain(RooCodeEventName.TaskDelegationCompleted) + expect(eventNamesAfterC).toContain(RooCodeEventName.TaskDelegationResumed) + + // Step 2: B completes -> should reopen A automatically (parent reference missing, must use parentTaskId path) + const clineB = { + taskId: "B", + parentTask: undefined, // simulate missing live parent reference + parentTaskId: "A", // persisted parent id + historyItem: { parentTaskId: "A" }, + providerRef: { deref: () => provider }, + say: vi.fn().mockResolvedValue(undefined), + emit: vi.fn(), + getTokenUsage: vi.fn(() => ({})), + toolUsage: {}, + clineMessages: [], + userMessageContent: [], + consecutiveMistakeCount: 0, + } as unknown as Task + + const blockB = { + type: "tool_use", + name: "attempt_completion", + params: { result: "B finished" }, + partial: false, + } as any + + await attemptCompletionTool.handle(clineB, blockB, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: vi.fn(), + removeClosingTag: vi.fn((_, v?: string) => v ?? ""), + askFinishSubTaskApproval, + toolProtocol: "xml", + toolDescription: () => "desc", + } as any) + + // After B completes, A must be current + expect(currentActiveId).toBe("A") + + // Ensure no resume_task asks were scheduled: verified indirectly by startTask:false on both hops + // (asserted in createTaskWithHistoryItem mock) + + // Provider emitted TaskDelegationCompleted/Resumed twice across both hops + const completedEvents = emitSpy.mock.calls.filter( + (c: any[]) => c[0] === RooCodeEventName.TaskDelegationCompleted, + ) + const resumedEvents = emitSpy.mock.calls.filter((c: any[]) => c[0] === RooCodeEventName.TaskDelegationResumed) + expect(completedEvents.length).toBeGreaterThanOrEqual(2) + expect(resumedEvents.length).toBeGreaterThanOrEqual(2) + + // Verify second hop used parentId = A + // Find a TaskDelegationCompleted matching A <- B + const hasAfromB = completedEvents.some(([, parentId, childId]: any[]) => parentId === "A" && childId === "B") + expect(hasAfromB).toBe(true) + }) +}) diff --git a/src/__tests__/new-task-delegation.spec.ts b/src/__tests__/new-task-delegation.spec.ts new file mode 100644 index 0000000000..b6f6d4d36c --- /dev/null +++ b/src/__tests__/new-task-delegation.spec.ts @@ -0,0 +1,44 @@ +// npx vitest run __tests__/new-task-delegation.spec.ts + +import { describe, it, expect, vi } from "vitest" +import { RooCodeEventName } from "@roo-code/types" +import { Task } from "../core/task/Task" + +describe("Task.startSubtask() metadata-driven delegation", () => { + it("Routes to provider.delegateParentAndOpenChild without pausing parent", async () => { + const provider = { + getState: vi.fn().mockResolvedValue({ + experiments: {}, + }), + delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }), + createTask: vi.fn(), + handleModeSwitch: vi.fn(), + } as any + + // Create a minimal Task-like instance with only fields used by startSubtask + const parent = Object.create(Task.prototype) as Task + ;(parent as any).taskId = "parent-1" + ;(parent as any).providerRef = { deref: () => provider } + ;(parent as any).emit = vi.fn() + + const child = await (Task.prototype as any).startSubtask.call(parent, "Do something", [], "code") + + expect(provider.delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + expect(child.taskId).toBe("child-1") + + // Parent should not be paused and no paused/unpaused events should be emitted + expect((parent as any).isPaused).not.toBe(true) + expect((parent as any).childTaskId).toBeUndefined() + const emittedEvents = (parent.emit as any).mock.calls.map((c: any[]) => c[0]) + expect(emittedEvents).not.toContain(RooCodeEventName.TaskPaused) + expect(emittedEvents).not.toContain(RooCodeEventName.TaskUnpaused) + + // Legacy path not used + expect(provider.createTask).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts new file mode 100644 index 0000000000..76cde6d386 --- /dev/null +++ b/src/__tests__/provider-delegation.spec.ts @@ -0,0 +1,92 @@ +// npx vitest run __tests__/provider-delegation.spec.ts + +import { describe, it, expect, vi } from "vitest" +import { RooCodeEventName } from "@roo-code/types" +import { ClineProvider } from "../core/webview/ClineProvider" + +describe("ClineProvider.delegateParentAndOpenChild()", () => { + it("persists parent delegation metadata and emits TaskDelegated", async () => { + const providerEmit = vi.fn() + const parentTask = { taskId: "parent-1", emit: vi.fn() } as any + + const updateTaskHistory = vi.fn() + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ taskId: "child-1" }) + const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "parent-1") { + return { + historyItem: { + id: "parent-1", + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + childIds: [], + }, + } + } + // child-1 + return { + historyItem: { + id: "child-1", + task: "Do something", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + }) + + const provider = { + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack, + createTask, + getTaskWithId, + updateTaskHistory, + handleModeSwitch, + log: vi.fn(), + } as unknown as ClineProvider + + const params = { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + } + + const child = await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, params) + + expect(child.taskId).toBe("child-1") + + // Invariant: parent closed before child creation + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + // Child task is created with initialStatus: "active" to avoid race conditions + expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, { + initialTodos: [], + initialStatus: "active", + }) + + // Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus) + expect(updateTaskHistory).toHaveBeenCalledTimes(1) + + // Parent set to "delegated" + const parentSaved = updateTaskHistory.mock.calls[0][0] + expect(parentSaved).toEqual( + expect.objectContaining({ + id: "parent-1", + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: expect.arrayContaining(["child-1"]), + }), + ) + + // Event emission (provider-level) + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") + + // Mode switch + expect(handleModeSwitch).toHaveBeenCalledWith("code") + }) +}) diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts new file mode 100644 index 0000000000..3284ae7629 --- /dev/null +++ b/src/__tests__/single-open-invariant.spec.ts @@ -0,0 +1,169 @@ +// npx vitest run __tests__/single-open-invariant.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ClineProvider } from "../core/webview/ClineProvider" +import { API } from "../extension/api" +import * as ProfileValidatorMod from "../shared/ProfileValidator" + +// Mock Task class used by ClineProvider to avoid heavy startup +vi.mock("../core/task/Task", () => { + class TaskStub { + public taskId: string + public instanceId = "inst" + public parentTask?: any + public apiConfiguration: any + public rootTask?: any + public enableBridge?: boolean + constructor(opts: any) { + this.taskId = opts.historyItem?.id ?? `task-${Math.random().toString(36).slice(2, 8)}` + this.parentTask = opts.parentTask + this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: "anthropic" } + opts.onCreated?.(this) + } + on() {} + off() {} + emit() {} + } + return { Task: TaskStub } +}) + +describe("Single-open-task invariant", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("User-initiated create: closes existing before opening new", async () => { + // Allow profile + vi.spyOn(ProfileValidatorMod.ProfileValidator, "isProfileAllowed").mockReturnValue(true) + + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const addClineToStack = vi.fn().mockResolvedValue(undefined) + + const provider = { + // Simulate an existing task present in stack + clineStack: [{ taskId: "existing-1" }], + setValues: vi.fn(), + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + organizationAllowList: "*", + diffEnabled: false, + enableCheckpoints: true, + checkpointTimeout: 60, + fuzzyMatchThreshold: 1.0, + cloudUserInfo: null, + remoteControlEnabled: false, + }), + removeClineFromStack, + addClineToStack, + setProviderProfile: vi.fn(), + log: vi.fn(), + getStateToPostToWebview: vi.fn(), + providerSettingsManager: { getModeConfigId: vi.fn(), listConfig: vi.fn() }, + customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) }, + taskCreationCallback: vi.fn(), + contextProxy: { + extensionUri: {}, + setValue: vi.fn(), + getValue: vi.fn(), + setProviderSettings: vi.fn(), + getProviderSettings: vi.fn(() => ({})), + }, + } as unknown as ClineProvider + + await (ClineProvider.prototype as any).createTask.call(provider, "New task") + + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(addClineToStack).toHaveBeenCalledTimes(1) + }) + + it("History resume path always closes current before rehydration (non-rehydrating case)", async () => { + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const addClineToStack = vi.fn().mockResolvedValue(undefined) + const updateGlobalState = vi.fn().mockResolvedValue(undefined) + + const provider = { + getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating + removeClineFromStack, + addClineToStack, + updateGlobalState, + log: vi.fn(), + customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) }, + providerSettingsManager: { + getModeConfigId: vi.fn().mockResolvedValue(undefined), + listConfig: vi.fn().mockResolvedValue([]), + }, + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + diffEnabled: false, + enableCheckpoints: true, + checkpointTimeout: 60, + fuzzyMatchThreshold: 1.0, + experiments: {}, + cloudUserInfo: null, + taskSyncEnabled: false, + }), + // Methods used by createTaskWithHistoryItem for pending edit cleanup + getPendingEditOperation: vi.fn().mockReturnValue(undefined), + clearPendingEditOperation: vi.fn(), + context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, + contextProxy: { + extensionUri: {}, + getValue: vi.fn(), + setValue: vi.fn(), + setProviderSettings: vi.fn(), + getProviderSettings: vi.fn(() => ({})), + }, + postStateToWebview: vi.fn(), + } as unknown as ClineProvider + + const historyItem = { + id: "hist-1", + number: 1, + ts: Date.now(), + task: "Task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/tmp", + } + + const task = await (ClineProvider.prototype as any).createTaskWithHistoryItem.call(provider, historyItem) + expect(task).toBeTruthy() + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(addClineToStack).toHaveBeenCalledTimes(1) + }) + + it("IPC StartNewTask path closes current before new task", async () => { + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ taskId: "ipc-1" }) + const provider = { + context: {} as any, + removeClineFromStack, + postStateToWebview: vi.fn(), + postMessageToWebview: vi.fn(), + createTask, + getValues: vi.fn(() => ({})), + providerSettingsManager: { saveConfig: vi.fn() }, + on: vi.fn((ev: any, cb: any) => { + if (ev === "taskCreated") { + // no-op for this test + } + return provider + }), + } as unknown as ClineProvider + + const output = { appendLine: vi.fn() } as any + const api = new API(output, provider, undefined, false) + + const taskId = await api.startNewTask({ + configuration: {}, + text: "hello", + images: undefined, + newTab: false, + }) + + expect(taskId).toBe("ipc-1") + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(createTask).toHaveBeenCalled() + }) +}) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index f6b9575be3..25a548b6e2 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -21,6 +21,8 @@ export type TaskMetadataOptions = { globalStoragePath: string workspace: string mode?: string + /** Initial status for the task (e.g., "active" for child tasks) */ + initialStatus?: "active" | "delegated" | "completed" } export async function taskMetadata({ @@ -32,6 +34,7 @@ export async function taskMetadata({ globalStoragePath, workspace, mode, + initialStatus, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -84,6 +87,9 @@ export async function taskMetadata({ } // Create historyItem once with pre-calculated values. + // initialStatus is included when provided (e.g., "active" for child tasks) + // to ensure the status is set from the very first save, avoiding race conditions + // where attempt_completion might run before a separate status update. const historyItem: HistoryItem = { id, rootTaskId, @@ -101,6 +107,7 @@ export async function taskMetadata({ size: taskDirSize, workspace, mode, + ...(initialStatus && { status: initialStatus }), } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 74e55702fd..c4a52246aa 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -150,6 +150,8 @@ export interface TaskOptions extends CreateTaskOptions { onCreated?: (task: Task) => void initialTodos?: TodoItem[] workspacePath?: string + /** Initial status for the task's history item (e.g., "active" for child tasks) */ + initialStatus?: "active" | "delegated" | "completed" } export class Task extends EventEmitter implements TaskLike { @@ -217,6 +219,7 @@ export class Task extends EventEmitter implements TaskLike { private readonly globalStoragePath: string abort: boolean = false currentRequestAbortController?: AbortController + skipPrevResponseIdOnce: boolean = false // TaskStatus idleAsk?: ClineMessage @@ -228,8 +231,6 @@ export class Task extends EventEmitter implements TaskLike { abortReason?: ClineApiReqCancelReason isInitialized = false isPaused: boolean = false - pausedModeSlug: string = defaultModeSlug - private pauseInterval: NodeJS.Timeout | undefined // API apiConfiguration: ProviderSettings @@ -322,6 +323,9 @@ export class Task extends EventEmitter implements TaskLike { // Cloud Sync Tracking private cloudSyncedMessageTimestamps: Set = new Set() + // Initial status for the task's history item (set at creation time to avoid race conditions) + private readonly initialStatus?: "active" | "delegated" | "completed" + constructor({ provider, apiConfiguration, @@ -342,6 +346,7 @@ export class Task extends EventEmitter implements TaskLike { onCreated, initialTodos, workspacePath, + initialStatus, }: TaskOptions) { super() @@ -428,6 +433,7 @@ export class Task extends EventEmitter implements TaskLike { this.parentTask = parentTask this.taskNumber = taskNumber + this.initialStatus = initialStatus // Store the task's mode when it's created. // For history items, use the stored mode; for new tasks, we'll set it @@ -869,6 +875,7 @@ export class Task extends EventEmitter implements TaskLike { globalStoragePath: this.globalStoragePath, workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. + initialStatus: this.initialStatus, }) if (hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)) { @@ -1495,7 +1502,14 @@ export class Task extends EventEmitter implements TaskLike { text: `\n${task}\n`, }, ...imageBlocks, - ]) + ]).catch((error) => { + // Swallow loop rejection when the task was intentionally abandoned/aborted + // during delegation or user cancellation to prevent unhandled rejections. + if (this.abandoned === true || this.abortReason === "user_cancelled") { + return + } + throw error + }) } private async resumeTaskFromHistory() { @@ -1843,12 +1857,6 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error removing event listeners:", error) } - // Stop waiting for child task completion. - if (this.pauseInterval) { - clearInterval(this.pauseInterval) - this.pauseInterval = undefined - } - if (this.enableBridge) { BridgeOrchestrator.getInstance() ?.unsubscribeFromTask(this.taskId) @@ -1925,79 +1933,87 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Provider not available") } - const newTask = await provider.createTask(message, undefined, this, { initialTodos }) - - if (newTask) { - this.isPaused = true // Pause parent. - this.childTaskId = newTask.taskId - - await provider.handleModeSwitch(mode) // Set child's mode. - await delay(500) // Allow mode change to take effect. - - this.emit(RooCodeEventName.TaskPaused, this.taskId) - this.emit(RooCodeEventName.TaskSpawned, newTask.taskId) - } - - return newTask - } - - // Used when a sub-task is launched and the parent task is waiting for it to - // finish. - // TBD: Add a timeout to prevent infinite waiting. - public async waitForSubtask() { - await new Promise((resolve) => { - this.pauseInterval = setInterval(() => { - if (!this.isPaused) { - clearInterval(this.pauseInterval) - this.pauseInterval = undefined - resolve() - } - }, 1000) + const child = await (provider as any).delegateParentAndOpenChild({ + parentTaskId: this.taskId, + message, + initialTodos, + mode, }) + return child } - public async completeSubtask(lastMessage: string) { - this.isPaused = false - this.childTaskId = undefined + /** + * Resume parent task after delegation completion without showing resume ask. + * Used in metadata-driven subtask flow. + * + * This method: + * - Clears any pending ask states + * - Resets abort and streaming flags + * - Ensures next API call includes full context + * - Immediately continues task loop without user interaction + */ + public async resumeAfterDelegation(): Promise { + // Clear any ask states that might have been set during history load + this.idleAsk = undefined + this.resumableAsk = undefined + this.interactiveAsk = undefined - this.emit(RooCodeEventName.TaskUnpaused, this.taskId) + // Reset abort and streaming state to ensure clean continuation + this.abort = false + this.abandoned = false + this.abortReason = undefined + this.didFinishAbortingStream = false + this.isStreaming = false + this.isWaitingForFirstChunk = false - // Fake an answer from the subtask that it has completed running and - // this is the result of what it has done add the message to the chat - // history and to the webview ui. - try { - await this.say("subtask_result", lastMessage) + // Ensure next API call includes full context after delegation + this.skipPrevResponseIdOnce = true - // Check if using native protocol to determine how to add the subtask result - const modelInfo = this.api.getModel().info - const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) + // Mark as initialized and active + this.isInitialized = true + this.emit(RooCodeEventName.TaskActive, this.taskId) - if (toolProtocol === "native" && this.pendingNewTaskToolCallId) { - // For native protocol, push the actual tool_result with the subtask's real result. - // NewTaskTool deferred pushing the tool_result until now so that the parent task - // gets useful information about what the subtask actually accomplished. - this.userMessageContent.push({ - type: "tool_result", - tool_use_id: this.pendingNewTaskToolCallId, - content: `[new_task completed] Result: ${lastMessage}`, - } as Anthropic.ToolResultBlockParam) - - // Clear the pending tool call ID - this.pendingNewTaskToolCallId = undefined - } else { - // For XML protocol (or if no pending tool call ID), add as a separate user message - await this.addToApiConversationHistory({ - role: "user", - content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }], - }) - } - } catch (error) { - this.providerRef - .deref() - ?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`) - - throw error + // Load conversation history if not already loaded + if (this.apiConversationHistory.length === 0) { + this.apiConversationHistory = await this.getSavedApiConversationHistory() } + + // Add environment details to the existing last user message (which contains the tool_result) + // This avoids creating a new user message which would cause consecutive user messages + const environmentDetails = await getEnvironmentDetails(this, true) + let lastUserMsgIndex = -1 + for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) { + if (this.apiConversationHistory[i].role === "user") { + lastUserMsgIndex = i + break + } + } + if (lastUserMsgIndex >= 0) { + const lastUserMsg = this.apiConversationHistory[lastUserMsgIndex] + if (Array.isArray(lastUserMsg.content)) { + // Remove any existing environment_details blocks before adding fresh ones + const contentWithoutEnvDetails = lastUserMsg.content.filter( + (block: Anthropic.Messages.ContentBlockParam) => { + if (block.type === "text" && typeof block.text === "string") { + const isEnvironmentDetailsBlock = + block.text.trim().startsWith("") && + block.text.trim().endsWith("") + return !isEnvironmentDetailsBlock + } + return true + }, + ) + // Add fresh environment details + lastUserMsg.content = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] + } + } + + // Save the updated history + await this.saveApiConversationHistory() + + // Continue task loop - pass empty array to signal no new user content needed + // The initiateTaskLoop will handle this by skipping user message addition + await this.initiateTaskLoop([]) } // Task Loop @@ -2085,37 +2101,6 @@ export class Task extends EventEmitter implements TaskLike { this.consecutiveMistakeCount = 0 } - // In this Cline request loop, we need to check if this task instance - // has been asked to wait for a subtask to finish before continuing. - const provider = this.providerRef.deref() - - if (this.isPaused && provider) { - provider.log(`[subtasks] paused ${this.taskId}.${this.instanceId}`) - await this.waitForSubtask() - provider.log(`[subtasks] resumed ${this.taskId}.${this.instanceId}`) - - // After subtask completes, completeSubtask has pushed content to userMessageContent. - // Copy it to currentUserContent so it gets sent to the API in this iteration. - if (this.userMessageContent.length > 0) { - currentUserContent.push(...this.userMessageContent) - this.userMessageContent = [] - } - - const currentMode = (await provider.getState())?.mode ?? defaultModeSlug - - if (currentMode !== this.pausedModeSlug) { - // The mode has changed, we need to switch back to the paused mode. - await provider.handleModeSwitch(this.pausedModeSlug) - - // Delay to allow mode change to take effect before next tool is executed. - await delay(500) - - provider.log( - `[subtasks] task ${this.taskId}.${this.instanceId} has switched back to '${this.pausedModeSlug}' from '${currentMode}'`, - ) - } - } - // Getting verbose details is an expensive operation, it uses ripgrep to // top-down build file structure of project which for large projects can // take a few seconds. For the best UX we show a placeholder api_req_started @@ -2175,10 +2160,15 @@ export class Task extends EventEmitter implements TaskLike { const finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] // Only add user message to conversation history if: - // 1. This is the first attempt (retryAttempt === 0), OR - // 2. The message was removed in a previous iteration (userMessageWasRemoved === true) + // 1. This is the first attempt (retryAttempt === 0), AND + // 2. The original userContent was not empty (empty signals delegation resume where + // the user message with tool_result and env details is already in history), OR + // 3. The message was removed in a previous iteration (userMessageWasRemoved === true) // This prevents consecutive user messages while allowing re-add when needed - if ((currentItem.retryAttempt ?? 0) === 0 || currentItem.userMessageWasRemoved) { + const isEmptyUserContent = currentUserContent.length === 0 + const shouldAddUserMessage = + ((currentItem.retryAttempt ?? 0) === 0 && !isEmptyUserContent) || currentItem.userMessageWasRemoved + if (shouldAddUserMessage) { await this.addToApiConversationHistory({ role: "user", content: finalUserContent }) TelemetryService.instance.captureConversationMessage(this.taskId, "user") } @@ -2194,7 +2184,7 @@ export class Task extends EventEmitter implements TaskLike { } satisfies ClineApiReqInfo) await this.saveClineMessages() - await provider?.postStateToWebview() + await this.providerRef.deref()?.postStateToWebview() try { let cacheWriteTokens = 0 @@ -3492,6 +3482,7 @@ export class Task extends EventEmitter implements TaskLike { const metadata: ApiHandlerCreateMessageMetadata = { mode: mode, taskId: this.taskId, + suppressPreviousResponseId: this.skipPrevResponseIdOnce, // Include tools and tool protocol when using native protocol and model supports it ...(shouldIncludeTools ? { tools: allTools, tool_choice: "auto", toolProtocol, parallelToolCalls: parallelToolCallsEnabled } @@ -3501,6 +3492,8 @@ export class Task extends EventEmitter implements TaskLike { // Create an AbortController to allow cancelling the request mid-stream this.currentRequestAbortController = new AbortController() const abortSignal = this.currentRequestAbortController.signal + // Reset the flag after using it + this.skipPrevResponseIdOnce = false // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. const stream = this.api.createMessage( diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 9f69403cb0..4bae9c49d0 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1966,153 +1966,4 @@ describe("Queued message processing after condense", () => { expect(spyB).toHaveBeenCalledWith("B message", undefined) expect(taskB.messageQueueService.isEmpty()).toBe(true) }) - - describe("completeSubtask native protocol handling", () => { - let mockProvider: any - let mockApiConfig: any - - beforeEach(() => { - vi.clearAllMocks() - - if (!TelemetryService.hasInstance()) { - TelemetryService.createInstance([]) - } - - mockApiConfig = { - apiProvider: "anthropic", - apiKey: "test-key", - } - - mockProvider = { - context: { - globalStorageUri: { fsPath: "/test/storage" }, - }, - getState: vi.fn().mockResolvedValue({ - apiConfiguration: mockApiConfig, - }), - say: vi.fn(), - postStateToWebview: vi.fn().mockResolvedValue(undefined), - postMessageToWebview: vi.fn().mockResolvedValue(undefined), - updateTaskHistory: vi.fn().mockResolvedValue(undefined), - log: vi.fn(), - } - }) - - it("should push tool_result to userMessageContent for native protocol with pending tool call ID", async () => { - // Create a task with a model that supports native tools - const task = new Task({ - provider: mockProvider, - apiConfiguration: { - ...mockApiConfig, - apiProvider: "anthropic", - toolProtocol: "native", // Explicitly set native protocol - }, - task: "parent task", - startTask: false, - }) - - // Mock the API to return a native protocol model - vi.spyOn(task.api, "getModel").mockReturnValue({ - id: "claude-3-5-sonnet-20241022", - info: { - contextWindow: 200000, - maxTokens: 8192, - supportsPromptCache: true, - supportsNativeTools: true, - defaultToolProtocol: "native", - } as ModelInfo, - }) - - // For native protocol, NewTaskTool does NOT push tool_result immediately. - // It only sets the pending tool call ID. The actual tool_result is pushed by completeSubtask. - task.pendingNewTaskToolCallId = "test-tool-call-id" - - // Call completeSubtask - await task.completeSubtask("Subtask completed successfully") - - // For native protocol, should push the actual tool_result with the subtask's result - expect(task.userMessageContent).toHaveLength(1) - expect(task.userMessageContent[0]).toEqual({ - type: "tool_result", - tool_use_id: "test-tool-call-id", - content: "[new_task completed] Result: Subtask completed successfully", - }) - - // Should NOT have added a user message to apiConversationHistory - expect(task.apiConversationHistory).toHaveLength(0) - - // pending tool call ID should be cleared - expect(task.pendingNewTaskToolCallId).toBeUndefined() - }) - - it("should add user message to apiConversationHistory for XML protocol", async () => { - // Create a task with a model that doesn't support native tools - const task = new Task({ - provider: mockProvider, - apiConfiguration: { - ...mockApiConfig, - apiProvider: "anthropic", - }, - task: "parent task", - startTask: false, - }) - - // Mock the API to return an XML protocol model (no native tool support) - vi.spyOn(task.api, "getModel").mockReturnValue({ - id: "claude-2", - info: { - contextWindow: 100000, - maxTokens: 4096, - supportsPromptCache: false, - supportsNativeTools: false, - } as ModelInfo, - }) - - // Call completeSubtask - await task.completeSubtask("Subtask completed successfully") - - // For XML protocol, should add to apiConversationHistory - expect(task.apiConversationHistory).toHaveLength(1) - expect(task.apiConversationHistory[0]).toEqual( - expect.objectContaining({ - role: "user", - content: [{ type: "text", text: "[new_task completed] Result: Subtask completed successfully" }], - }), - ) - - // Should NOT have added to userMessageContent - expect(task.userMessageContent).toHaveLength(0) - }) - - it("should set isPaused to false after completeSubtask", async () => { - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "parent task", - startTask: false, - }) - - // Mock the API to return an XML protocol model - vi.spyOn(task.api, "getModel").mockReturnValue({ - id: "claude-2", - info: { - contextWindow: 100000, - maxTokens: 4096, - supportsPromptCache: false, - supportsNativeTools: false, - } as ModelInfo, - }) - - // Set isPaused to true (simulating waiting for subtask) - task.isPaused = true - task.childTaskId = "child-task-id" - - // Call completeSubtask - await task.completeSubtask("Subtask completed") - - // Should reset paused state - expect(task.isPaused).toBe(false) - expect(task.childTaskId).toBeUndefined() - }) - }) }) diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index f7551fb4a3..e21f3b7188 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -1,7 +1,6 @@ -import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" -import { RooCodeEventName } from "@roo-code/types" +import { RooCodeEventName, type HistoryItem } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" @@ -21,6 +20,18 @@ export interface AttemptCompletionCallbacks extends ToolCallbacks { toolDescription: () => string } +/** + * Interface for provider methods needed by AttemptCompletionTool for delegation handling. + */ +interface DelegationProvider { + getTaskWithId(id: string): Promise<{ historyItem: HistoryItem }> + reopenParentFromDelegation(params: { + parentTaskId: string + childTaskId: string + completionResultSummary: string + }): Promise +} + export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { readonly name = "attempt_completion" as const @@ -33,7 +44,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { async execute(params: AttemptCompletionParams, task: Task, callbacks: AttemptCompletionCallbacks): Promise { const { result } = params - const { handleError, pushToolResult, askFinishSubTaskApproval, toolDescription, toolProtocol } = callbacks + const { handleError, pushToolResult, askFinishSubTaskApproval } = callbacks // Prevent attempt_completion if any tool failed in the current turn if (task.didToolFailInCurrentTurn) { @@ -77,17 +88,50 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { TelemetryService.instance.captureTaskCompleted(task.taskId) task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) - if (task.parentTask) { - const didApprove = await askFinishSubTaskApproval() + // Check for subtask using parentTaskId (metadata-driven delegation) + if (task.parentTaskId) { + // Check if this subtask has already completed and returned to parent + // to prevent duplicate tool_results when user revisits from history + const provider = task.providerRef.deref() as DelegationProvider | undefined + if (provider) { + try { + const { historyItem } = await provider.getTaskWithId(task.taskId) + const status = historyItem?.status - if (!didApprove) { - pushToolResult(formatResponse.toolDenied()) - return + if (status === "completed") { + // Subtask already completed - skip delegation flow entirely + // Fall through to normal completion ask flow below (outside this if block) + // This shows the user the completion result and waits for acceptance + // without injecting another tool_result to the parent + } else if (status === "active") { + // Normal subtask completion - do delegation + const delegated = await this.delegateToParent( + task, + result, + provider, + askFinishSubTaskApproval, + pushToolResult, + ) + if (delegated) return + } else { + // Unexpected status (undefined or "delegated") - log error and skip delegation + // undefined indicates a bug in status persistence during child creation + // "delegated" would mean this child has its own grandchild pending (shouldn't reach attempt_completion) + console.error( + `[AttemptCompletionTool] Unexpected child task status "${status}" for task ${task.taskId}. ` + + `Expected "active" or "completed". Skipping delegation to prevent data corruption.`, + ) + // Fall through to normal completion ask flow + } + } catch (err) { + // If we can't get the history, log error and skip delegation + console.error( + `[AttemptCompletionTool] Failed to get history for task ${task.taskId}: ${(err as Error)?.message ?? String(err)}. ` + + `Skipping delegation.`, + ) + // Fall through to normal completion ask flow + } } - - pushToolResult("") - await task.providerRef.deref()?.finishSubTask(result) - return } const { response, text, images } = await task.ask("completion_result", "", false) @@ -106,6 +150,35 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } } + /** + * Handles the common delegation flow when a subtask completes. + * Returns true if delegation was performed and the caller should return early. + */ + private async delegateToParent( + task: Task, + result: string, + provider: DelegationProvider, + askFinishSubTaskApproval: () => Promise, + pushToolResult: (result: string) => void, + ): Promise { + const didApprove = await askFinishSubTaskApproval() + + if (!didApprove) { + pushToolResult(formatResponse.toolDenied()) + return true + } + + pushToolResult("") + + await provider.reopenParentFromDelegation({ + parentTaskId: task.parentTaskId!, + childTaskId: task.taskId, + completionResultSummary: result, + }) + + return true + } + override async handlePartial(task: Task, block: ToolUse<"attempt_completion">): Promise { const result: string | undefined = block.params.result const command: string | undefined = block.params.command diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f406774eae..c5607d2a85 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" import { Task } from "../task/Task" -import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { getModeBySlug } from "../../shared/modes" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import { parseMarkdownChecklist } from "./UpdateTodoListTool" @@ -123,40 +123,16 @@ export class NewTaskTool extends BaseTool<"new_task"> { task.checkpointSave(true) } - // Preserve the current mode so we can resume with it later. - task.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug - - // For native protocol, set the pending tool call ID BEFORE starting the subtask. - // This prevents a race condition where the subtask completes (during the delay - // in startSubtask) before we set the ID, which would cause completeSubtask to - // not push the tool_result, breaking the API conversation structure. - if (toolProtocol === "native" && toolCallId) { - task.pendingNewTaskToolCallId = toolCallId - } - - const newTask = await task.startSubtask(unescapedMessage, todoItems, mode) - - if (!newTask) { - // Clear the pending ID since the subtask wasn't created - if (toolProtocol === "native" && toolCallId) { - task.pendingNewTaskToolCallId = undefined - } - pushToolResult(t("tools:newTask.errors.policy_restriction")) - return - } - - // For native protocol with toolCallId, don't push tool_result here. - // The actual result (including what the subtask accomplished) will be pushed - // by completeSubtask. This gives the parent task useful information about - // what the subtask actually did. - // The task loop will stay alive because isPaused is true (see Task.ts stack push condition). - if (toolProtocol !== "native" || !toolCallId) { - // For XML protocol, push the result immediately (existing behavior) - pushToolResult( - `Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage} and ${todoItems.length} todo items`, - ) - } + // Delegate parent and open child as sole active task + const child = await (provider as any).delegateParentAndOpenChild({ + parentTaskId: task.taskId, + message: unescapedMessage, + initialTodos: todoItems, + mode, + }) + // Reflect delegation in tool result (no pause/unpause, no wait) + pushToolResult(`Delegated to child task ${child.taskId}`) return } catch (error) { await handleError("creating new task", error) diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 43ba3ea9d3..975f754ee5 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -74,6 +74,15 @@ const mockSayAndCreateMissingParamError = vi.fn() const mockStartSubtask = vi .fn<(message: string, todoItems: any[], mode: string) => Promise>() .mockResolvedValue({ taskId: "mock-subtask-id" }) + +// Adapter to satisfy legacy expectations while exercising new delegation path +const mockDelegateParentAndOpenChild = vi.fn( + async (args: { parentTaskId: string; message: string; initialTodos: any[]; mode: string }) => { + // Call legacy spy so existing expectations still pass + await mockStartSubtask(args.message, args.initialTodos, args.mode) + return { taskId: "child-1" } + }, +) const mockCheckpointSave = vi.fn() // Mock the Cline instance and its methods/properties @@ -93,6 +102,7 @@ const mockCline = { deref: vi.fn(() => ({ getState: vi.fn(() => ({ customModes: [], mode: "ask" })), handleModeSwitch: vi.fn(), + delegateParentAndOpenChild: mockDelegateParentAndOpenChild, })), }, } @@ -157,7 +167,7 @@ describe("newTaskTool", () => { ) // Verify side effects - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should not un-escape single escaped \@", async () => { @@ -270,7 +280,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should work with todos parameter when provided", async () => { @@ -303,7 +313,7 @@ describe("newTaskTool", () => { "code", ) - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should error when mode parameter is missing", async () => { @@ -423,7 +433,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should REQUIRE todos when VSCode setting is enabled", async () => { @@ -505,7 +515,7 @@ describe("newTaskTool", () => { ) // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should work with empty todos string when VSCode setting is enabled", async () => { @@ -542,7 +552,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should check VSCode setting with Package.name configuration key", async () => { @@ -613,3 +623,77 @@ describe("newTaskTool", () => { // Add more tests for error handling (invalid mode, approval denied) if needed }) + +describe("newTaskTool delegation flow", () => { + it("delegates to provider and does not call legacy startSubtask", async () => { + // Arrange: stub provider delegation + const providerSpy = { + getState: vi.fn().mockResolvedValue({ + mode: "ask", + experiments: {}, + }), + delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }), + handleModeSwitch: vi.fn(), + } as any + + // Use a fresh local cline instance to avoid cross-test interference + const localStartSubtask = vi.fn() + const localEmit = vi.fn() + const localCline = { + ask: vi.fn(), + sayAndCreateMissingParamError: mockSayAndCreateMissingParamError, + emit: localEmit, + recordToolError: mockRecordToolError, + consecutiveMistakeCount: 0, + isPaused: false, + pausedModeSlug: "ask", + taskId: "mock-parent-task-id", + enableCheckpoints: false, + checkpointSave: mockCheckpointSave, + startSubtask: localStartSubtask, + providerRef: { + deref: vi.fn(() => providerSpy), + }, + } + + const block: ToolUse = { + type: "tool_use", + name: "new_task", + params: { + mode: "code", + message: "Do something", + // no todos -> should default to [] + }, + partial: false, + } + + // Act + await newTaskTool.handle(localCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) + + // Assert: provider method called with correct params + expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "mock-parent-task-id", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + // Assert: legacy path not used + expect(localStartSubtask).not.toHaveBeenCalled() + + // Assert: no pause/unpause events emitted in delegation path + const pauseEvents = (localEmit as any).mock.calls.filter( + (c: any[]) => c[0] === "taskPaused" || c[0] === "taskUnpaused", + ) + expect(pauseEvents.length).toBe(0) + + // Assert: tool result reflects delegation + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task child-1")) + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 20c746f272..5927324860 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -92,8 +92,9 @@ import { Task } from "../task/Task" import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt" import { webviewMessageHandler } from "./webviewMessageHandler" -import type { ClineMessage } from "@roo-code/types" +import type { ClineMessage, TodoItem } from "@roo-code/types" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" +import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" @@ -483,19 +484,6 @@ export class ClineProvider return this.clineStack.map((cline) => cline.taskId) } - // Remove the current task/cline instance (at the top of the stack), so this - // task is finished and resume the previous task/cline instance (if it - // exists). - // This is used when a subtask is finished and the parent task needs to be - // resumed. - async finishSubTask(lastMessage: string) { - // Remove the last cline instance from the stack (this is the finished - // subtask). - await this.removeClineFromStack() - // Resume the last cline instance in the stack (if it exists - this is - // the 'parent' calling task). - await this.getCurrentTask()?.completeSubtask(lastMessage) - } // Pending Edit Operations Management /** @@ -862,7 +850,10 @@ export class ClineProvider await this.removeClineFromStack() } - public async createTaskWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) { + public async createTaskWithHistoryItem( + historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, + options?: { startTask?: boolean }, + ) { // Check if we're rehydrating the current task to avoid flicker const currentTask = this.getCurrentTask() const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id @@ -940,7 +931,10 @@ export class ClineProvider taskNumber: historyItem.number, workspacePath: historyItem.workspace, onCreated: this.taskCreationCallback, + startTask: options?.startTask ?? true, enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled), + // Preserve the status from the history item to avoid overwriting it when the task saves messages + initialStatus: historyItem.status, }) if (isRehydratingCurrentTask) { @@ -1666,9 +1660,8 @@ export class ClineProvider // remove task from stack if it's the current task if (id === this.getCurrentTask()?.taskId) { - // if we found the taskid to delete - call finish to abort this task and allow a new task to be started, - // if we are deleting a subtask and parent task is still waiting for subtask to finish - it allows the parent to resume (this case should neve exist) - await this.finishSubTask(t("common:tasks.deleted")) + // Close the current task instance; delegation flows will be handled via metadata if applicable. + await this.removeClineFromStack() } // delete task from the task history state @@ -2359,7 +2352,13 @@ export class ClineProvider const existingItemIndex = history.findIndex((h) => h.id === item.id) if (existingItemIndex !== -1) { - history[existingItemIndex] = item + // Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten. + // This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened, + // terminated, or when routine message persistence occurs. + history[existingItemIndex] = { + ...history[existingItemIndex], + ...item, + } } else { history.push(item) } @@ -2699,6 +2698,15 @@ export class ClineProvider remoteControlEnabled, } = await this.getState() + // Single-open-task invariant: always enforce for user-initiated top-level tasks + if (!parentTask) { + try { + await this.removeClineFromStack() + } catch { + // Non-fatal + } + } + if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) } @@ -2919,11 +2927,11 @@ export class ClineProvider language, mode, taskId: task?.taskId, - parentTaskId: task?.parentTask?.taskId, + parentTaskId: task?.parentTaskId, apiProvider: apiConfiguration?.apiProvider, modelId: task?.api?.getModel().id, diffStrategy: task?.diffStrategy?.getName(), - isSubtask: task ? !!task.parentTask : undefined, + isSubtask: task ? !!task.parentTaskId : undefined, ...(todos && { todos }), } } @@ -2953,6 +2961,286 @@ export class ClineProvider return this.currentWorkspacePath || getWorkspacePath() } + /** + * Delegate parent task and open child task. + * + * - Enforce single-open invariant + * - Persist parent delegation metadata + * - Emit TaskDelegated (task-level; API forwards to provider/bridge) + * - Create child as sole active and switch mode to child's mode + */ + public async delegateParentAndOpenChild(params: { + parentTaskId: string + message: string + initialTodos: TodoItem[] + mode: string + }): Promise { + const { parentTaskId, message, initialTodos, mode } = params + + // Metadata-driven delegation is always enabled + + // 1) Get parent (must be current task) + const parent = this.getCurrentTask() + if (!parent) { + throw new Error("[delegateParentAndOpenChild] No current task") + } + if (parent.taskId !== parentTaskId) { + throw new Error( + `[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`, + ) + } + + // 2) Enforce single-open invariant by closing/disposing the parent first + // 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() + } catch (error) { + this.log( + `[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + // Non-fatal: proceed with child creation even if parent cleanup had issues + } + + // 3) Switch provider mode to child's requested mode BEFORE creating the child task + // This ensures the child's system prompt and configuration are based on the correct mode. + // The mode switch must happen before createTask() because the Task constructor + // initializes its mode from provider.getState() during initializeTaskMode(). + try { + await this.handleModeSwitch(mode as any) + } catch (e) { + this.log( + `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ + (e as Error)?.message ?? String(e) + }`, + ) + } + + // 4) Create child as sole active (parent reference preserved for lineage) + // Pass initialStatus: "active" to ensure the child task's historyItem is created + // with status from the start, avoiding race conditions where the task might + // call attempt_completion before status is persisted separately. + const child = await this.createTask(message, undefined, parent as any, { + initialTodos, + initialStatus: "active", + }) + + // 5) Persist parent delegation metadata + try { + const { historyItem } = await this.getTaskWithId(parentTaskId) + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) + const updatedHistory: typeof historyItem = { + ...historyItem, + status: "delegated", + delegatedToId: child.taskId, + awaitingChildId: child.taskId, + childIds, + } + await this.updateTaskHistory(updatedHistory) + } catch (err) { + this.log( + `[delegateParentAndOpenChild] Failed to persist parent metadata for ${parentTaskId} -> ${child.taskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + + // 6) Emit TaskDelegated (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) + } catch { + // non-fatal + } + + return child + } + + /** + * Reopen parent task from delegation with write-back and events. + */ + public async reopenParentFromDelegation(params: { + parentTaskId: string + childTaskId: string + completionResultSummary: string + }): Promise { + const { parentTaskId, childTaskId, completionResultSummary } = params + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + + // 1) Load parent from history and current persisted messages + const { historyItem } = await this.getTaskWithId(parentTaskId) + + let parentClineMessages: ClineMessage[] = [] + try { + parentClineMessages = await readTaskMessages({ + taskId: parentTaskId, + globalStoragePath, + }) + } catch { + parentClineMessages = [] + } + + let parentApiMessages: any[] = [] + try { + parentApiMessages = (await readApiMessages({ + taskId: parentTaskId, + globalStoragePath, + })) as any[] + } catch { + parentApiMessages = [] + } + + // 2) Inject synthetic records: UI subtask_result and update API tool_result + const ts = Date.now() + + // Defensive: ensure arrays + if (!Array.isArray(parentClineMessages)) parentClineMessages = [] + if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + + const subtaskUiMessage: ClineMessage = { + type: "say", + say: "subtask_result", + text: completionResultSummary, + ts, + } + parentClineMessages.push(subtaskUiMessage) + await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) + + // Find the tool_use_id from the last assistant message's new_task tool_use + let toolUseId: string | undefined + for (let i = parentApiMessages.length - 1; i >= 0; i--) { + const msg = parentApiMessages[i] + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.name === "new_task") { + toolUseId = block.id + break + } + } + if (toolUseId) break + } + } + + // The API expects: user → assistant (with tool_use) → user (with tool_result) + // We need to add a NEW user message with the tool_result AFTER the assistant's tool_use + // NOT add it to an existing user message + if (toolUseId) { + // Check if the last message is already a user message with a tool_result for this tool_use_id + // (in case this is a retry or the history was already updated) + const lastMsg = parentApiMessages[parentApiMessages.length - 1] + let alreadyHasToolResult = false + if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { + for (const block of lastMsg.content) { + if (block.type === "tool_result" && block.tool_use_id === toolUseId) { + // Update the existing tool_result content + block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + alreadyHasToolResult = true + break + } + } + } + + // If no existing tool_result found, create a NEW user message with the tool_result + if (!alreadyHasToolResult) { + parentApiMessages.push({ + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: toolUseId, + content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, + ], + ts, + }) + } + } else { + // Fallback for XML protocol or when toolUseId couldn't be found: + // Add a text block (not ideal but maintains backward compatibility) + parentApiMessages.push({ + role: "user", + content: [ + { + type: "text", + text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, + ], + ts, + }) + } + + await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) + + // 3) Update child metadata to "completed" status + try { + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + await this.updateTaskHistory({ + ...childHistory, + status: "completed", + }) + } catch (err) { + this.log( + `[reopenParentFromDelegation] Failed to persist child completed status for ${childTaskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + + // 4) Update parent metadata and persist BEFORE emitting completion event + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) + const updatedHistory: typeof historyItem = { + ...historyItem, + status: "active", + completedByChildId: childTaskId, + completionResultSummary, + awaitingChildId: undefined, + childIds, + } + await this.updateTaskHistory(updatedHistory) + + // 5) Emit TaskDelegationCompleted (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) + } catch { + // non-fatal + } + + // 6) Close child instance if still open (single-open-task invariant) + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack() + } + + // 7) Reopen the parent from history as the sole active task (restores saved mode) + // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling + const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) + + // 8) Inject restored histories into the in-memory instance before resuming + if (parentInstance) { + try { + await parentInstance.overwriteClineMessages(parentClineMessages) + } catch { + // non-fatal + } + try { + await parentInstance.overwriteApiConversationHistory(parentApiMessages as any) + } catch { + // non-fatal + } + + // Auto-resume parent without ask("resume_task") + await parentInstance.resumeAfterDelegation() + } + + // 9) Emit TaskDelegationResumed (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal + } + } + /** * 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.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 70876373fe..c3a515b9de 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -615,14 +615,12 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) }) - test("calls clearTask when there is no parent task", async () => { + test("calls clearTask (delegation handled via metadata)", async () => { // Setup a single task without parent const mockCline = new Task(defaultTaskOptions) - // No need to set parentTask - it's undefined by default // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) - const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // Add task to stack @@ -634,25 +632,22 @@ describe("ClineProvider", () => { // Trigger clearTask message await messageHandler({ type: "clearTask" }) - // Verify clearTask was called (not finishSubTask) + // Verify clearTask was called expect(clearTaskSpy).toHaveBeenCalled() - expect(finishSubTaskSpy).not.toHaveBeenCalled() expect(postStateToWebviewSpy).toHaveBeenCalled() }) - test("calls finishSubTask when there is a parent task", async () => { + test("calls clearTask even with parent task (delegation via metadata)", async () => { // Setup parent and child tasks const parentTask = new Task(defaultTaskOptions) const childTask = new Task(defaultTaskOptions) - // Set up parent-child relationship by setting the parentTask property - // The mock allows us to set properties directly + // Set up parent-child relationship ;(childTask as any).parentTask = parentTask ;(childTask as any).rootTask = parentTask // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) - const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // Add both tasks to stack (parent first, then child) @@ -665,9 +660,8 @@ describe("ClineProvider", () => { // Trigger clearTask message await messageHandler({ type: "clearTask" }) - // Verify finishSubTask was called (not clearTask) - expect(finishSubTaskSpy).toHaveBeenCalledWith(expect.stringContaining("canceled")) - expect(clearTaskSpy).not.toHaveBeenCalled() + // Verify clearTask was called (delegation happens via metadata, not finishSubTask) + expect(clearTaskSpy).toHaveBeenCalled() expect(postStateToWebviewSpy).toHaveBeenCalled() }) @@ -676,7 +670,6 @@ describe("ClineProvider", () => { // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) - const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // Get the message handler @@ -687,21 +680,17 @@ describe("ClineProvider", () => { // When there's no current task, clearTask is still called (it handles the no-task case internally) expect(clearTaskSpy).toHaveBeenCalled() - expect(finishSubTaskSpy).not.toHaveBeenCalled() - // State should still be posted expect(postStateToWebviewSpy).toHaveBeenCalled() }) - test("correctly identifies subtask scenario for issue #4602", async () => { - // This test specifically validates the fix for issue #4602 - // where canceling during API retry was incorrectly treating a single task as a subtask + test("correctly identifies task scenario for issue #4602", async () => { + // This test validates the fix for issue #4602 + // where canceling during API retry correctly uses clearTask const mockCline = new Task(defaultTaskOptions) - // No parent task by default - no need to explicitly set // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) - const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) // Add only one task to stack await provider.addClineToStack(mockCline) @@ -715,9 +704,8 @@ describe("ClineProvider", () => { // Trigger clearTask message (simulating cancel during API retry) await messageHandler({ type: "clearTask" }) - // The fix ensures clearTask is called, not finishSubTask + // clearTask should be called (delegation handled via metadata) expect(clearTaskSpy).toHaveBeenCalled() - expect(finishSubTaskSpy).not.toHaveBeenCalled() }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 1a0afa1fa3..986dd40470 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -634,19 +634,10 @@ export const webviewMessageHandler = async ( } break case "clearTask": - // Clear task resets the current session and allows for a new task - // to be started, if this session is a subtask - it allows the - // parent task to be resumed. - // Check if the current task actually has a parent task. - const currentTask = provider.getCurrentTask() - - if (currentTask && currentTask.parentTask) { - await provider.finishSubTask(t("common:tasks.canceled")) - } else { - // Regular task - just clear it - await provider.clearTask() - } - + // Clear task resets the current session. Delegation flows are + // handled via metadata; parent resumption occurs through + // reopenParentFromDelegation, not via finishSubTask. + await provider.clearTask() await provider.postStateToWebview() break case "didShowAnnouncement": diff --git a/src/extension/api.ts b/src/extension/api.ts index 8f642777f6..a9e11b4dbe 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -171,8 +171,9 @@ export class API extends EventEmitter implements RooCodeAPI { return this.sidebarProvider.getCurrentTaskStack() } - public async clearCurrentTask(lastMessage?: string) { - await this.sidebarProvider.finishSubTask(lastMessage ?? "") + public async clearCurrentTask(_lastMessage?: string) { + // Legacy finishSubTask removed; clear current by closing active task instance. + await this.sidebarProvider.removeClineFromStack() await this.sidebarProvider.postStateToWebview() } @@ -270,6 +271,18 @@ export class API extends EventEmitter implements RooCodeAPI { this.emit(RooCodeEventName.TaskSpawned, task.taskId, childTaskId) }) + task.on(RooCodeEventName.TaskDelegated as any, (childTaskId: string) => { + ;(this.emit as any)(RooCodeEventName.TaskDelegated, task.taskId, childTaskId) + }) + + task.on(RooCodeEventName.TaskDelegationCompleted as any, (childTaskId: string, summary: string) => { + ;(this.emit as any)(RooCodeEventName.TaskDelegationCompleted, task.taskId, childTaskId, summary) + }) + + task.on(RooCodeEventName.TaskDelegationResumed as any, (childTaskId: string) => { + ;(this.emit as any)(RooCodeEventName.TaskDelegationResumed, task.taskId, childTaskId) + }) + // Task Execution task.on(RooCodeEventName.Message, async (message) => { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index e09cdc557a..5f5e9c281b 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -355,8 +355,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction msg.ask === "completion_result" || msg.say === "completion_result", + ) + if (isCompletedSubtask) { + setPrimaryButtonText(t("chat:startNewTask.title")) + setSecondaryButtonText(undefined) + } else { + setPrimaryButtonText(t("chat:resumeTask.title")) + setSecondaryButtonText(t("chat:terminate.title")) + } setDidClickCancel(false) // special case where we reset the cancel button state break case "resume_completed_task": @@ -400,6 +414,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (clineAsk === "resume_task" && currentTaskItem?.parentTaskId) { + const hasCompletionResult = messages.some( + (msg) => msg.ask === "completion_result" || msg.say === "completion_result", + ) + if (hasCompletionResult) { + setPrimaryButtonText(t("chat:startNewTask.title")) + setSecondaryButtonText(undefined) + } + } + }, [clineAsk, currentTaskItem?.parentTaskId, messages, t]) + useEffect(() => { if (messages.length === 0) { setSendingDisabled(false) @@ -638,7 +665,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { @@ -655,6 +681,33 @@ const ChatViewComponent: React.ForwardRefRenderFunction msg.ask === "completion_result" || msg.say === "completion_result", + ) + if (isCompletedSubtaskForClick) { + startNewTask() + } else { + // Only send text/images if they exist + if (trimmedInput || (images && images.length > 0)) { + vscode.postMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", + text: trimmedInput, + images: images, + }) + // Clear input state after sending + setInputValue("") + setSelectedImages([]) + } else { + vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) + } + } + break case "completion_result": case "resume_completed_task": // Waiting for feedback, but we can just present a new task button @@ -669,7 +722,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction @@ -80,6 +81,7 @@ const TaskItem = ({ {...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}> {item.highlight ? undefined : item.task} + la configuració del punt de control.", "init_checkpoint_fail_long_time": "La inicialització del punt de control ha trigat més de {{timeout}} segons, per això els punts de control estan desactivats per a aquesta tasca. Pots desactivar els punts de control o augmentar el temps d'espera a la configuració del punt de control.", "attempt_completion_tool_failed": "No es pot executar attempt_completion perquè una crida d'eina anterior ha fallat en aquest torn. Si us plau, resol el problema de l'eina abans d'intentar completar." + }, + "tasks": { + "delegated": "Delegat a subtasca", + "delegated_to": "Delegat a la tasca {{childId}}", + "delegation_completed": "Subtasca completada, reprenent la tasca principal", + "awaiting_child": "Esperant la tasca filla {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index a6a3c683b4..51d2fbe6c7 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Du hast {{timeout}} Sekunden auf die Initialisierung des Checkpoints gewartet. Wenn du die Checkpoint-Funktion nicht brauchst, kannst du sie in den Checkpoint-Einstellungen ausschalten.", "init_checkpoint_fail_long_time": "Die Initialisierung des Checkpoints dauert länger als {{timeout}} Sekunden, deshalb sind Checkpoints für diese Aufgabe deaktiviert. Du kannst Checkpoints ausschalten oder die Wartezeit in den Checkpoint-Einstellungen verlängern.", "attempt_completion_tool_failed": "Du kannst attempt_completion nicht ausführen, weil ein vorheriger Tool-Aufruf in diesem Durchgang fehlgeschlagen ist. Behebe den Tool-Fehler, bevor du versuchst, abzuschließen." + }, + "tasks": { + "delegated": "An Unteraufgabe delegiert", + "delegated_to": "An Aufgabe {{childId}} delegiert", + "delegation_completed": "Unteraufgabe abgeschlossen, übergeordnete Aufgabe wird fortgesetzt", + "awaiting_child": "Warte auf Unteraufgabe {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 77b014eae2..da86e671d7 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Waited {{timeout}} seconds for checkpoint initialization. If you don't need the checkpoint feature, please turn it off in the checkpoint settings.", "init_checkpoint_fail_long_time": "Checkpoint initialization has taken more than {{timeout}} seconds, so checkpoints are disabled for this task. You can disable checkpoints or extend the waiting time in the checkpoint settings.", "attempt_completion_tool_failed": "Cannot execute attempt_completion because a previous tool call failed in this turn. Please address the tool failure before attempting completion." + }, + "tasks": { + "delegated": "Delegated to subtask", + "delegated_to": "Delegated to task {{childId}}", + "delegation_completed": "Subtask completed, resuming parent", + "awaiting_child": "Awaiting child task {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 8efa783486..27c0bc016d 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Has esperado {{timeout}} segundos para la inicialización del punto de control. Si no necesitas esta función, desactívala en la configuración del punto de control.", "init_checkpoint_fail_long_time": "La inicialización del punto de control ha tardado más de {{timeout}} segundos, por lo que los puntos de control están desactivados para esta tarea. Puedes desactivar los puntos de control o aumentar el tiempo de espera en la configuración del punto de control.", "attempt_completion_tool_failed": "No se puede ejecutar attempt_completion porque una llamada de herramienta anterior falló en este turno. Por favor, resuelve el error de la herramienta antes de intentar completar." + }, + "tasks": { + "delegated": "Delegado a subtarea", + "delegated_to": "Delegado a la tarea {{childId}}", + "delegation_completed": "Subtarea completada, reanudando tarea principal", + "awaiting_child": "Esperando tarea secundaria {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index 70c8ac9511..1e7dc5f72f 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Tu as attendu {{timeout}} secondes pour l'initialisation du checkpoint. Si tu n'as pas besoin de cette fonction, désactive-la dans les paramètres du checkpoint.", "init_checkpoint_fail_long_time": "L'initialisation du checkpoint a pris plus de {{timeout}} secondes, donc les checkpoints sont désactivés pour cette tâche. Tu peux désactiver les checkpoints ou prolonger le délai dans les paramètres du checkpoint.", "attempt_completion_tool_failed": "Tu ne peux pas exécuter attempt_completion car un appel d'outil précédent a échoué dans ce tour. Résous l'échec de l'outil avant de tenter de terminer." + }, + "tasks": { + "delegated": "Délégué à la sous-tâche", + "delegated_to": "Délégué à la tâche {{childId}}", + "delegation_completed": "Sous-tâche terminée, reprise de la tâche parent", + "awaiting_child": "En attente de la tâche enfant {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 4b97bf73f8..b42cc1eb84 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "तुमने {{timeout}} सेकंड तक चेकपॉइंट इनिशियलाइज़ेशन का इंतजार किया। अगर तुम्हें यह फ़ीचर नहीं चाहिए, तो चेकपॉइंट सेटिंग्स में बंद कर दो।", "init_checkpoint_fail_long_time": "चेकपॉइंट इनिशियलाइज़ेशन {{timeout}} सेकंड से ज़्यादा समय ले रहा है, इसलिए इस कार्य के लिए चेकपॉइंट बंद कर दिए गए हैं। तुम चेकपॉइंट बंद कर सकते हो या चेकपॉइंट सेटिंग्स में इंतजार का समय बढ़ा सकते हो।", "attempt_completion_tool_failed": "attempt_completion निष्पादित नहीं किया जा सकता क्योंकि इस टर्न में पिछली टूल कॉल विफल रही है। कृपया पूरा करने का प्रयास करने से पहले टूल विफलता को ठीक करें।" + }, + "tasks": { + "delegated": "उप-कार्य को सौंपा गया", + "delegated_to": "कार्य {{childId}} को सौंपा गया", + "delegation_completed": "उप-कार्य पूर्ण, मुख्य कार्य फिर से शुरू हो रहा है", + "awaiting_child": "चाइल्ड कार्य {{childId}} की प्रतीक्षा में" } } diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index 27c03c4b02..041530d09d 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Kamu sudah menunggu {{timeout}} detik untuk inisialisasi checkpoint. Kalau tidak butuh fitur ini, matikan saja di pengaturan checkpoint.", "init_checkpoint_fail_long_time": "Inisialisasi checkpoint sudah lebih dari {{timeout}} detik, jadi checkpoint dinonaktifkan untuk tugas ini. Kamu bisa mematikan checkpoint atau menambah waktu tunggu di pengaturan checkpoint.", "attempt_completion_tool_failed": "Tidak dapat mengeksekusi attempt_completion karena panggilan alat sebelumnya gagal dalam giliran ini. Harap atasi kegagalan alat sebelum mencoba menyelesaikan." + }, + "tasks": { + "delegated": "Didelegasikan ke subtugas", + "delegated_to": "Didelegasikan ke tugas {{childId}}", + "delegation_completed": "Subtugas selesai, melanjutkan tugas induk", + "awaiting_child": "Menunggu tugas anak {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index 0a1c7396dc..5b732ad6a1 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Hai aspettato {{timeout}} secondi per l'inizializzazione del checkpoint. Se non ti serve questa funzione, disattivala nelle impostazioni del checkpoint.", "init_checkpoint_fail_long_time": "L'inizializzazione del checkpoint ha impiegato più di {{timeout}} secondi, quindi i checkpoint sono disabilitati per questa attività. Puoi disattivare i checkpoint o aumentare il tempo di attesa nelle impostazioni del checkpoint.", "attempt_completion_tool_failed": "Non puoi eseguire attempt_completion perché una chiamata di strumento precedente è fallita in questo turno. Risolvi il problema dello strumento prima di tentare di completare." + }, + "tasks": { + "delegated": "Delegato a sottoattività", + "delegated_to": "Delegato all'attività {{childId}}", + "delegation_completed": "Sottoattività completata, ripresa attività padre", + "awaiting_child": "In attesa dell'attività figlia {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index 12a8cd0c7e..6c5de092a1 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "{{timeout}} 秒間チェックポイントの初期化を待機しました。チェックポイント機能が不要な場合は、チェックポイント設定でオフにしてください。", "init_checkpoint_fail_long_time": "チェックポイントの初期化が {{timeout}} 秒以上かかったため、このタスクではチェックポイントが無効化されました。チェックポイントをオフにするか、チェックポイント設定で待機時間を延長できます。", "attempt_completion_tool_failed": "前回のツール呼び出しがこのターンで失敗したため、attempt_completionを実行できません。完了を試みる前にツールの失敗に対処してください。" + }, + "tasks": { + "delegated": "サブタスクに委任", + "delegated_to": "タスク{{childId}}に委任", + "delegation_completed": "サブタスク完了、親タスクを再開", + "awaiting_child": "子タスク{{childId}}を待機中" } } diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index c5c8010d07..e97581a1a9 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "{{timeout}}초 동안 체크포인트 초기화를 기다렸어. 체크포인트 기능이 필요 없다면 체크포인트 설정에서 꺼 줘.", "init_checkpoint_fail_long_time": "체크포인트 초기화가 {{timeout}}초 이상 걸려서 이 작업에 대해 체크포인트가 꺼졌어. 체크포인트를 끄거나 체크포인트 설정에서 대기 시간을 늘릴 수 있어.", "attempt_completion_tool_failed": "이전 도구 호출이 이 턴에서 실패했기 때문에 attempt_completion을 실행할 수 없습니다. 완료를 시도하기 전에 도구 실패를 해결하세요." + }, + "tasks": { + "delegated": "하위 작업에 위임됨", + "delegated_to": "작업 {{childId}}에 위임됨", + "delegation_completed": "하위 작업 완료, 상위 작업 재개", + "awaiting_child": "하위 작업 {{childId}} 대기 중" } } diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index a030a8a286..a27d08ba49 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Je hebt {{timeout}} seconden gewacht op de initialisatie van de checkpoint. Als je deze functie niet nodig hebt, schakel hem dan uit in de checkpoint-instellingen.", "init_checkpoint_fail_long_time": "De initialisatie van de checkpoint duurde meer dan {{timeout}} seconden, dus checkpoints zijn uitgeschakeld voor deze taak. Je kunt checkpoints uitschakelen of de wachttijd in de checkpoint-instellingen verhogen.", "attempt_completion_tool_failed": "Je kunt attempt_completion niet uitvoeren omdat een eerdere tool-aanroep in deze beurt is mislukt. Los het tool-probleem op voordat je probeert te voltooien." + }, + "tasks": { + "delegated": "Gedelegeerd naar subtaak", + "delegated_to": "Gedelegeerd naar taak {{childId}}", + "delegation_completed": "Subtaak voltooid, hoofdtaak wordt hervat", + "awaiting_child": "Wachten op kindtaak {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index 0eeb2ef15e..c7253c28e6 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Czekałeś {{timeout}} sekund na inicjalizację punktu kontrolnego. Jeśli nie potrzebujesz tej funkcji, wyłącz ją w ustawieniach punktu kontrolnego.", "init_checkpoint_fail_long_time": "Inicjalizacja punktu kontrolnego trwała ponad {{timeout}} sekund, więc punkty kontrolne zostały wyłączone dla tego zadania. Możesz wyłączyć punkty kontrolne lub wydłużyć czas oczekiwania w ustawieniach punktu kontrolnego.", "attempt_completion_tool_failed": "Nie można wykonać attempt_completion, ponieważ poprzednie wywołanie narzędzia nie powiodło się w tym cyklu. Rozwiąż błąd narzędzia przed próbą zakończenia." + }, + "tasks": { + "delegated": "Przekazano do podzadania", + "delegated_to": "Przekazano do zadania {{childId}}", + "delegation_completed": "Podzadanie ukończone, wznowienie zadania nadrzędnego", + "awaiting_child": "Oczekiwanie na zadanie podrzędne {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index c7519fa24f..a0abd99f20 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Você esperou {{timeout}} segundos para inicializar o checkpoint. Se não precisa dessa função, desative nas configurações do checkpoint.", "init_checkpoint_fail_long_time": "A inicialização do checkpoint levou mais de {{timeout}} segundos, então os checkpoints foram desativados para esta tarefa. Você pode desativar os checkpoints ou aumentar o tempo de espera nas configurações do checkpoint.", "attempt_completion_tool_failed": "Não é possível executar attempt_completion porque uma chamada de ferramenta anterior falhou neste turno. Por favor, resolva a falha da ferramenta antes de tentar concluir." + }, + "tasks": { + "delegated": "Delegado para subtarefa", + "delegated_to": "Delegado para tarefa {{childId}}", + "delegation_completed": "Subtarefa concluída, retomando tarefa pai", + "awaiting_child": "Aguardando tarefa filha {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index 7e98bf1247..2ac643953e 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Ожидание инициализации контрольной точки заняло {{timeout}} секунд. Если тебе не нужна эта функция, отключи её в настройках контрольных точек.", "init_checkpoint_fail_long_time": "Инициализация контрольной точки заняла более {{timeout}} секунд, поэтому контрольные точки отключены для этой задачи. Ты можешь отключить контрольные точки или увеличить время ожидания в настройках контрольных точек.", "attempt_completion_tool_failed": "Невозможно выполнить attempt_completion, потому что предыдущий вызов инструмента не удался в этом повороте. Пожалуйста, устрани сбой инструмента перед попыткой завершения." + }, + "tasks": { + "delegated": "Делегировано подзадаче", + "delegated_to": "Делегировано задаче {{childId}}", + "delegation_completed": "Подзадача завершена, возобновление родительской задачи", + "awaiting_child": "Ожидание дочерней задачи {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index fbcdb2179a..ccd6812704 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "{{timeout}} saniye boyunca kontrol noktası başlatılması beklendi. Bu özelliğe ihtiyacın yoksa kontrol noktası ayarlarından kapatabilirsin.", "init_checkpoint_fail_long_time": "Kontrol noktası başlatılması {{timeout}} saniyeden fazla sürdü, bu yüzden bu görev için kontrol noktaları devre dışı bırakıldı. Kontrol noktalarını kapatabilir veya kontrol noktası ayarlarından bekleme süresini artırabilirsin.", "attempt_completion_tool_failed": "attempt_completion çalıştırılamıyor çünkü bu turda önceki bir araç çağrısı başarısız oldu. Lütfen tamamlamayı denemeden önce araç hatasını giderin." + }, + "tasks": { + "delegated": "Alt göreve devredildi", + "delegated_to": "{{childId}} görevine devredildi", + "delegation_completed": "Alt görev tamamlandı, üst görev devam ediyor", + "awaiting_child": "{{childId}} alt görevi bekleniyor" } } diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index 8ccf58dac7..85f14780e8 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Bạn đã chờ {{timeout}} giây để khởi tạo điểm kiểm tra. Nếu không cần chức năng này, hãy tắt nó trong cài đặt điểm kiểm tra.", "init_checkpoint_fail_long_time": "Khởi tạo điểm kiểm tra mất hơn {{timeout}} giây, vì vậy các điểm kiểm tra đã bị vô hiệu hóa cho tác vụ này. Bạn có thể tắt các điểm kiểm tra hoặc tăng thời gian chờ trong cài đặt điểm kiểm tra.", "attempt_completion_tool_failed": "Không thể thực thi attempt_completion vì một lệnh gọi công cụ trước đó đã thất bại trong lượt này. Vui lòng giải quyết lỗi công cụ trước khi cố gắng hoàn thành." + }, + "tasks": { + "delegated": "Ủy quyền cho nhiệm vụ con", + "delegated_to": "Ủy quyền cho nhiệm vụ {{childId}}", + "delegation_completed": "Nhiệm vụ con hoàn thành, tiếp tục nhiệm vụ cha", + "awaiting_child": "Đang chờ nhiệm vụ con {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index 53da4313be..f762a3044e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "初始化存档点已等待 {{timeout}} 秒。如果你不需要存档点功能,请在存档点设置中关闭。", "init_checkpoint_fail_long_time": "存档点初始化已超过 {{timeout}} 秒,因此本任务已禁用存档点。你可以关闭存档点或在存档点设置中延长等待时间。", "attempt_completion_tool_failed": "无法执行 attempt_completion,因为本轮中先前的工具调用失败了。请在尝试完成前解决工具失败问题。" + }, + "tasks": { + "delegated": "已委托给子任务", + "delegated_to": "已委托给任务 {{childId}}", + "delegation_completed": "子任务已完成,恢复父任务", + "awaiting_child": "等待子任务 {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 9b2a69c77d..3dd64c1c4c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "初始化存檔點已等待 {{timeout}} 秒。如果你不需要存檔點功能,請在存檔點設定中關閉。", "init_checkpoint_fail_long_time": "存檔點初始化已超過 {{timeout}} 秒,因此此工作已停用存檔點。你可以關閉存檔點或在存檔點設定中延長等待時間。", "attempt_completion_tool_failed": "無法執行 attempt_completion,因為本輪中先前的工具呼叫失敗了。請在嘗試完成前解決工具失敗問題。" + }, + "tasks": { + "delegated": "已委派給子工作", + "delegated_to": "已委派給工作 {{childId}}", + "delegation_completed": "子工作已完成,繼續父工作", + "awaiting_child": "等待子工作 {{childId}}" } }