fix(coding-agent): split /clone from /fork UX

closes #2962
This commit is contained in:
Mario Zechner
2026-04-20 14:33:32 +02:00
parent 62c1c4031c
commit d554409b1f
16 changed files with 386 additions and 34 deletions

View File

@@ -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();
});
});

View 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 });
});
});

View File

@@ -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");