@@ -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))
|
||||
|
||||
@@ -169,7 +169,8 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
|
||||
| `/name <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 <path> # 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 <path|id>`** - 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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -26,7 +26,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
|
||||
{ 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" },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -567,6 +567,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
return success(id, "fork", { text: result.selectedText, cancelled: result.cancelled });
|
||||
}
|
||||
|
||||
case "clone": {
|
||||
const leafId = session.sessionManager.getLeafId();
|
||||
if (!leafId) {
|
||||
return error(id, "clone", "Cannot clone session: no current entry selected");
|
||||
}
|
||||
const result = await runtimeHost.fork(leafId, { position: "at" });
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return success(id, "clone", { cancelled: result.cancelled });
|
||||
}
|
||||
|
||||
case "get_fork_messages": {
|
||||
const messages = session.getUserMessagesForForking();
|
||||
return success(id, "get_fork_messages", { messages });
|
||||
|
||||
@@ -57,6 +57,7 @@ export type RpcCommand =
|
||||
| { id?: string; type: "export_html"; outputPath?: string }
|
||||
| { id?: string; type: "switch_session"; sessionPath: string }
|
||||
| { id?: string; type: "fork"; entryId: string }
|
||||
| { id?: string; type: "clone" }
|
||||
| { id?: string; type: "get_fork_messages" }
|
||||
| { id?: string; type: "get_last_assistant_text" }
|
||||
| { id?: string; type: "set_session_name"; name: string }
|
||||
@@ -172,6 +173,7 @@ export type RpcResponse =
|
||||
| { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } }
|
||||
| { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } }
|
||||
| { id?: string; type: "response"; command: "fork"; success: true; data: { text: string; cancelled: boolean } }
|
||||
| { id?: string; type: "response"; command: "clone"; success: true; data: { cancelled: boolean } }
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
|
||||
type CloneCommandContext = {
|
||||
sessionManager: { getLeafId: () => string | null };
|
||||
runtimeHost: {
|
||||
fork: (entryId: string, options?: { position?: "before" | "at" }) => Promise<{ cancelled: boolean }>;
|
||||
};
|
||||
handleRuntimeSessionChange: () => Promise<void>;
|
||||
renderCurrentSessionState: () => void;
|
||||
editor: { setText: (text: string) => void };
|
||||
showStatus: (message: string) => void;
|
||||
showError: (message: string) => void;
|
||||
ui: { requestRender: () => void };
|
||||
};
|
||||
|
||||
type InteractiveModePrototype = {
|
||||
handleCloneCommand(this: CloneCommandContext): Promise<void>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
29
packages/coding-agent/test/rpc-client-clone.test.ts
Normal file
29
packages/coding-agent/test/rpc-client-clone.test.ts
Normal file
@@ -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<unknown>;
|
||||
getData: <T>(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 = <T>(response: unknown): T => {
|
||||
return (response as { data: T }).data;
|
||||
};
|
||||
|
||||
const result = await client.clone();
|
||||
|
||||
expect(send).toHaveBeenCalledWith({ type: "clone" });
|
||||
expect(result).toEqual({ cancelled: false });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user