This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/server/sessions/piSessionService.tree.test.ts
T

396 lines
20 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { SessionTreeNavigateRequest, SessionTreeSummaryChoice } from "../../shared/apiTypes.js";
import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionServiceDependencies } from "./piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, testModelRuntime, type TestSession } from "./piSessionService.testSupport.js";
import type { ProjectableSessionTreeNode } from "./sessionTreeProjection.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
const SESSION_ID = "tree-session";
type NavigateTree = NonNullable<PiAgentSession["navigateTree"]>;
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function treeNode(entry: Record<string, unknown>, children: ProjectableSessionTreeNode[] = []): ProjectableSessionTreeNode {
return { entry, children };
}
function navigationRequest(
summary: SessionTreeSummaryChoice = { mode: "none" },
expectedLeafId: string | null = "leaf-1",
): SessionTreeNavigateRequest {
return { targetId: "target-1", expectedLeafId, summary };
}
function treeHarness(
managerPatch: Partial<PiSessionManager> = {},
sessionPatch: Partial<TestSession> = {},
dependenciesPatch: Partial<PiSessionServiceDependencies> = {},
) {
const hub = new CapturingSessionEventHub();
const manager = fakeSessionManager("/workspace", {
getSessionId: () => SESSION_ID,
getLeafId: () => "leaf-1",
...managerPatch,
});
const fake = fakeRuntime(SESSION_ID, { sessionManager: manager, ...sessionPatch });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: emptyArchiveStore(),
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord(SESSION_ID)]),
heartbeatIntervalMs: 60_000,
...dependenciesPatch,
});
return { service, fake, hub };
}
describe("PiSessionService session-tree behavior", () => {
it("opens /tree from the live manager through the safe projection boundary", async () => {
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
const roots = [treeNode({
type: "message",
id: "leaf-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [
{ type: "text", text: "safe answer" },
{ type: "thinking", thinking: "private reasoning", thinkingSignature: "private signature" },
],
usage: { private: true },
},
})];
const { service } = treeHarness({ getTree: () => roots }, { navigateTree });
await expect(service.runCommand(sessionRef(SESSION_ID), "/tree")).resolves.toEqual({
type: "tree",
tree: {
nodes: [{
id: "leaf-1",
parentId: null,
kind: "assistant",
summary: "safe answer",
timestamp: "2026-01-01T00:00:00.000Z",
}],
activeLeafId: "leaf-1",
activePathIds: ["leaf-1"],
},
});
await service.dispose();
});
it("maps none, default, and trimmed custom summary choices exactly and returns only editor text", async () => {
const navigateTree = vi.fn<NavigateTree>();
navigateTree
.mockResolvedValueOnce({ cancelled: false })
.mockResolvedValueOnce({ cancelled: false })
.mockResolvedValueOnce({ cancelled: false, editorText: "exact user text", summaryEntry: { details: "must not escape" } });
const { service, fake } = treeHarness({}, { navigateTree });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "none" }))).resolves.toEqual({ cancelled: false });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }))).resolves.toEqual({ cancelled: false });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "custom", instructions: " focus on tests\nwithout losing context " }))).resolves.toEqual({
cancelled: false,
editorText: "exact user text",
});
expect(navigateTree).toHaveBeenNthCalledWith(1, "target-1", { summarize: false });
expect(navigateTree).toHaveBeenNthCalledWith(2, "target-1", { summarize: true });
expect(navigateTree).toHaveBeenNthCalledWith(3, "target-1", { summarize: true, customInstructions: "focus on tests\nwithout losing context" });
expect(service.activeCount()).toBe(1);
expect(fake.calls.dispose).toBe(0);
await service.dispose();
});
it("validates stale leaves, active work, unavailable runtimes, and custom instruction bounds", async () => {
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
const { service, fake } = treeHarness({ getLeafId: () => "new-leaf" }, { navigateTree });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "none" }, "old-leaf"))).rejects.toThrow(
"The session changed since /tree was opened. Reopen /tree and try again.",
);
expect(navigateTree).not.toHaveBeenCalled();
fake.session.isStreaming = true;
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "none" }, "new-leaf"))).rejects.toThrow(
"Stop current session activity before navigating the session tree",
);
fake.session.isStreaming = false;
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "custom", instructions: " " }, "new-leaf"))).rejects.toThrow(
"Custom branch-summary instructions are required",
);
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "custom", instructions: "x".repeat(10_001) }, "new-leaf"))).rejects.toThrow(
"Custom branch-summary instructions must be at most 10000 characters",
);
expect(navigateTree).not.toHaveBeenCalled();
await service.dispose();
const unavailable = treeHarness();
await expect(unavailable.service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
"Session tree navigation is not supported by this Pi runtime",
);
await unavailable.service.dispose();
});
it("holds a per-runtime gate that rejects concurrent navigation and leaf-producing work", async () => {
const navigation = deferred<Awaited<ReturnType<NavigateTree>>>();
const navigateTree = vi.fn<NavigateTree>(() => navigation.promise);
const { service, fake } = treeHarness({}, { navigateTree });
const firstNavigation = service.navigateTree(sessionRef(SESSION_ID), navigationRequest());
await vi.waitFor(() => { expect(navigateTree).toHaveBeenCalledOnce(); });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
"Stop current session activity before navigating the session tree",
);
await expect(service.prompt(sessionRef(SESSION_ID), "do not append yet")).rejects.toThrow(
"Cannot send a prompt while session tree navigation is active",
);
await expect(service.shell(sessionRef(SESSION_ID), "!pwd")).rejects.toThrow(
"Cannot run a shell command while session tree navigation is active",
);
await expect(service.setThinkingLevel(sessionRef(SESSION_ID), "off")).rejects.toThrow(
"Cannot change the thinking level while session tree navigation is active",
);
const model = testModel();
await expect(service.setModel(sessionRef(SESSION_ID), model.provider, model.id)).rejects.toThrow(
"Cannot change models while session tree navigation is active",
);
await expect(service.cycleModel(sessionRef(SESSION_ID), "forward")).rejects.toThrow(
"Cannot change models while session tree navigation is active",
);
await expect(service.runCommand(sessionRef(SESSION_ID), "/name blocked")).resolves.toEqual({
type: "unsupported",
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
});
await expect(service.archive(sessionRef(SESSION_ID))).rejects.toThrow("Stop current session activity before archiving");
expect(fake.calls.prompt).toEqual([]);
navigation.resolve({ cancelled: false });
await expect(firstNavigation).resolves.toEqual({ cancelled: false });
await service.prompt(sessionRef(SESSION_ID), "now append");
expect(fake.calls.prompt).toEqual([{ text: "now append", options: undefined }]);
await service.dispose();
});
it("blocks navigation while prompt preflight can await before Pi reports streaming", async () => {
const promptOperation = deferred<undefined>();
const prompt = vi.fn(() => promptOperation.promise);
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
const { service } = treeHarness({}, { prompt, navigateTree });
await service.prompt(sessionRef(SESSION_ID), "preflight is still running");
await vi.waitFor(() => { expect(prompt).toHaveBeenCalledOnce(); });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
"Stop current session activity before navigating the session tree",
);
promptOperation.resolve(undefined);
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).resolves.toEqual({ cancelled: false });
await service.dispose();
});
it("blocks navigation while an asynchronous model change can still append an entry", async () => {
const modelChange = deferred<undefined>();
const setModel = vi.fn(() => modelChange.promise);
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
const { service } = treeHarness({}, { setModel, navigateTree });
const model = testModel();
const changingModel = service.setModel(sessionRef(SESSION_ID), model.provider, model.id);
await vi.waitFor(() => { expect(setModel).toHaveBeenCalledOnce(); });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
"Stop current session activity before navigating the session tree",
);
modelChange.resolve(undefined);
await changingModel;
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).resolves.toEqual({ cancelled: false });
await service.dispose();
});
it("blocks tree navigation while clone replaces and rebinds the runtime", async () => {
const replacement = deferred<{ cancelled: boolean; selectedText?: string }>();
const rebound = deferred<undefined>();
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
const { service, fake } = treeHarness({}, { navigateTree });
const replacementSessionId = "tree-session-replacement";
const replacementFake = fakeRuntime(replacementSessionId, {
sessionManager: fakeSessionManager("/workspace", {
getSessionId: () => replacementSessionId,
getLeafId: () => "replacement-leaf",
}),
navigateTree,
});
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const fork = vi.fn(async () => {
if (!Reflect.set(fake.runtime, "session", replacementFake.session)) throw new Error("Could not replace fake runtime session");
await rebindSession?.(replacementFake.session);
rebound.resolve(undefined);
return replacement.promise;
});
fake.runtime.fork = fork;
const cloning = service.runCommand(sessionRef(SESSION_ID), "/clone");
await rebound.promise;
await expect(service.navigateTree(sessionRef(replacementSessionId), navigationRequest({ mode: "none" }, "replacement-leaf"))).rejects.toThrow(
"Stop current session activity before navigating the session tree",
);
replacement.resolve({ cancelled: false });
await expect(cloning).resolves.toMatchObject({ type: "done", message: "Session cloned" });
await expect(service.navigateTree(sessionRef(replacementSessionId), navigationRequest({ mode: "none" }, "replacement-leaf"))).resolves.toEqual({ cancelled: false });
expect(fork).toHaveBeenCalledOnce();
await service.dispose();
});
it("blocks tree navigation while session resources reload", async () => {
const reloadOperation = deferred<undefined>();
const reload = vi.fn(() => reloadOperation.promise);
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
const { service } = treeHarness({}, { navigateTree, reload });
const reloading = service.runCommand(sessionRef(SESSION_ID), "/reload");
await vi.waitFor(() => { expect(reload).toHaveBeenCalledOnce(); });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
"Stop current session activity before navigating the session tree",
);
reloadOperation.resolve(undefined);
await expect(reloading).resolves.toMatchObject({ type: "done" });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).resolves.toEqual({ cancelled: false });
await service.dispose();
});
it("blocks tree navigation while the route-level runtime reload disposes and reopens the session", async () => {
const disposal = deferred<undefined>();
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
const { service, fake } = treeHarness({}, { navigateTree });
const dispose = vi.fn(() => disposal.promise);
fake.runtime.dispose = dispose;
const reloading = service.reload(sessionRef(SESSION_ID));
await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce(); });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
"Stop current session activity before navigating the session tree",
);
disposal.resolve(undefined);
await reloading;
await service.dispose();
});
it("blocks tree navigation throughout an asynchronous archive operation", async () => {
const archiveOperation = deferred<{ sessionId: string; cwd: string; archivedAt: string }>();
const archive = vi.fn(() => archiveOperation.promise);
const archiveStore = { ...emptyArchiveStore(), archive };
const navigateTree = vi.fn<NavigateTree>(() => Promise.resolve({ cancelled: false }));
const { service } = treeHarness({}, { navigateTree }, { archiveStore });
const archiving = service.archive(sessionRef(SESSION_ID));
await vi.waitFor(() => { expect(archive).toHaveBeenCalledOnce(); });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest())).rejects.toThrow(
"Stop current session activity before navigating the session tree",
);
archiveOperation.resolve({ sessionId: SESSION_ID, cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" });
await archiving;
await service.dispose();
});
it("aborts branch summarization through the existing abort path and reports cancellation", async () => {
const navigation = deferred<Awaited<ReturnType<NavigateTree>>>();
const navigateTree = vi.fn<NavigateTree>(() => navigation.promise);
const abortBranchSummary = vi.fn(() => { navigation.resolve({ cancelled: true, aborted: true }); });
const abort = vi.fn(() => Promise.resolve());
const { service, hub } = treeHarness({}, { navigateTree, abortBranchSummary, abort });
const navigationResult = service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }));
await vi.waitFor(() => { expect(navigateTree).toHaveBeenCalledOnce(); });
await service.abort(sessionRef(SESSION_ID));
await expect(navigationResult).resolves.toEqual({ cancelled: true, aborted: true });
expect(abortBranchSummary).toHaveBeenCalledOnce();
expect(abort).toHaveBeenCalledOnce();
expect(abortBranchSummary.mock.invocationCallOrder[0]).toBeLessThan(abort.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY);
expect(hub.sessionEvents.some(({ event }) => event.type === "activity.update" && event.activity.label === "branch summary aborted")).toBe(true);
await service.dispose();
});
it("does not republish stale navigation state after stopping and disposing its runtime", async () => {
const navigation = deferred<Awaited<ReturnType<NavigateTree>>>();
const navigateTree = vi.fn<NavigateTree>(() => navigation.promise);
const abortBranchSummary = vi.fn(() => { navigation.resolve({ cancelled: true, aborted: true }); });
const { service, hub, fake } = treeHarness({}, { navigateTree, abortBranchSummary });
const navigationResult = service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }));
await vi.waitFor(() => { expect(navigateTree).toHaveBeenCalledOnce(); });
hub.sessionEvents.length = 0;
await service.stop(sessionRef(SESSION_ID));
await expect(navigationResult).resolves.toEqual({ cancelled: true, aborted: true });
expect(service.activeCount()).toBe(0);
expect(fake.calls.dispose).toBe(1);
expect(hub.sessionEvents).toEqual([]);
await service.dispose();
});
it("still runs the normal abort and releases the gate when the branch-summary abort hook fails", async () => {
const navigation = deferred<Awaited<ReturnType<NavigateTree>>>();
const navigateTree = vi.fn<NavigateTree>(() => navigation.promise);
const branchAbortFailure = new Error("branch abort hook failed");
const abortBranchSummary = vi.fn<NonNullable<PiAgentSession["abortBranchSummary"]>>(() => { throw branchAbortFailure; });
const abort = vi.fn(() => {
navigation.resolve({ cancelled: true, aborted: true });
return Promise.resolve();
});
const { service } = treeHarness({}, { navigateTree, abortBranchSummary, abort });
const navigationResult = service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }));
await vi.waitFor(() => { expect(navigateTree).toHaveBeenCalledOnce(); });
await expect(service.abort(sessionRef(SESSION_ID))).rejects.toBe(branchAbortFailure);
await expect(navigationResult).resolves.toEqual({ cancelled: true, aborted: true });
expect(abortBranchSummary).toHaveBeenCalledOnce();
expect(abort).toHaveBeenCalledOnce();
await expect(service.prompt(sessionRef(SESSION_ID), "gate released after abort failure")).resolves.toBeUndefined();
abortBranchSummary.mockImplementation(() => undefined);
await service.dispose();
});
it("releases the gate and publishes final status after navigation failure", async () => {
const failure = new Error("summary provider failed");
const navigateTree = vi.fn<NavigateTree>();
navigateTree.mockRejectedValueOnce(failure).mockResolvedValueOnce({ cancelled: false });
const { service, hub } = treeHarness({}, { navigateTree });
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "default" }))).rejects.toBe(failure);
await expect(service.navigateTree(sessionRef(SESSION_ID), navigationRequest({ mode: "none" }))).resolves.toEqual({ cancelled: false });
expect(navigateTree).toHaveBeenCalledTimes(2);
expect(hub.sessionEvents.some(({ event }) => event.type === "activity.update"
&& event.activity.phase === "error"
&& event.activity.detail === "summary provider failed")).toBe(true);
expect(hub.sessionEvents.filter(({ event }) => event.type === "status.update").length).toBeGreaterThanOrEqual(4);
await service.dispose();
});
});