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
This commit is contained in:
Roo Code 2025-12-12 16:08:45 +00:00
parent d976a9b296
commit 1e6ac27950
10 changed files with 578 additions and 2 deletions

View file

@ -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<typeof historyItemSchema>

View file

@ -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<string> {
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()

View file

@ -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",
}),
)
})
})

View file

@ -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

View file

@ -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<

View file

@ -51,6 +51,7 @@ export interface WebviewMessage {
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "forkTask"
| "importSettings"
| "exportSettings"
| "resetState"

View file

@ -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" })}
/>
<LucideIconButton
icon={GitBranchIcon}
title={t("chat:task.fork")}
disabled={buttonsDisabled}
onClick={() => vscode.postMessage({ type: "forkTask" })}
/>
{item?.task && (
<LucideIconButton
icon={CopyIcon}

View file

@ -12,6 +12,7 @@ import {
HardDriveUpload,
FoldVertical,
Globe,
GitBranchIcon,
} from "lucide-react"
import prettyBytes from "pretty-bytes"
@ -61,7 +62,8 @@ const TaskHeader = ({
todos,
}: TaskHeaderProps) => {
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 = (
<LucideIconButton
title={t("chat:task.condenseContext")}
@ -131,6 +141,25 @@ const TaskHeader = ({
{t("cloud:upsell.longRunningTask")}
</DismissibleUpsell>
)}
{/* Forked from indicator */}
{parentTask && (
<div className="mb-2 px-3 py-2 bg-vscode-input-background rounded-lg border border-vscode-sideBar-background">
<div className="flex items-center gap-2 text-sm text-vscode-descriptionForeground">
<GitBranchIcon className="size-3.5 shrink-0" />
<span className="font-medium">{t("chat:task.forkedFrom")}</span>
<StandardTooltip content={t("chat:task.openParentTask")}>
<button
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "showTaskWithId", text: parentTask.id })
}}
className="text-vscode-textLink hover:text-vscode-textLink/80 underline max-w-[200px] truncate">
{parentTask.task}
</button>
</StandardTooltip>
</div>
</div>
)}
<div
className={cn(
"px-3 pt-2.5 pb-2 flex flex-col gap-1.5 relative z-1 cursor-pointer",

View file

@ -34,6 +34,7 @@ vi.mock("react-i18next", () => ({
const translations: Record<string, string> = {
"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(<TaskActions item={mockItem} buttonsDisabled={false} />)
const forkButton = screen.getByLabelText("Fork conversation (create copy at this point)")
expect(forkButton).toBeInTheDocument()
})
it("sends forkTask message when fork button is clicked", () => {
render(<TaskActions item={mockItem} buttonsDisabled={false} />)
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(<TaskActions item={mockItem} buttonsDisabled={false} />)
let forkButton = screen.getByLabelText("Fork conversation (create copy at this point)")
expect(forkButton).not.toBeDisabled()
rerender(<TaskActions item={mockItem} buttonsDisabled={true} />)
forkButton = screen.getByLabelText("Fork conversation (create copy at this point)")
expect(forkButton).toBeDisabled()
})
it("renders delete button when item has size", () => {
render(<TaskActions item={mockItem} buttonsDisabled={false} />)

View file

@ -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",