From 1e6ac27950f0334d733de30897bc8816304f42fe Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 12 Dec 2025 16:08:45 +0000 Subject: [PATCH] feat: implement conversation forking feature (#10049) - Add forkedFromTaskId field to HistoryItem schema - Implement forkCurrentTask() method in ClineProvider with atomic copy-then-switch - Add forkTask message handler in webviewMessageHandler - Add fork button to TaskActions UI component with GitBranch icon - Add "Forked from..." indicator in TaskHeader with link to parent task - Add comprehensive tests for fork functionality - Preserve all persisted state (UI/API history, tokens, cost, metadata) - Reset delegation fields in forked task to start fresh - Include error handling and partial fork cleanup on failure --- packages/types/src/history.ts | 1 + src/core/webview/ClineProvider.ts | 105 +++++ .../__tests__/ClineProvider.fork.spec.ts | 387 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 9 + src/shared/ExtensionMessage.ts | 3 + src/shared/WebviewMessage.ts | 1 + .../src/components/chat/TaskActions.tsx | 9 +- webview-ui/src/components/chat/TaskHeader.tsx | 31 +- .../chat/__tests__/TaskActions.spec.tsx | 31 ++ webview-ui/src/i18n/locales/en/chat.json | 3 + 10 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 src/core/webview/__tests__/ClineProvider.fork.spec.ts diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index e5b6f5418f..07a37edcb7 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -25,6 +25,7 @@ export const historyItemSchema = z.object({ 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 + forkedFromTaskId: z.string().optional(), // ID of the task this was forked from }) export type HistoryItem = z.infer diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0beb218969..f6837ebec7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1674,6 +1674,111 @@ export class ClineProvider await this.postStateToWebview() } + /** + * Fork the current task, creating an exact copy with all conversation history and state. + * This enables exploring multiple task paths without reloading context. + * + * Implements atomic copy-then-switch for safety: + * 1. Creates new task directory and copies all data + * 2. Creates new history item with forkedFromTaskId reference + * 3. Only switches to the fork after all data is persisted + * + * @returns The new forked task ID + */ + async forkCurrentTask(): Promise { + const currentTask = this.getCurrentTask() + if (!currentTask) { + throw new Error("No current task to fork") + } + + const currentTaskId = currentTask.taskId + const { historyItem } = await this.getTaskWithId(currentTaskId) + + // Generate new unique task ID + const newTaskId = Date.now().toString() + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + + try { + // ATOMIC COPY PHASE: Copy all task data before switching + + // 1. Read current task's UI messages + const uiMessages = await readTaskMessages({ + taskId: currentTaskId, + globalStoragePath, + }) + + // 2. Read current task's API messages + const apiMessages = await readApiMessages({ + taskId: currentTaskId, + globalStoragePath, + }) + + // 3. Create new task directory and save messages atomically + await saveTaskMessages({ + messages: uiMessages, + taskId: newTaskId, + globalStoragePath, + }) + + await saveApiMessages({ + messages: apiMessages, + taskId: newTaskId, + globalStoragePath, + }) + + // 4. Create new history item with all metadata preserved + const newHistoryItem: HistoryItem = { + ...historyItem, + id: newTaskId, + ts: Date.now(), + forkedFromTaskId: currentTaskId, + // Reset delegation fields for the fork + status: undefined, + delegatedToId: undefined, + awaitingChildId: undefined, + completedByChildId: undefined, + completionResultSummary: undefined, + // Preserve parent/root relationships + parentTaskId: historyItem.parentTaskId, + rootTaskId: historyItem.rootTaskId, + // Note: childIds not copied - fork starts fresh without children + } + + // 5. Add new history item to state + await this.updateTaskHistory(newHistoryItem) + + this.log(`[forkCurrentTask] Successfully forked task ${currentTaskId} to ${newTaskId}`) + + // SWITCH PHASE: Only after all data is safely persisted + // 6. Switch to the newly forked task + await this.createTaskWithHistoryItem(newHistoryItem) + + // 7. Post success message to webview + await this.postMessageToWebview({ + type: "taskForked", + taskId: newTaskId, + forkedFromTaskId: currentTaskId, + }) + + return newTaskId + } catch (error) { + // Clean up partial fork on error + try { + await this.deleteTaskWithId(newTaskId) + } catch (cleanupError) { + this.log( + `[forkCurrentTask] Failed to clean up partial fork ${newTaskId}: ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }`, + ) + } + + const errorMessage = error instanceof Error ? error.message : String(error) + this.log(`[forkCurrentTask] Failed to fork task ${currentTaskId}: ${errorMessage}`) + throw new Error(`Failed to fork task: ${errorMessage}`) + } + } + async refreshWorkspace() { this.currentWorkspacePath = getWorkspacePath() await this.postStateToWebview() diff --git a/src/core/webview/__tests__/ClineProvider.fork.spec.ts b/src/core/webview/__tests__/ClineProvider.fork.spec.ts new file mode 100644 index 0000000000..ee808a2f92 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.fork.spec.ts @@ -0,0 +1,387 @@ +// npx vitest src/core/webview/__tests__/ClineProvider.fork.spec.ts + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" +import { ClineProvider } from "../ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" +import type { HistoryItem } from "@roo-code/types" +import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages } from "../../task-persistence" +import { TelemetryService } from "@roo-code/telemetry" + +vi.mock("vscode", () => ({ + Uri: { + file: (path: string) => ({ fsPath: path }), + joinPath: vi.fn(), + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + workspaceFolders: [], + }, + env: { + uriScheme: "vscode", + language: "en", + }, +})) + +vi.mock("../../task-persistence") +vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + getAllTerminals: vi.fn().mockReturnValue([]), + }, +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(() => ({ + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + })), +})) + +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation((options: any) => ({ + taskId: options?.historyItem?.id || "test-task-id", + emit: vi.fn(), + abortTask: vi.fn(), + })), +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(false), + }, + BridgeOrchestrator: { + isEnabled: vi.fn().mockReturnValue(false), + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +describe("ClineProvider.forkCurrentTask()", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockContextProxy: ContextProxy + + beforeEach(() => { + // Initialize TelemetryService + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + // Create mock context + mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn(), + setKeysForSync: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + onDidChange: vi.fn(), + }, + subscriptions: [], + extensionUri: vscode.Uri.file("/test/extension"), + extensionPath: "/test/extension", + globalStorageUri: vscode.Uri.file("/test/storage"), + storageUri: vscode.Uri.file("/test/workspace-storage"), + logUri: vscode.Uri.file("/test/logs"), + extensionMode: vscode.ExtensionMode.Test, + } as any + + mockOutputChannel = { + appendLine: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } as any + + mockContextProxy = new ContextProxy(mockContext) + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", mockContextProxy) + + // Mock file system operations + vi.mocked(readTaskMessages).mockResolvedValue([ + { type: "say", say: "text", text: "Hello", ts: 1000 }, + { type: "ask", ask: "completion_result", text: "Done", ts: 2000 }, + ] as any) + + vi.mocked(readApiMessages).mockResolvedValue([ + { role: "user", content: [{ type: "text", text: "Hello" }], ts: 1000 }, + { role: "assistant", content: [{ type: "text", text: "Response" }], ts: 1500 }, + ] as any) + + vi.mocked(saveTaskMessages).mockResolvedValue() + vi.mocked(saveApiMessages).mockResolvedValue() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("should fork current task and create an exact copy", async () => { + // Setup: Create a mock current task + const originalHistoryItem: HistoryItem = { + id: "task-123", + number: 1, + ts: 1000, + task: "Original task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + mode: "code", + workspace: "/test/workspace", + } + + // Mock getTaskWithId to return the original task + vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({ + historyItem: originalHistoryItem, + taskDirPath: "/test/storage/tasks/task-123", + }) + + // Mock getCurrentTask to return a task + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ + taskId: "task-123", + } as any) + + // Mock updateTaskHistory + vi.spyOn(provider as any, "updateTaskHistory").mockResolvedValue([]) + + // Mock createTaskWithHistoryItem + vi.spyOn(provider as any, "createTaskWithHistoryItem").mockResolvedValue({ + taskId: "new-task-id", + }) + + // Mock postMessageToWebview + vi.spyOn(provider as any, "postMessageToWebview").mockResolvedValue(undefined) + + // Execute fork + const newTaskId = await provider.forkCurrentTask() + + // Assertions + expect(newTaskId).toBeDefined() + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-123", + globalStoragePath: "/test/storage", + }) + expect(readApiMessages).toHaveBeenCalledWith({ + taskId: "task-123", + globalStoragePath: "/test/storage", + }) + + // Verify messages were saved with new task ID + expect(saveTaskMessages).toHaveBeenCalledWith({ + messages: expect.any(Array), + taskId: expect.stringMatching(/^\d+$/), // New timestamp-based ID + globalStoragePath: "/test/storage", + }) + + expect(saveApiMessages).toHaveBeenCalledWith({ + messages: expect.any(Array), + taskId: expect.stringMatching(/^\d+$/), // New timestamp-based ID + globalStoragePath: "/test/storage", + }) + + // Verify new history item was created with forkedFromTaskId + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + forkedFromTaskId: "task-123", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + mode: "code", + }), + ) + + // Verify switch to new task + expect(provider.createTaskWithHistoryItem).toHaveBeenCalledWith( + expect.objectContaining({ + forkedFromTaskId: "task-123", + }), + ) + + // Verify success message posted + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskForked", + taskId: newTaskId, + forkedFromTaskId: "task-123", + }) + }) + + it("should reset delegation fields in forked task", async () => { + // Setup: Task with delegation metadata + const delegatedHistoryItem: HistoryItem = { + id: "task-456", + number: 1, + ts: 1000, + task: "Delegated task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + status: "delegated", + delegatedToId: "child-task-1", + awaitingChildId: "child-task-1", + childIds: ["child-task-1"], + completedByChildId: "child-task-1", + completionResultSummary: "Child completed", + } + + vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({ + historyItem: delegatedHistoryItem, + }) + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ taskId: "task-456" } as any) + vi.spyOn(provider as any, "updateTaskHistory").mockResolvedValue([]) + vi.spyOn(provider as any, "createTaskWithHistoryItem").mockResolvedValue({}) + vi.spyOn(provider as any, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.forkCurrentTask() + + // Verify delegation fields are reset + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + forkedFromTaskId: "task-456", + status: undefined, + delegatedToId: undefined, + awaitingChildId: undefined, + completedByChildId: undefined, + completionResultSummary: undefined, + // childIds should not be present (not copied) + }), + ) + }) + + it("should preserve parent/root relationships", async () => { + // Setup: Task with parent/root relationships + const subtaskHistoryItem: HistoryItem = { + id: "task-789", + number: 2, + ts: 1000, + task: "Subtask", + tokensIn: 50, + tokensOut: 25, + totalCost: 0.02, + parentTaskId: "parent-task", + rootTaskId: "root-task", + } + + vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({ + historyItem: subtaskHistoryItem, + }) + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ taskId: "task-789" } as any) + vi.spyOn(provider as any, "updateTaskHistory").mockResolvedValue([]) + vi.spyOn(provider as any, "createTaskWithHistoryItem").mockResolvedValue({}) + vi.spyOn(provider as any, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.forkCurrentTask() + + // Verify parent/root relationships are preserved + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + parentTaskId: "parent-task", + rootTaskId: "root-task", + forkedFromTaskId: "task-789", + }), + ) + }) + + it("should throw error when no current task exists", async () => { + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + await expect(provider.forkCurrentTask()).rejects.toThrow("No current task to fork") + }) + + it("should clean up partial fork on error", async () => { + const originalHistoryItem: HistoryItem = { + id: "task-error", + number: 1, + ts: 1000, + task: "Error task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + } + + vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({ + historyItem: originalHistoryItem, + }) + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ taskId: "task-error" } as any) + + // Make saveApiMessages fail + vi.mocked(saveApiMessages).mockRejectedValue(new Error("Disk full")) + + // Mock deleteTaskWithId for cleanup + vi.spyOn(provider as any, "deleteTaskWithId").mockResolvedValue(undefined) + + await expect(provider.forkCurrentTask()).rejects.toThrow("Failed to fork task: Disk full") + + // Verify cleanup was attempted + expect(provider.deleteTaskWithId).toHaveBeenCalled() + }) + + it("should copy all task state including tokens and cost", async () => { + const fullStateHistoryItem: HistoryItem = { + id: "task-full", + number: 1, + ts: 1000, + task: "Full state task", + tokensIn: 5000, + tokensOut: 3000, + cacheWrites: 2000, + cacheReads: 1000, + totalCost: 0.15, + size: 1024, + workspace: "/test/workspace", + mode: "architect", + } + + vi.spyOn(provider as any, "getTaskWithId").mockResolvedValue({ + historyItem: fullStateHistoryItem, + }) + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ taskId: "task-full" } as any) + vi.spyOn(provider as any, "updateTaskHistory").mockResolvedValue([]) + vi.spyOn(provider as any, "createTaskWithHistoryItem").mockResolvedValue({}) + vi.spyOn(provider as any, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.forkCurrentTask() + + // Verify all state is preserved + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + tokensIn: 5000, + tokensOut: 3000, + cacheWrites: 2000, + cacheReads: 1000, + totalCost: 0.15, + size: 1024, + workspace: "/test/workspace", + mode: "architect", + forkedFromTaskId: "task-full", + }), + ) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c08504c576..79dc1b7136 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -708,6 +708,15 @@ export const webviewMessageHandler = async ( case "deleteTaskWithId": provider.deleteTaskWithId(message.text!) break + case "forkTask": + try { + await provider.forkCurrentTask() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Failed to fork task: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to fork conversation: ${errorMessage}`) + } + break case "deleteMultipleTasksWithIds": { const ids = message.ids diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 5d40f2ef09..d50b678e37 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -132,6 +132,7 @@ export interface ExtensionMessage { | "interactionRequired" | "browserSessionUpdate" | "browserSessionNavigate" + | "taskForked" text?: string payload?: any // Add a generic payload for now, can refine later // Checkpoint warning message @@ -217,6 +218,8 @@ export interface ExtensionMessage { browserSessionMessages?: ClineMessage[] // For browser session panel updates isBrowserSessionActive?: boolean // For browser session panel updates stepIndex?: number // For browserSessionNavigate: the target step index to display + taskId?: string // For taskForked: the new forked task ID + forkedFromTaskId?: string // For taskForked: the original task ID } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index eb109166c8..26f4e273fb 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -51,6 +51,7 @@ export interface WebviewMessage { | "showTaskWithId" | "deleteTaskWithId" | "exportTaskWithId" + | "forkTask" | "importSettings" | "exportSettings" | "resetState" diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index 74575ddc28..dfdd1648c1 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -10,7 +10,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { ShareButton } from "./ShareButton" import { CloudTaskButton } from "./CloudTaskButton" -import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react" +import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon, GitBranchIcon } from "lucide-react" import { LucideIconButton } from "./LucideIconButton" interface TaskActionsProps { @@ -32,6 +32,13 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { onClick={() => vscode.postMessage({ type: "exportCurrentTask" })} /> + vscode.postMessage({ type: "forkTask" })} + /> + {item?.task && ( { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState() + const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive, taskHistory } = + useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) @@ -109,6 +111,14 @@ const TaskHeader = ({ const showBrowserGlobe = browserSessionStartIndex !== -1 || !!isBrowserSessionActive + // Find parent task if this is a forked task + const parentTask = useMemo(() => { + if (!currentTaskItem?.forkedFromTaskId || !taskHistory) { + return null + } + return taskHistory.find((item) => item.id === currentTaskItem.forkedFromTaskId) + }, [currentTaskItem?.forkedFromTaskId, taskHistory]) + const condenseButton = ( )} + {/* Forked from indicator */} + {parentTask && ( +
+
+ + {t("chat:task.forkedFrom")} + + + +
+
+ )}
({ const translations: Record = { "chat:task.share": "Share task", "chat:task.export": "Export task history", + "chat:task.fork": "Fork conversation (create copy at this point)", "chat:task.delete": "Delete Task (Shift + Click to skip confirmation)", "chat:task.shareWithOrganization": "Share with Organization", "chat:task.shareWithOrganizationDescription": "Only members of your organization can access", @@ -335,6 +336,36 @@ describe("TaskActions", () => { }) }) + it("renders fork button", () => { + render() + + const forkButton = screen.getByLabelText("Fork conversation (create copy at this point)") + expect(forkButton).toBeInTheDocument() + }) + + it("sends forkTask message when fork button is clicked", () => { + render() + + const forkButton = screen.getByLabelText("Fork conversation (create copy at this point)") + fireEvent.click(forkButton) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "forkTask", + }) + }) + + it("fork button respects buttonsDisabled state", () => { + const { rerender } = render() + + let forkButton = screen.getByLabelText("Fork conversation (create copy at this point)") + expect(forkButton).not.toBeDisabled() + + rerender() + + forkButton = screen.getByLabelText("Fork conversation (create copy at this point)") + expect(forkButton).toBeDisabled() + }) + it("renders delete button when item has size", () => { render() diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 0bd258e88b..1abb819e71 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -14,6 +14,9 @@ "contextWindow": "Context Length", "closeAndStart": "Close task and start a new one", "export": "Export task history", + "fork": "Fork conversation (create copy at this point)", + "forkedFrom": "Forked from:", + "openParentTask": "Open parent task", "share": "Share task", "delete": "Delete Task (Shift + Click to skip confirmation)", "shareWithOrganization": "Share with Organization",