diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index faae5dee..113d29c2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `/clone` to duplicate the current active branch into a new session, while keeping `/fork` focused on forking from a previous user message ([#2962](https://github.com/badlogic/pi-mono/issues/2962)) + ### Fixed - Fixed skill resolution to dedupe symlinked aliases by canonical path, so `pi config` no longer shows duplicate skill entries when `~/.pi/agent/skills` points to `~/.agents/skills` ([#3405](https://github.com/badlogic/pi-mono/issues/3405)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 07f6dd4c..3c3fe05b 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -169,7 +169,8 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | `/name ` | Set session display name | | `/session` | Show session info (path, tokens, cost) | | `/tree` | Jump to any point in the session and continue from there | -| `/fork` | Create a new session from the current branch | +| `/fork` | Create a new session from a previous user message | +| `/clone` | Duplicate the current active branch into a new session | | `/compact [prompt]` | Manually compact context, optional custom instructions | | `/copy` | Copy last assistant message to clipboard | | `/export [file]` | Export session to HTML file | @@ -238,7 +239,9 @@ pi --fork # Fork specific session file or ID into a new session - Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all - Press Shift+L to label entries as bookmarks and Shift+T to toggle label timestamps -**`/fork`** - Create a new session file from the current branch. Opens a selector, copies history up to the selected point, and places that message in the editor for modification. +**`/fork`** - Create a new session file from a previous user message on the active branch. Opens a selector, copies the active path up to that point, and places the selected prompt in the editor for modification. + +**`/clone`** - Duplicate the current active branch into a new session file at the current position. The new session keeps the full active-path history and opens with an empty editor. **`--fork `** - Fork an existing session file or partial session UUID directly from the CLI. This copies the full source session into a new session file in the current project. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 57411d3b..3c3eac00 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -269,7 +269,7 @@ user sends another prompt ◄───────────────── ├─► session_start { reason: "new" | "resume", previousSessionFile? } └─► resources_discover { reason: "startup" } -/fork +/fork or /clone ├─► session_before_fork (can cancel) ├─► session_shutdown ├─► session_start { reason: "fork", previousSessionFile } @@ -346,18 +346,19 @@ Do cleanup work in `session_shutdown`, then reestablish any in-memory state in ` #### session_before_fork -Fired when forking via `/fork`. +Fired when forking via `/fork` or cloning via `/clone`. ```typescript pi.on("session_before_fork", async (event, ctx) => { - // event.entryId - ID of the entry being forked from - return { cancel: true }; // Cancel fork + // event.entryId - ID of the selected entry + // event.position - "before" for /fork, "at" for /clone + return { cancel: true }; // Cancel fork/clone // OR - return { skipConversationRestore: true }; // Fork but don't rewind messages + return { skipConversationRestore: true }; // Reserved for future conversation restore control }); ``` -After a successful fork, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. +After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. #### session_before_compact / session_compact @@ -909,7 +910,7 @@ if (result.cancelled) { } ``` -### ctx.fork(entryId) +### ctx.fork(entryId, options?) Fork from a specific entry, creating a new session file: @@ -918,8 +919,17 @@ const result = await ctx.fork("entry-id-123"); if (!result.cancelled) { // Now in the forked session } + +const cloneResult = await ctx.fork("entry-id-456", { position: "at" }); +if (!cloneResult.cancelled) { + // New session contains the active path through entry-id-456 +} ``` +Options: +- `position`: `"before"` (default) forks before the selected user message, restoring that prompt into the editor +- `position`: `"at"` duplicates the active path through the selected entry without restoring editor text + ### ctx.navigateTree(targetId, options?) Navigate to a different point in the session tree: diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 54defea9..5736fddc 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -580,7 +580,7 @@ If an extension cancelled the switch: #### fork -Create a new fork from a previous user message. Can be cancelled by a `session_before_fork` extension event handler. Returns the text of the message being forked from. +Create a new fork from a previous user message on the active branch. Can be cancelled by a `session_before_fork` extension event handler. Returns the text of the message being forked from. ```json {"type": "fork", "entryId": "abc123"} @@ -606,6 +606,34 @@ If an extension cancelled the fork: } ``` +#### clone + +Duplicate the current active branch into a new session at the current position. Can be cancelled by a `session_before_fork` extension event handler. + +```json +{"type": "clone"} +``` + +Response: +```json +{ + "type": "response", + "command": "clone", + "success": true, + "data": {"cancelled": false} +} +``` + +If an extension cancelled the clone: +```json +{ + "type": "response", + "command": "clone", + "success": true, + "data": {"cancelled": true} +} +``` + #### get_fork_messages Get user messages available for forking. diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 8b70077f..c6579f76 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -159,6 +159,7 @@ const runtime = await createAgentSessionRuntime(createRuntime, { - `newSession()` - `switchSession()` - `fork()` +- clone flows via `fork(entryId, { position: "at" })` - `importFromJsonl()` Important behavior: @@ -718,7 +719,7 @@ const { session: opened } = await createAgentSession({ const currentProjectSessions = await SessionManager.list(process.cwd()); const allSessions = await SessionManager.listAll(process.cwd()); -// Session replacement API for /new, /resume, /fork, and import flows. +// Session replacement API for /new, /resume, /fork, /clone, and import flows. const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd }); return { @@ -744,8 +745,11 @@ await runtime.newSession(); // Replace the active session with another saved session await runtime.switchSession("/path/to/session.jsonl"); -// Replace the active session with a fork from a specific entry +// Replace the active session with a fork from a specific user entry await runtime.fork("entry-id"); + +// Clone the active path through a specific entry +await runtime.fork("entry-id", { position: "at" }); ``` **SessionManager tree API:** diff --git a/packages/coding-agent/docs/session.md b/packages/coding-agent/docs/session.md index 30844fb0..22a9f8e1 100644 --- a/packages/coding-agent/docs/session.md +++ b/packages/coding-agent/docs/session.md @@ -191,7 +191,7 @@ First line of the file. Metadata only, not part of the tree (no `id`/`parentId`) {"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"} ``` -For sessions with a parent (created via `/fork` or `newSession({ parentSession })`): +For sessions with a parent (created via `/fork`, `/clone`, or `newSession({ parentSession })`): ```json {"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"} diff --git a/packages/coding-agent/docs/tree.md b/packages/coding-agent/docs/tree.md index de8ba694..afa85f62 100644 --- a/packages/coding-agent/docs/tree.md +++ b/packages/coding-agent/docs/tree.md @@ -15,6 +15,8 @@ Sessions are stored as trees where each entry has an `id` and `parentId`. The "l | Summary | Never | Optional (user prompted) | | Events | `session_before_fork` / `session_start` (`reason: "fork"`) | `session_before_tree` / `session_tree` | +Use `/clone` when you want a new session file containing the full current active branch without rewinding to an earlier user message. + ## Tree UI ``` diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 803f20a8..8fa588ca 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -26,7 +26,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "session", description: "Show session info and stats" }, { name: "changelog", description: "Show changelog entries" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, - { name: "fork", description: "Create a new fork from a previous message" }, + { name: "fork", description: "Create a new fork from a previous user message" }, + { name: "clone", description: "Duplicate the current session at the current position" }, { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "login", description: "Login with OAuth provider" }, { name: "logout", description: "Logout from OAuth provider" }, diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index d02c27fd..cbe20ec8 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -120,8 +120,14 @@ export class UserMessageSelectorComponent extends Container { // Add header this.addChild(new Spacer(1)); - this.addChild(new Text(theme.bold("Branch from Message"), 1, 0)); - this.addChild(new Text(theme.fg("muted", "Select a message or duplicate the current session"), 1, 0)); + this.addChild(new Text(theme.bold("Fork from Message"), 1, 0)); + this.addChild( + new Text( + theme.fg("muted", "Select a user message to copy the active path up to that point into a new session"), + 1, + 0, + ), + ); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8ed2923e..f13c33bb 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2425,6 +2425,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/clone") { + this.editor.setText(""); + await this.handleCloneCommand(); + return; + } if (text === "/tree") { this.showTreeSelector(); this.editor.setText(""); @@ -3963,7 +3968,6 @@ export class InteractiveMode { } private showUserMessageSelector(): void { - const duplicateSessionOptionId = "__duplicate_session__"; const userMessages = this.session.getUserMessagesForForking(); if (userMessages.length === 0) { @@ -3971,28 +3975,14 @@ export class InteractiveMode { return; } - const selectableEntries = [ - ...userMessages.map((m) => ({ id: m.entryId, text: m.text })), - { id: duplicateSessionOptionId, text: "Duplicate session" }, - ]; const initialSelectedId = userMessages[userMessages.length - 1]?.entryId; this.showSelector((done) => { const selector = new UserMessageSelectorComponent( - selectableEntries, + userMessages.map((m) => ({ id: m.entryId, text: m.text })), async (entryId) => { try { - let result: { cancelled: boolean; selectedText?: string }; - if (entryId === duplicateSessionOptionId) { - const leafId = this.sessionManager.getLeafId(); - if (!leafId) { - throw new Error("Cannot duplicate session: no current entry selected"); - } - result = await this.runtimeHost.fork(leafId, { position: "at" }); - } else { - result = await this.runtimeHost.fork(entryId); - } - + const result = await this.runtimeHost.fork(entryId); if (result.cancelled) { done(); this.ui.requestRender(); @@ -4003,7 +3993,7 @@ export class InteractiveMode { this.renderCurrentSessionState(); this.editor.setText(result.selectedText ?? ""); done(); - this.showStatus("Branched to new session"); + this.showStatus("Forked to new session"); } catch (error: unknown) { done(); this.showError(error instanceof Error ? error.message : String(error)); @@ -4019,6 +4009,29 @@ export class InteractiveMode { }); } + private async handleCloneCommand(): Promise { + const leafId = this.sessionManager.getLeafId(); + if (!leafId) { + this.showStatus("Nothing to clone yet"); + return; + } + + try { + const result = await this.runtimeHost.fork(leafId, { position: "at" }); + if (result.cancelled) { + this.ui.requestRender(); + return; + } + + await this.handleRuntimeSessionChange(); + this.renderCurrentSessionState(); + this.editor.setText(""); + this.showStatus("Cloned to new session"); + } catch (error: unknown) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + private showTreeSelector(initialSelectedId?: string): void { const tree = this.sessionManager.getTree(); const realLeafId = this.sessionManager.getLeafId(); diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index fce737bf..98fa0562 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -342,6 +342,15 @@ export class RpcClient { return this.getData(response); } + /** + * Clone the current active branch into a new session. + * @returns Object with `cancelled: true` if an extension cancelled the clone + */ + async clone(): Promise<{ cancelled: boolean }> { + const response = await this.send({ type: "clone" }); + return this.getData(response); + } + /** * Get messages available for forking. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index b775adc0..1b5b1e5d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -567,6 +567,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise string | null }; + runtimeHost: { + fork: (entryId: string, options?: { position?: "before" | "at" }) => Promise<{ cancelled: boolean }>; + }; + handleRuntimeSessionChange: () => Promise; + renderCurrentSessionState: () => void; + editor: { setText: (text: string) => void }; + showStatus: (message: string) => void; + showError: (message: string) => void; + ui: { requestRender: () => void }; +}; + +type InteractiveModePrototype = { + handleCloneCommand(this: CloneCommandContext): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; + +describe("InteractiveMode /clone", () => { + it("clones the current leaf into a new session", async () => { + const fork = vi.fn(async () => ({ cancelled: false })); + const handleRuntimeSessionChange = vi.fn(async () => {}); + const renderCurrentSessionState = vi.fn(); + const setText = vi.fn(); + const showStatus = vi.fn(); + const showError = vi.fn(); + const requestRender = vi.fn(); + + const context: CloneCommandContext = { + sessionManager: { getLeafId: () => "leaf-123" }, + runtimeHost: { fork }, + handleRuntimeSessionChange, + renderCurrentSessionState, + editor: { setText }, + showStatus, + showError, + ui: { requestRender }, + }; + + await interactiveModePrototype.handleCloneCommand.call(context); + + expect(fork).toHaveBeenCalledWith("leaf-123", { position: "at" }); + expect(handleRuntimeSessionChange).toHaveBeenCalled(); + expect(renderCurrentSessionState).toHaveBeenCalled(); + expect(setText).toHaveBeenCalledWith(""); + expect(showStatus).toHaveBeenCalledWith("Cloned to new session"); + expect(showError).not.toHaveBeenCalled(); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it("shows a status message when there is nothing to clone", async () => { + const fork = vi.fn(async () => ({ cancelled: false })); + const showStatus = vi.fn(); + const showError = vi.fn(); + + const context: CloneCommandContext = { + sessionManager: { getLeafId: () => null }, + runtimeHost: { fork }, + handleRuntimeSessionChange: vi.fn(async () => {}), + renderCurrentSessionState: vi.fn(), + editor: { setText: vi.fn() }, + showStatus, + showError, + ui: { requestRender: vi.fn() }, + }; + + await interactiveModePrototype.handleCloneCommand.call(context); + + expect(fork).not.toHaveBeenCalled(); + expect(showStatus).toHaveBeenCalledWith("Nothing to clone yet"); + expect(showError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/rpc-client-clone.test.ts b/packages/coding-agent/test/rpc-client-clone.test.ts new file mode 100644 index 00000000..f05caa4a --- /dev/null +++ b/packages/coding-agent/test/rpc-client-clone.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { RpcClient } from "../src/modes/rpc/rpc-client.js"; + +type RpcClientPrivate = { + send: (command: { type: string }) => Promise; + getData: (response: unknown) => T; +}; + +describe("RpcClient clone", () => { + it("sends the clone RPC command", async () => { + const client = new RpcClient(); + const privateClient = client as unknown as RpcClientPrivate; + const send = vi.fn(async () => ({ + type: "response", + command: "clone", + success: true, + data: { cancelled: false }, + })); + privateClient.send = send; + privateClient.getData = (response: unknown): T => { + return (response as { data: T }).data; + }; + + const result = await client.clone(); + + expect(send).toHaveBeenCalledWith({ type: "clone" }); + expect(result).toEqual({ cancelled: false }); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-runtime.test.ts b/packages/coding-agent/test/suite/agent-session-runtime.test.ts index 99152fc1..55de188e 100644 --- a/packages/coding-agent/test/suite/agent-session-runtime.test.ts +++ b/packages/coding-agent/test/suite/agent-session-runtime.test.ts @@ -234,6 +234,158 @@ describe("AgentSessionRuntime characterization", () => { expect(events).toEqual([{ type: "session_before_fork", entryId: "missing-entry", position: "at" }]); }); + it("duplicates the current active branch when forking at the current position", async () => { + const { runtime } = await createRuntimeForTest(() => {}); + await runtime.session.prompt("hello"); + await runtime.session.prompt("again"); + + const beforeMessages = runtime.session.messages.map((message) => ({ + role: message.role, + text: + message.role === "user" + ? typeof message.content === "string" + ? message.content + : message.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") + : undefined, + })); + const previousSessionFile = runtime.session.sessionFile; + const leafId = runtime.session.sessionManager.getLeafId(); + expect(leafId).toBeTruthy(); + + const result = await runtime.fork(leafId!, { position: "at" }); + expect(result).toEqual({ cancelled: false, selectedText: undefined }); + expect(runtime.session.sessionFile).not.toBe(previousSessionFile); + expect( + runtime.session.messages.map((message) => ({ + role: message.role, + text: + message.role === "user" + ? typeof message.content === "string" + ? message.content + : message.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") + : undefined, + })), + ).toEqual(beforeMessages); + }); + + it("duplicates the current active branch in-memory when forking at the current position", async () => { + const tempDir = join(tmpdir(), `pi-runtime-suite-in-memory-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + + const faux = registerFauxProvider({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-2", reasoning: false }, + ], + }); + faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]); + + const authStorage = AuthStorage.inMemory(); + authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key"); + + const runtimeOptions = { + agentDir: tempDir, + authStorage, + model: faux.getModel(), + resourceLoaderOptions: { + extensionFactories: [ + (pi: ExtensionAPI) => { + pi.registerProvider(faux.getModel().provider, { + baseUrl: faux.getModel().baseUrl, + apiKey: "faux-key", + api: faux.api, + models: faux.models.map((registeredModel) => ({ + id: registeredModel.id, + name: registeredModel.name, + api: registeredModel.api, + reasoning: registeredModel.reasoning, + input: registeredModel.input, + cost: registeredModel.cost, + contextWindow: registeredModel.contextWindow, + maxTokens: registeredModel.maxTokens, + })), + }); + }, + ], + noSkills: true, + noPromptTemplates: true, + noThemes: true, + }, + }; + const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { + const services = await createAgentSessionServices({ + ...runtimeOptions, + cwd, + }); + return { + ...(await createAgentSessionFromServices({ + services, + sessionManager, + sessionStartEvent, + model: runtimeOptions.model, + })), + services, + diagnostics: services.diagnostics, + }; + }; + const runtime = await createAgentSessionRuntime(createRuntime, { + cwd: tempDir, + agentDir: tempDir, + sessionManager: SessionManager.inMemory(tempDir), + }); + await runtime.session.bindExtensions({}); + cleanups.push(async () => { + await runtime.dispose(); + faux.unregister(); + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + await runtime.session.prompt("hello"); + await runtime.session.prompt("again"); + + const beforeMessages = runtime.session.messages.map((message) => ({ + role: message.role, + text: + message.role === "user" + ? typeof message.content === "string" + ? message.content + : message.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") + : undefined, + })); + const leafId = runtime.session.sessionManager.getLeafId(); + expect(leafId).toBeTruthy(); + expect(runtime.session.sessionFile).toBeUndefined(); + + const result = await runtime.fork(leafId!, { position: "at" }); + expect(result).toEqual({ cancelled: false, selectedText: undefined }); + expect(runtime.session.sessionFile).toBeUndefined(); + expect( + runtime.session.messages.map((message) => ({ + role: message.role, + text: + message.role === "user" + ? typeof message.content === "string" + ? message.content + : message.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") + : undefined, + })), + ).toEqual(beforeMessages); + }); + it("throws when forking with an invalid entry id", async () => { const { runtime } = await createRuntimeForTest(() => {}); await expect(runtime.fork("missing-entry")).rejects.toThrow("Invalid entry ID for forking");