feat: add hierarchical session tree navigator

This commit is contained in:
Federico Jaramillo Martinez
2026-07-20 10:40:17 +02:00
parent a77c83b309
commit 4ca4a1d096
31 changed files with 4062 additions and 122 deletions
@@ -0,0 +1,395 @@
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();
});
});
+418 -90
View File
@@ -19,12 +19,13 @@ import {
type ModelRuntime,
type ResourceDiagnostic,
} from "@earendil-works/pi-coding-agent";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientSessionTreeNavigateRequest, ClientSessionTreeNavigateResult, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
import { projectBrowserMessage } from "../browserMessageProjection.js";
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js";
import { projectSessionTree, type ProjectableSessionTreeNode } from "./sessionTreeProjection.js";
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
import type { ActiveSession } from "./sessionRuntimeStore.js";
@@ -32,6 +33,7 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH } from "../../shared/apiTypes.js";
import type {
SavedPromptAttachment,
SessionBulkArchiveResponse,
@@ -106,6 +108,51 @@ interface QueuedPrompt {
echoUserMessage?: boolean;
}
interface DeferredSubsessionNotification {
parentId: string;
childId: string;
text: string;
}
interface TreeExclusiveOperationTarget {
sessionId: string;
session?: PiAgentSession;
runtime?: PiSessionRuntime;
}
type PiTreeNavigationOptions =
| { summarize: false }
| { summarize: true; customInstructions?: string };
function sessionTreeNavigationOptions(request: ClientSessionTreeNavigateRequest): PiTreeNavigationOptions {
switch (request.summary.mode) {
case "none":
return { summarize: false };
case "default":
return { summarize: true };
case "custom": {
const customInstructions = request.summary.instructions.trim();
if (customInstructions === "") throw new Error("Custom branch-summary instructions are required");
if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
throw new Error(`Custom branch-summary instructions must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
}
return { summarize: true, customInstructions };
}
}
}
function decrementWeakCount<Key extends object>(counts: WeakMap<Key, number>, key: Key): void {
const remaining = (counts.get(key) ?? 1) - 1;
if (remaining <= 0) counts.delete(key);
else counts.set(key, remaining);
}
function decrementMapCount<Key>(counts: Map<Key, number>, key: Key): void {
const remaining = (counts.get(key) ?? 1) - 1;
if (remaining <= 0) counts.delete(key);
else counts.set(key, remaining);
}
interface TrackedSubsessionLink {
parentSessionId: string;
childSessionId: string;
@@ -197,6 +244,7 @@ export interface PiSessionManager {
getSessionFile(): string | undefined;
getBranch(): unknown[];
getEntries?(): readonly unknown[];
getTree?(): readonly ProjectableSessionTreeNode[];
getLeafId(): string | null;
getHeader?(): { parentSession?: string } | null | undefined;
appendCustomEntry?(customType: string, data?: unknown): string;
@@ -276,6 +324,8 @@ export interface PiAgentSession {
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
navigateTree?(targetId: string, options?: { summarize?: boolean; customInstructions?: string }): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: unknown }>;
abortBranchSummary?(): void;
abort(): Promise<void>;
clearQueue(): { steering: string[]; followUp: string[] };
getSteeringMessages(): readonly string[];
@@ -605,6 +655,15 @@ export class PiSessionService implements SessionRouteService {
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService<PiAgentSession>;
/** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
private readonly treeNavigations = new WeakSet<PiAgentSession>();
/** Counts async operations that may append an entry before they settle. */
private readonly sessionEntryMutationCounts = new WeakMap<PiAgentSession, number>();
/** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
private readonly treeExclusiveRuntimeOperationCounts = new WeakMap<PiSessionRuntime, number>();
private readonly treeExclusiveSessionOperationCounts = new Map<string, number>();
private readonly deferredSubsessionNotifications = new WeakMap<PiAgentSession, DeferredSubsessionNotification[]>();
private readonly deferredGeneratedSessionNames = new WeakMap<PiAgentSession, string>();
private readonly compactionPromptQueues = new Map<string, QueuedPrompt[]>();
private readonly compactionDrainTimers = new Map<string, NodeJS.Timeout>();
private readonly authLossWarnings = new Set<string>();
@@ -667,14 +726,27 @@ export class PiSessionService implements SessionRouteService {
events,
{
onCompactionStart: (session) => {
this.beginSessionEntryMutation(session, "compact the session");
this.publishActivity(session, "compacting", "active");
this.publishStatus(session);
},
onCompactionEnd: (session, result, detail) => {
this.endSessionEntryMutation(session);
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
this.publishStatus(session);
},
reloadSession: (session) => this.reloadSessionRuntime(session),
getSessionTree: (session) => {
if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function") return undefined;
return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
},
hasActiveWork: (session) => this.hasActiveWork(session),
isTreeNavigationActive: (session) => this.treeNavigations.has(session),
runSessionReplacement: (session, operation) => this.runTreeExclusiveOperation(
[{ sessionId: session.sessionId, session }],
"Stop current session activity before replacing the session",
operation,
),
},
{ listSessionNames: (cwd) => this.listSessionNames(cwd) },
);
@@ -789,7 +861,7 @@ export class PiSessionService implements SessionRouteService {
active.runtime.setRebindSession(undefined);
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
try {
await active.runtime.session.abort();
await this.abortSessionOperations(active.runtime.session);
} finally {
await active.runtime.dispose();
}
@@ -1197,19 +1269,33 @@ export class PiSessionService implements SessionRouteService {
private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise<void> {
try {
const session = await this.getOrOpenParentForSubsession(parentId, childId);
await session.sendCustomMessage(
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } },
{ triggerTurn: true, deliverAs: "followUp" },
);
this.publishStatus(session);
if (this.treeNavigations.has(session)) {
const pending = this.deferredSubsessionNotifications.get(session) ?? [];
pending.push({ parentId, childId, text });
this.deferredSubsessionNotifications.set(session, pending);
return;
}
await this.deliverSubsessionNotification(session, { parentId, childId, text });
} catch (error: unknown) {
this.logger.info(
{ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) },
"failed to notify parent of subsession completion",
);
this.logSubsessionNotificationFailure(parentId, childId, error);
}
}
private async deliverSubsessionNotification(session: PiAgentSession, notification: DeferredSubsessionNotification): Promise<void> {
await this.runSessionEntryMutation(session, "deliver a subsession notification", () => session.sendCustomMessage(
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: notification.text, display: true, details: { sessionId: notification.childId } },
{ triggerTurn: true, deliverAs: "followUp" },
));
this.publishStatus(session);
}
private logSubsessionNotificationFailure(parentId: string, childId: string, error: unknown): void {
this.logger.info(
{ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) },
"failed to notify parent of subsession completion",
);
}
async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
const session = await this.getOrOpen(ref);
return pageMessagesAtSafeBoundary(historyMessages(session), page);
@@ -1251,14 +1337,16 @@ export class PiSessionService implements SessionRouteService {
async setModel(ref: PiSessionLookup, provider: string, modelId: string): Promise<ClientSessionStatus> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.assertTreeNavigationInactive(session, "change models");
await session.modelRuntime.reloadConfig();
this.assertTreeNavigationInactive(session, "change models");
const candidates = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
: session.modelRuntime.getAvailableSnapshot();
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
?? session.modelRuntime.getModel(provider, modelId);
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
await session.setModel(model);
await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
this.publishStatus(session);
return this.statusFromSession(session);
@@ -1267,7 +1355,7 @@ export class PiSessionService implements SessionRouteService {
async cycleModel(ref: PiSessionLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
const result = await session.cycleModel(direction);
const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
if (result === undefined) throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
this.publishStatus(session);
@@ -1282,6 +1370,7 @@ export class PiSessionService implements SessionRouteService {
async setThinkingLevel(ref: PiSessionLookup, level: string): Promise<ClientSessionStatus> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.assertTreeNavigationInactive(session, "change the thinking level");
// pi owns the valid set; validate against the session's live levels rather
// than a hardcoded union so this stays correct if pi changes the set.
const available = session.getAvailableThinkingLevels();
@@ -1296,6 +1385,7 @@ export class PiSessionService implements SessionRouteService {
async cycleThinkingLevel(ref: PiSessionLookup): Promise<ClientSessionStatus> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.assertTreeNavigationInactive(session, "change the thinking level");
const level = session.cycleThinkingLevel();
if (level === undefined) throw new Error("Current model does not support thinking");
this.publishActivity(session, `thinking: ${level}`, "idle");
@@ -1330,6 +1420,7 @@ export class PiSessionService implements SessionRouteService {
const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.assertTreeNavigationInactive(session, "send a prompt");
this.maybeGenerateSessionName(session, promptText);
const isQueued = session.isStreaming || session.isCompacting;
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
@@ -1349,7 +1440,7 @@ export class PiSessionService implements SessionRouteService {
this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
if (behavior === undefined && echoUserMessage) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
const promptOptions = buildPromptOptions(behavior, images);
const promptPromise = session.prompt(text, promptOptions).catch((error: unknown) => {
const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "error", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
@@ -1378,6 +1469,7 @@ export class PiSessionService implements SessionRouteService {
await this.assertWritable(ref);
const active = await this.getActive(ref);
const { session } = active.runtime;
this.assertTreeNavigationInactive(session, "run a shell command");
const isExcluded = text.startsWith("!!");
const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
if (!command) throw new Error("Usage: !<shell command>");
@@ -1385,11 +1477,11 @@ export class PiSessionService implements SessionRouteService {
this.publishActivity(session, "running bash", "active", command);
this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
void session.executeBash(command, (chunk) => {
void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
this.publishActivity(session, "running bash", "active", command);
this.publishStatus(session);
}, { excludeFromContext: isExcluded }).then((result) => {
}, { excludeFromContext: isExcluded })).then((result) => {
this.events.publish(session.sessionId, {
type: "shell.end",
output: result.output,
@@ -1421,43 +1513,105 @@ export class PiSessionService implements SessionRouteService {
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
}
async navigateTree(ref: PiSessionLookup, request: ClientSessionTreeNavigateRequest): Promise<ClientSessionTreeNavigateResult> {
if (request.targetId.trim() === "") throw new Error("Session tree target is required");
if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
throw new Error("Stop current session activity before navigating the session tree");
}
await this.assertWritable(ref);
const options = sessionTreeNavigationOptions(request);
const session = await this.getOrOpen(ref);
if (typeof session.navigateTree !== "function") throw new Error("Session tree navigation is not supported by this Pi runtime");
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before navigating the session tree");
// Acquire synchronously after the active-work check. No leaf-producing work
// may enter this runtime until Pi's potentially asynchronous summary settles.
this.treeNavigations.add(session);
try {
if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
}
this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
this.publishStatus(session);
const result = await session.navigateTree(request.targetId, options);
if (result.cancelled) {
if (this.isCurrentActiveSession(session)) {
this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
}
return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
}
if (this.isCurrentActiveSession(session)) this.publishActivity(session, "session tree navigated", "idle");
return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (this.isCurrentActiveSession(session)) {
this.publishActivity(session, "tree navigation failed", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
}
throw error;
} finally {
this.treeNavigations.delete(session);
if (this.isCurrentActiveSession(session)) {
this.flushDeferredTreeNavigationWork(session);
this.publishStatus(session);
} else {
this.deferredGeneratedSessionNames.delete(session);
this.deferredSubsessionNotifications.delete(session);
}
}
}
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
this.publishActivity(session, "reloading resources", "active");
const priorGeneration = this.notificationGenerationBySession.get(session);
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
await session.reload(priorGeneration === undefined ? undefined : {
beforeSessionStart: () => {
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
this.notificationGenerationBySession.set(session, candidateGeneration);
this.replaceSessionNotificationContext(session, candidateGeneration);
},
});
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
}
this.publishActivity(session, "resources reloaded", "idle");
this.publishStatus(session);
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
this.notificationGenerationBySession.set(session, candidateGeneration);
}
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "reload failed", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
this.publishStatus(session);
throw error;
}
await this.runTreeExclusiveOperation(
[{ sessionId: session.sessionId, session }],
"Stop current session activity before reloading",
async () => {
this.publishActivity(session, "reloading resources", "active");
const priorGeneration = this.notificationGenerationBySession.get(session);
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
await session.reload(priorGeneration === undefined ? undefined : {
beforeSessionStart: () => {
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
this.notificationGenerationBySession.set(session, candidateGeneration);
this.replaceSessionNotificationContext(session, candidateGeneration);
},
});
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
}
this.publishActivity(session, "resources reloaded", "idle");
this.publishStatus(session);
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
this.notificationGenerationBySession.set(session, candidateGeneration);
}
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "reload failed", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
this.publishStatus(session);
throw error;
}
},
);
}
async archive(ref: PiSessionLookup): Promise<void> {
const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
const archiveInput = await this.archiveInputForSession(session);
await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStore.archive(archiveInput);
await this.runTreeExclusiveOperation(
[{ sessionId: session.sessionId, session }],
"Stop current session activity before archiving",
async () => {
const archiveInput = await this.archiveInputForSession(session);
await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStore.archive(archiveInput);
},
);
}
async archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
@@ -1499,23 +1653,42 @@ export class PiSessionService implements SessionRouteService {
}
}
const readyInputs: ArchiveSessionInput[] = [];
const readyPlanItems: { input: ArchiveSessionInput; active?: ActiveSession<PiSessionRuntime> }[] = [];
for (const item of planItems) {
try {
await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
readyInputs.push(item.input);
} catch (error: unknown) {
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
const active = this.activeForLookup({ id: item.input.sessionId, cwd: item.input.cwd });
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
continue;
}
readyPlanItems.push(active === undefined ? item : { ...item, active });
}
const readyInputs: ArchiveSessionInput[] = [];
const archivedSessionIds = [...alreadyArchivedSessionIds];
try {
const archived = await this.archiveStoreArchiveMany(readyInputs);
archivedSessionIds.push(...archived.map((record) => record.sessionId));
} catch (error: unknown) {
for (const input of readyInputs) failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
}
await this.runTreeExclusiveOperation(
readyPlanItems.map(({ input, active }) => ({
sessionId: input.sessionId,
...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
})),
"Stop current session activity before archiving",
async () => {
for (const item of readyPlanItems) {
try {
await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
readyInputs.push(item.input);
} catch (error: unknown) {
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
}
}
try {
const archived = await this.archiveStoreArchiveMany(readyInputs);
archivedSessionIds.push(...archived.map((record) => record.sessionId));
} catch (error: unknown) {
for (const input of readyInputs) failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
}
},
);
return {
archived: true,
@@ -1533,12 +1706,21 @@ export class PiSessionService implements SessionRouteService {
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
for (const target of plan.targets) {
if (target.archived) this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
}
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
for (const input of archiveInputs) await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStoreArchiveMany(archiveInputs);
await this.runTreeExclusiveOperation(
plan.unarchivedTargets.map((target) => ({
sessionId: target.id,
...(target.activeSession === undefined ? {} : { session: target.activeSession }),
})),
`Stop current session activity before archiving ${sessionDisplayName(session)}`,
async () => {
for (const target of plan.targets) {
if (target.archived) this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
}
for (const input of archiveInputs) await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStoreArchiveMany(archiveInputs);
},
);
return {
archived: true,
@@ -1625,28 +1807,35 @@ export class PiSessionService implements SessionRouteService {
const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
const priorGeneration = this.notificationGenerationBySession.get(session);
const { sessionId, cwd } = notificationIdentityForSession(session);
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
await this.closeActive(
sessionId,
priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS,
);
candidateGeneration = priorGeneration === undefined
? undefined
: this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
}
this.publishStatus(reopened.runtime.session);
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
}
throw error;
}
const reopenedSession = await this.runTreeExclusiveOperation(
[{ sessionId: session.sessionId, session }],
"Stop current session activity before reloading",
async () => {
const priorGeneration = this.notificationGenerationBySession.get(session);
const { sessionId, cwd } = notificationIdentityForSession(session);
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
await this.closeActive(
sessionId,
priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS,
);
candidateGeneration = priorGeneration === undefined
? undefined
: this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
}
return reopened.runtime.session;
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
}
throw error;
}
},
);
this.publishStatus(reopenedSession);
}
async detachParent(ref: PiSessionLookup): Promise<void> {
@@ -1680,9 +1869,16 @@ export class PiSessionService implements SessionRouteService {
const sessionId = active.runtime.session.sessionId;
this.clearCompactionPromptQueue(sessionId);
clearSessionQueue(active.runtime.session);
await active.runtime.session.abort();
this.publishActivity(active.runtime.session, "stopped", "idle");
this.publishStatus(active.runtime.session);
try {
await this.abortSessionOperations(active.runtime.session);
this.publishActivity(active.runtime.session, "stopped", "idle");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(active.runtime.session, "stop failed", "error", message);
throw error;
} finally {
this.publishStatus(active.runtime.session);
}
}
async stop(ref: PiSessionLookup): Promise<void> {
@@ -1906,12 +2102,33 @@ export class PiSessionService implements SessionRouteService {
active.unsubscribe();
active.runtime.setRebindSession(undefined);
try {
await active.runtime.session.abort();
await this.abortSessionOperations(active.runtime.session);
} finally {
await active.runtime.dispose();
}
}
private async abortSessionOperations(session: PiAgentSession): Promise<void> {
let branchSummaryAbortFailed = false;
let branchSummaryAbortError: unknown;
try {
session.abortBranchSummary?.();
} catch (error: unknown) {
branchSummaryAbortFailed = true;
branchSummaryAbortError = error;
}
try {
await session.abort();
} catch (abortError: unknown) {
if (branchSummaryAbortFailed) {
throw new AggregateError([branchSummaryAbortError, abortError], "Failed to abort session operations", { cause: abortError });
}
throw abortError;
}
if (branchSummaryAbortFailed) throw branchSummaryAbortError;
}
private async assertWritable(ref: PiSessionLookup): Promise<void> {
if (await this.getArchived(ref) !== undefined) throw new Error("Archived sessions are read-only. Restore the session to continue.");
}
@@ -1979,6 +2196,10 @@ export class PiSessionService implements SessionRouteService {
return archived;
}
private isCurrentActiveSession(session: PiAgentSession): boolean {
return this.active.get(session.sessionId)?.runtime.session === session;
}
private activeForLookup(ref: PiSessionLookup): ActiveSession<PiSessionRuntime> | undefined {
const sessionId = sessionIdFromLookup(ref);
const exact = this.active.get(sessionId);
@@ -2260,10 +2481,37 @@ export class PiSessionService implements SessionRouteService {
private applyGeneratedSessionName(session: PiAgentSession, name: string | undefined): void {
if (name === undefined || session.sessionName !== undefined) return;
if (this.treeNavigations.has(session)) {
this.deferredGeneratedSessionNames.set(session, name);
return;
}
session.setSessionName(name);
this.publishSessionName(session);
}
private flushDeferredTreeNavigationWork(session: PiAgentSession): void {
const generatedName = this.deferredGeneratedSessionNames.get(session);
this.deferredGeneratedSessionNames.delete(session);
if (generatedName !== undefined) {
try {
this.applyGeneratedSessionName(session, generatedName);
} catch (error: unknown) {
this.logger.info(
{ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) },
"failed to apply deferred session name",
);
}
}
const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
this.deferredSubsessionNotifications.delete(session);
for (const notification of notifications) {
void this.deliverSubsessionNotification(session, notification).catch((error: unknown) => {
this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
});
}
}
applyAuthChange(change: AuthChange = {}): void {
// ModelRuntime.login()/logout() refresh the shared runtime before AuthService
// emits the change, so no refresh is needed here. Keeping this synchronous
@@ -2329,6 +2577,8 @@ export class PiSessionService implements SessionRouteService {
}
private activityLabelFromStatus(session: PiAgentSession): string {
if (this.treeNavigations.has(session)) return "navigating session tree";
if (this.isSessionEntryMutationActive(session)) return "updating session";
if (session.isCompacting) return "compacting";
if (session.isBashRunning) return "running bash";
if (session.isStreaming) return "agent running";
@@ -2337,7 +2587,85 @@ export class PiSessionService implements SessionRouteService {
}
private hasActiveWork(session: PiAgentSession): boolean {
return sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
return this.treeNavigations.has(session)
|| this.isSessionEntryMutationActive(session)
|| this.isTreeExclusiveOperationActive(session)
|| sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
}
private async runTreeExclusiveOperation<T>(
targets: readonly TreeExclusiveOperationTarget[],
activeError: string,
operation: () => Promise<T>,
): Promise<T> {
const sessionIds = new Set<string>();
const runtimes = new Set<PiSessionRuntime>();
for (const target of targets) {
const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
const session = target.session ?? runtime?.session;
if (session !== undefined && this.hasActiveWork(session)) throw new Error(activeError);
sessionIds.add(target.sessionId);
if (runtime !== undefined) runtimes.add(runtime);
}
for (const sessionId of sessionIds) {
this.treeExclusiveSessionOperationCounts.set(sessionId, (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) + 1);
}
for (const runtime of runtimes) {
this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
}
try {
return await operation();
} finally {
for (const runtime of runtimes) decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
for (const sessionId of sessionIds) decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
}
}
private isTreeExclusiveSessionIdentityActive(sessionId: string): boolean {
return (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) > 0;
}
private isTreeExclusiveOperationActive(session: PiAgentSession): boolean {
if (this.isTreeExclusiveSessionIdentityActive(session.sessionId)) return true;
const runtime = this.activeRuntimeForSession(session);
return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
}
private activeRuntimeForSession(session: PiAgentSession): PiSessionRuntime | undefined {
for (const active of new Set(this.active.values())) {
if (active.runtime.session === session) return active.runtime;
}
return undefined;
}
private assertTreeNavigationInactive(session: PiAgentSession, action: string): void {
if (this.treeNavigations.has(session)) throw new Error(`Cannot ${action} while session tree navigation is active`);
}
private async runSessionEntryMutation<T>(session: PiAgentSession, action: string, operation: () => Promise<T>): Promise<T> {
this.beginSessionEntryMutation(session, action);
try {
return await operation();
} finally {
this.endSessionEntryMutation(session);
}
}
private beginSessionEntryMutation(session: PiAgentSession, action: string): void {
this.assertTreeNavigationInactive(session, action);
this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
}
private endSessionEntryMutation(session: PiAgentSession): void {
const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
if (remaining <= 0) this.sessionEntryMutationCounts.delete(session);
else this.sessionEntryMutationCounts.set(session, remaining);
}
private isSessionEntryMutationActive(session: PiAgentSession): boolean {
return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
}
private publishActivityForEvent(session: PiAgentSession, event: unknown): void {
@@ -139,6 +139,71 @@ describe("SessionCommandService", () => {
expect(reloadSession).not.toHaveBeenCalled();
});
it("returns an injected full tree snapshot for /tree without creating a select request", async () => {
const active = activeSession();
const tree = {
nodes: [{ id: "root", parentId: null, kind: "user" as const, summary: "hello" }],
activeLeafId: "root",
activePathIds: ["root"],
};
const getSessionTree = vi.fn(() => tree);
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { getSessionTree });
await expect(service.run("s1", "/tree")).resolves.toEqual({ type: "tree", tree });
expect(getSessionTree).toHaveBeenCalledWith(active.runtime.session);
expect(active.runtime.fork).not.toHaveBeenCalled();
});
it("rejects /tree for active, empty, and unavailable runtimes", async () => {
const active = activeSession();
let externallyActive = true;
const getSessionTree = vi.fn(() => ({ nodes: [], activeLeafId: null, activePathIds: [] }));
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
getSessionTree,
hasActiveWork: () => externallyActive,
});
await expect(service.run("s1", "/tree")).resolves.toEqual({
type: "unsupported",
message: "Cannot open the session tree while the session is active. Stop current activity and try /tree again.",
});
expect(getSessionTree).not.toHaveBeenCalled();
externallyActive = false;
await expect(service.run("s1", "/tree")).resolves.toEqual({
type: "unsupported",
message: "Cannot navigate an empty session tree.",
});
const unavailable = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
await expect(unavailable.run("s1", "/tree")).resolves.toEqual({
type: "unsupported",
message: "Session tree navigation is not available with this Pi runtime.",
});
});
it("blocks commands and pending command responses while a tree navigation owns the session gate", async () => {
const active = activeSession();
let navigationActive = false;
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
isTreeNavigationActive: () => navigationActive,
});
const pendingFork = await service.run("s1", "/fork");
if (pendingFork.type !== "select") throw new Error("Expected select result");
navigationActive = true;
await expect(service.run("s1", "/name changed")).resolves.toEqual({
type: "unsupported",
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
});
await expect(service.respond("s1", pendingFork.requestId, "m1")).resolves.toEqual({
type: "unsupported",
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
});
expect(active.runtime.session.setSessionName).not.toHaveBeenCalled();
expect(active.runtime.fork).not.toHaveBeenCalled();
});
it("creates fork selection requests from newest message to oldest and responds with selected entry", async () => {
const active = activeSession({
getUserMessagesForForking: vi.fn(() => [
@@ -202,6 +267,43 @@ describe("SessionCommandService", () => {
expect(cloned.setSessionName).toHaveBeenCalledWith("Build auth — Copy 2");
});
it("does not start a clone if tree navigation takes the gate during async name lookup", async () => {
const active = activeSession();
const names = deferred<readonly string[]>();
const listSessionNames = vi.fn(() => names.promise);
let navigationActive = false;
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
isTreeNavigationActive: () => navigationActive,
}, { listSessionNames });
const clone = service.run("s1", "/clone");
await vi.waitFor(() => { expect(listSessionNames).toHaveBeenCalledOnce(); });
navigationActive = true;
names.resolve([]);
await expect(clone).resolves.toEqual({
type: "unsupported",
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
});
expect(active.runtime.fork).not.toHaveBeenCalled();
});
it("clones the leaf that is current after asynchronous name lookup", async () => {
let leafId = "leaf-before-navigation";
const active = activeSession({ sessionManager: { getLeafId: () => leafId } });
const names = deferred<readonly string[]>();
const listSessionNames = vi.fn(() => names.promise);
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {}, { listSessionNames });
const clone = service.run("s1", "/clone");
await vi.waitFor(() => { expect(listSessionNames).toHaveBeenCalledOnce(); });
leafId = "leaf-after-navigation";
names.resolve([]);
await expect(clone).resolves.toMatchObject({ type: "done", message: "Session cloned" });
expect(active.runtime.fork).toHaveBeenCalledWith("leaf-after-navigation", { position: "at" });
});
it("rejects fork and clone while the session has active work", async () => {
const active = activeSession({ isStreaming: true });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
@@ -232,3 +334,11 @@ describe("SessionCommandService", () => {
expect(active.runtime.fork).not.toHaveBeenCalled();
});
});
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
+66 -10
View File
@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import type { SessionUiEvent } from "../../shared/apiTypes.js";
import type { ClientCommandResult, ClientSession } from "../types.js";
import type { ClientCommandResult, ClientSession, ClientSessionTreeSnapshot } from "../types.js";
import { isBuiltinCommand } from "./builtinCommands.js";
export interface CommandSession {
@@ -51,6 +51,10 @@ export interface SessionCommandLifecycle<TSession extends CommandSession = Comma
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
reloadSession?: (session: TSession) => Promise<void>;
getSessionTree?: (session: TSession) => ClientSessionTreeSnapshot | undefined;
hasActiveWork?: (session: TSession) => boolean;
isTreeNavigationActive?: (session: TSession) => boolean;
runSessionReplacement?: <T>(session: TSession, operation: () => Promise<T>) => Promise<T>;
}
export interface SessionCommandNaming {
@@ -81,6 +85,8 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
const [name = "", ...args] = text.trim().replace(/^\//, "").split(/\s+/);
const rest = args.join(" ").trim();
if (this.lifecycle.isTreeNavigationActive?.(session) === true) return treeNavigationActiveUnsupported();
if (!isBuiltinCommand(name)) {
if (this.isRuntimeCommand(session, name)) {
// The command is forwarded to the agent, which expands it (e.g. /skill:*
@@ -99,6 +105,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
if (name === "reload") return this.reload(session);
if (name === "clone") return this.clone(active);
if (name === "fork") return this.fork(active);
if (name === "tree") return this.tree(session);
return { type: "unsupported", message: `/${name} is not implemented in the web UI yet` };
}
@@ -109,11 +116,17 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
this.pendingSelects.delete(requestId);
const active = await this.getActive(sessionId);
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
const relatedName = await this.nextRelatedSessionName(active, "fork");
const result = await active.runtime.fork(value);
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
const result = await this.runSessionReplacement(active.runtime, async () => {
const forkResult = await active.runtime.fork(value);
if (!forkResult.cancelled) this.tryNameRelatedSession(active.runtime.session, relatedName);
return forkResult;
});
if (result.cancelled) return { type: "done", message: "Fork cancelled" };
this.tryNameRelatedSession(active.runtime.session, relatedName);
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime), ...promptDraft(result.selectedText) };
}
@@ -145,7 +158,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
}
private async reload(session: TSession): Promise<ClientCommandResult> {
if (sessionHasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
if (this.hasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
if (this.lifecycle.reloadSession === undefined) return { type: "unsupported", message: "/reload is not available for this session runtime." };
try {
@@ -158,18 +171,27 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
}
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
const initialLeafId = active.runtime.session.sessionManager.getLeafId();
if (initialLeafId === null || initialLeafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
const relatedName = await this.nextRelatedSessionName(active, "copy");
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
// The active leaf may have changed while related-session names were loaded.
// Clone the position that is current when the serialized replacement begins.
const leafId = active.runtime.session.sessionManager.getLeafId();
if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
const relatedName = await this.nextRelatedSessionName(active, "copy");
const result = await active.runtime.fork(leafId, { position: "at" });
const result = await this.runSessionReplacement(active.runtime, async () => {
const cloneResult = await active.runtime.fork(leafId, { position: "at" });
if (!cloneResult.cancelled) this.tryNameRelatedSession(active.runtime.session, relatedName);
return cloneResult;
});
if (result.cancelled) return { type: "done", message: "Clone cancelled" };
this.tryNameRelatedSession(active.runtime.session, relatedName);
return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) };
}
private fork(active: CommandActiveSession<TSession>): ClientCommandResult {
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
const messages = active.runtime.session.getUserMessagesForForking();
if (!messages.length) return { type: "unsupported", message: "No user messages to fork from" };
const requestId = crypto.randomUUID();
@@ -182,6 +204,32 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
};
}
private tree(session: TSession): ClientCommandResult {
if (this.hasActiveWork(session)) {
return { type: "unsupported", message: "Cannot open the session tree while the session is active. Stop current activity and try /tree again." };
}
if (this.lifecycle.getSessionTree === undefined) return treeUnavailableUnsupported();
try {
const tree = this.lifecycle.getSessionTree(session);
if (tree === undefined) return treeUnavailableUnsupported();
if (tree.nodes.length === 0) return { type: "unsupported", message: "Cannot navigate an empty session tree." };
return { type: "tree", tree };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { type: "unsupported", message: `Unable to open the session tree: ${message}` };
}
}
private hasActiveWork(session: TSession): boolean {
return sessionHasActiveWork(session) || this.lifecycle.hasActiveWork?.(session) === true;
}
private runSessionReplacement<T>(runtime: CommandRuntime<TSession>, operation: () => Promise<T>): Promise<T> {
const runReplacement = this.lifecycle.runSessionReplacement;
return runReplacement === undefined ? operation() : runReplacement(runtime.session, operation);
}
private async nextRelatedSessionName(active: CommandActiveSession<TSession>, kind: RelatedSessionKind): Promise<string> {
const sourceTitle = relatedSessionSourceTitle(active.runtime.session);
const sourceName = normalizedName(active.runtime.session.sessionName);
@@ -291,6 +339,14 @@ function forkActiveUnsupported(command: "fork" | "clone"): ClientCommandResult {
return { type: "unsupported", message: `Cannot ${command} while the session is active. Stop current activity before ${command === "fork" ? "forking" : "cloning"}.` };
}
function treeUnavailableUnsupported(): ClientCommandResult {
return { type: "unsupported", message: "Session tree navigation is not available with this Pi runtime." };
}
function treeNavigationActiveUnsupported(): ClientCommandResult {
return { type: "unsupported", message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first." };
}
function promptDraft(text: string | undefined): Partial<Pick<Extract<ClientCommandResult, { type: "done" }>, "promptDraft">> {
return text === undefined ? {} : { promptDraft: text };
}
+91
View File
@@ -2,6 +2,7 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH } from "../../shared/apiTypes.js";
import type {
MessagePage,
SessionBulkArchiveResponse,
@@ -15,6 +16,8 @@ import type {
SessionRef,
SessionStatus,
SessionStreamSnapshot,
SessionTreeNavigateRequest,
SessionTreeNavigateResult,
} from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
@@ -169,6 +172,89 @@ describe("session routes", () => {
}
});
it("strictly parses cwd-scoped session tree navigation requests", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/tree/navigate",
payload: {
cwd: "/repo/./",
targetId: "entry-2",
expectedLeafId: null,
summary: { mode: "custom", instructions: " focus on tests " },
},
});
const withoutSummary = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/tree/navigate",
payload: { cwd: "/repo", targetId: "entry-1", expectedLeafId: "leaf-1", summary: { mode: "none" } },
});
const withDefaultSummary = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/tree/navigate",
payload: { cwd: "/repo", targetId: "entry-3", expectedLeafId: "leaf-2", summary: { mode: "default" } },
});
expect([response.statusCode, withoutSummary.statusCode, withDefaultSummary.statusCode]).toEqual([200, 200, 200]);
expect(response.json()).toEqual({ cancelled: false, editorText: "edit this" });
expect(routeService.navigateTreeCalls).toEqual([
{
lookup: { id: "session-1", cwd: resolve("/repo") },
request: { targetId: "entry-2", expectedLeafId: null, summary: { mode: "custom", instructions: "focus on tests" } },
},
{
lookup: { id: "session-1", cwd: resolve("/repo") },
request: { targetId: "entry-1", expectedLeafId: "leaf-1", summary: { mode: "none" } },
},
{
lookup: { id: "session-1", cwd: resolve("/repo") },
request: { targetId: "entry-3", expectedLeafId: "leaf-2", summary: { mode: "default" } },
},
]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects malformed session tree navigation unions before calling the service", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
const base = { targetId: "entry-2", expectedLeafId: "leaf-1", summary: { mode: "none" } };
const malformed: Record<string, unknown>[] = [
{ targetId: "entry-2", summary: { mode: "none" } },
{ ...base, expectedLeafId: 1 },
{ ...base, summary: { mode: "future" } },
{ ...base, summary: { mode: "none", instructions: "not allowed" } },
{ ...base, summary: { mode: "default", instructions: "not allowed" } },
{ ...base, summary: { mode: "custom" } },
{ ...base, summary: { mode: "custom", instructions: " " } },
{ ...base, summary: { mode: "custom", instructions: "x".repeat(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH + 1) } },
{ ...base, summary: { mode: "custom", instructions: "focus", extra: true } },
];
try {
for (const payload of malformed) {
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/tree/navigate", payload });
expect(response.statusCode).toBe(400);
}
expect(routeService.navigateTreeCalls).toEqual([]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects prompt payloads that omit text without opening a session", async () => {
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
@@ -534,6 +620,7 @@ class CapturingRouteSessionService implements SessionRouteService {
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
reloadError: Error | undefined;
clearQueueError: Error | undefined;
@@ -667,6 +754,10 @@ class CapturingRouteSessionService implements SessionRouteService {
shell(): never { throw unusedRouteMethod("shell"); }
runCommand(): never { throw unusedRouteMethod("runCommand"); }
respondToCommand(): never { throw unusedRouteMethod("respondToCommand"); }
navigateTree(lookup: SessionRouteLookup, request: SessionTreeNavigateRequest): Promise<SessionTreeNavigateResult> {
this.navigateTreeCalls.push({ lookup, request });
return Promise.resolve({ cancelled: false, editorText: "edit this" });
}
abort(): never { throw unusedRouteMethod("abort"); }
stop(): never { throw unusedRouteMethod("stop"); }
archive(): never { throw unusedRouteMethod("archive"); }
+57 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanupRequest } from "../../shared/apiTypes.js";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice } from "../../shared/apiTypes.js";
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
@@ -286,6 +286,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
}
});
app.post<{ Params: { sessionId: string }; Body: unknown }>(`${prefix}/sessions/:sessionId/tree/navigate`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.navigateTree(sessionLookupFromBody(request.params.sessionId, body), sessionTreeNavigateRequestFromBody(body));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/abort`, async (request, reply) => {
try {
await sessions.abort(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
@@ -423,6 +432,38 @@ function sessionLookupFromCwd(id: string, cwd: string | undefined): SessionLooku
return cwd === undefined || cwd === "" ? id : { id, cwd: normalizeRequestCwd(cwd) };
}
function sessionTreeNavigateRequestFromBody(body: Record<string, unknown>): SessionTreeNavigateRequest {
const targetId = requireNonEmptyString(body, "targetId");
const expectedLeafId = requireNullableString(body, "expectedLeafId");
return { targetId, expectedLeafId, summary: sessionTreeSummaryChoice(body["summary"]) };
}
function sessionTreeSummaryChoice(value: unknown): SessionTreeSummaryChoice {
const summary = requireRecord(value);
const mode = requireString(summary, "mode");
if (mode === "none" || mode === "default") {
if (Object.hasOwn(summary, "instructions")) throw new Error(`instructions field is not valid for ${mode} summary mode`);
requireExactFields(summary, ["mode"], "summary");
return { mode };
}
if (mode === "custom") {
requireExactFields(summary, ["mode", "instructions"], "summary");
const instructions = requireString(summary, "instructions");
if (instructions.trim() === "") throw new Error("instructions field must not be blank");
if (instructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
throw new Error(`instructions field must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
}
return { mode, instructions: instructions.trim() };
}
throw new Error("summary mode is invalid");
}
function requireExactFields(record: Record<string, unknown>, fields: readonly string[], label: string): void {
const allowed = new Set(fields);
const unexpected = Object.keys(record).find((field) => !allowed.has(field));
if (unexpected !== undefined) throw new Error(`${label} field contains unsupported property: ${unexpected}`);
}
function optionalRecord(value: unknown): Record<string, unknown> {
if (value === undefined || value === null) return {};
return requireRecord(value);
@@ -439,6 +480,21 @@ function requireString(record: Record<string, unknown>, field: string): string {
return value;
}
function requireNonEmptyString(record: Record<string, unknown>, field: string): string {
const value = requireString(record, field);
if (value.trim() === "") throw new Error(`${field} field must not be empty`);
return value;
}
function requireNullableString(record: Record<string, unknown>, field: string): string | null {
if (!Object.hasOwn(record, field)) throw new Error(`${field} field is required`);
const value = record[field];
if (value === null) return null;
if (typeof value !== "string") throw new Error(`${field} field must be a string or null`);
if (value.trim() === "") throw new Error(`${field} field must not be empty`);
return value;
}
function requireNonEmptyBoundedString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== "string") throw new Error(`${field} field must be a string`);
if (value === "") throw new Error(`${field} field must not be empty`);
+3
View File
@@ -19,6 +19,8 @@ import type {
ClientSessionModel,
ClientSessionRef,
ClientSessionStatus,
ClientSessionTreeNavigateRequest,
ClientSessionTreeNavigateResult,
ClientThinkingLevel,
SessionStreamSnapshot,
} from "../types.js";
@@ -62,6 +64,7 @@ export interface SessionRouteService {
shell(ref: SessionRouteLookup, text: string): Promise<void>;
runCommand(ref: SessionRouteLookup, text: string): Promise<ClientCommandResult>;
respondToCommand(ref: SessionRouteLookup, requestId: string, value: string): Promise<ClientCommandResult>;
navigateTree(ref: SessionRouteLookup, request: ClientSessionTreeNavigateRequest): Promise<ClientSessionTreeNavigateResult>;
abort(ref: SessionRouteLookup): Promise<void>;
stop(ref: SessionRouteLookup): void | Promise<void>;
archive(ref: SessionRouteLookup): Promise<void>;
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import { projectSessionTree, type ProjectableSessionTreeNode } from "./sessionTreeProjection.js";
function treeNode(
entry: Record<string, unknown>,
children: ProjectableSessionTreeNode[] = [],
label?: unknown,
): ProjectableSessionTreeNode {
return { entry, children, ...(label === undefined ? {} : { label }) };
}
function entry(id: string, parentId: string | null, type: string, patch: Record<string, unknown> = {}): Record<string, unknown> {
return { id, parentId, type, timestamp: "2026-01-01T00:00:00.000Z", ...patch };
}
describe("projectSessionTree", () => {
it("preserves complete pre-order structure while strictly excluding raw private fields", () => {
const rootChildren: ProjectableSessionTreeNode[] = [];
const assistantChildren: ProjectableSessionTreeNode[] = [];
const root = treeNode(entry("root", null, "message", {
message: {
role: "user",
content: [
{ type: "text", text: "hello\nworld", textSignature: "secret-text-signature" },
{ type: "image", mimeType: "image/png", data: "secret-image-base64" },
],
providerState: "secret-user-provider-state",
},
}), rootChildren, " Important\nroot ");
const assistant = treeNode(entry("assistant", "root", "message", {
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "secret chain of thought", thinkingSignature: "secret-thinking-signature" },
{ type: "toolCall", name: "read", arguments: { path: "secret-tool-argument", thinkingSignature: "secret-argument-signature" }, thoughtSignature: "secret-thought-signature" },
],
usage: { privateUsage: "secret-usage" },
responseId: "secret-response-id",
stopReason: "toolUse",
},
}), assistantChildren);
const toolResult = treeNode(entry("tool", "assistant", "message", {
message: {
role: "toolResult",
toolName: "read",
content: [{ type: "text", text: "visible tool text" }],
details: { token: "secret-tool-details" },
unknownProviderField: "secret-tool-provider-field",
isError: false,
},
}));
const custom = treeNode(entry("custom", "root", "custom", {
customType: "extension-state",
data: { apiKey: "secret-extension-data" },
}));
const orphan = treeNode(entry("orphan", "missing-parent", "future_entry", {
futurePayload: "secret-unknown-payload",
}));
rootChildren.push(assistant, custom);
assistantChildren.push(toolResult);
const snapshot = projectSessionTree([root, orphan], "tool");
const serialized = JSON.stringify(snapshot);
expect(snapshot.nodes.map((node) => node.id)).toEqual(["root", "assistant", "tool", "custom", "orphan"]);
expect(snapshot.activeLeafId).toBe("tool");
expect(snapshot.activePathIds).toEqual(["root", "assistant", "tool"]);
expect(snapshot.nodes[0]).toMatchObject({ kind: "user", summary: "hello world [image]", label: "Important root" });
expect(snapshot.nodes[1]).toMatchObject({ kind: "assistant", summary: "Tool call: read" });
expect(snapshot.nodes[2]).toMatchObject({ kind: "tool-result", summary: "Tool result (read): visible tool text" });
expect(snapshot.nodes[3]).toMatchObject({ kind: "custom", summary: "Custom entry: extension-state" });
expect(snapshot.nodes[4]).toMatchObject({ kind: "other", summary: "Entry: future_entry" });
for (const secret of [
"secret-text-signature",
"secret-image-base64",
"secret-user-provider-state",
"secret chain of thought",
"secret-thinking-signature",
"secret-tool-argument",
"secret-argument-signature",
"secret-thought-signature",
"secret-usage",
"secret-response-id",
"secret-tool-details",
"secret-tool-provider-field",
"secret-extension-data",
"secret-unknown-payload",
]) expect(serialized).not.toContain(secret);
for (const privateKey of ["thinkingSignature", "thoughtSignature", "arguments", "usage", "details", "data", "mimeType"]) {
expect(serialized).not.toContain(privateKey);
}
});
it("projects each supported entry kind from only its safe display fields", () => {
const roots = [
treeNode(entry("assistant-error", null, "message", { message: { role: "assistant", content: [{ type: "thinking", thinking: "private" }], stopReason: "error", errorMessage: "private provider error" } })),
treeNode(entry("tool-error", null, "message", { message: { role: "toolResult", toolName: "bash", content: [{ type: "image", data: "private-image", mimeType: "image/png" }], details: "private", isError: true } })),
treeNode(entry("bash", null, "message", { message: { role: "bashExecution", command: "npm test", output: "private shell output", fullOutputPath: "/private/path" } })),
treeNode(entry("custom-visible", null, "custom_message", { customType: "notice", content: "visible notice", display: true, details: "private" })),
treeNode(entry("custom-hidden", null, "custom_message", { customType: "private-custom-type", content: "private hidden content", display: false })),
treeNode(entry("compaction", null, "compaction", { summary: "compact summary", details: "private" })),
treeNode(entry("branch", null, "branch_summary", { summary: "branch summary", details: "private" })),
treeNode(entry("model", null, "model_change", { provider: "anthropic", modelId: "claude" })),
treeNode(entry("thinking", null, "thinking_level_change", { thinkingLevel: "high" })),
treeNode(entry("info", null, "session_info", { name: "Tree work" })),
treeNode(entry("label", null, "label", { targetId: "private-target", label: "checkpoint" })),
];
const snapshot = projectSessionTree(roots, null);
const byId = new Map(snapshot.nodes.map((node) => [node.id, node]));
expect(byId.get("assistant-error")).toMatchObject({ kind: "assistant", summary: "Assistant error" });
expect(byId.get("tool-error")).toMatchObject({ kind: "tool-result", summary: "Tool error (bash): [image]" });
expect(byId.get("bash")).toMatchObject({ kind: "bash", summary: "Shell: npm test" });
expect(byId.get("custom-visible")).toMatchObject({ kind: "custom-message", summary: "Custom message (notice): visible notice" });
expect(byId.get("custom-hidden")).toMatchObject({ kind: "custom-message", summary: "Hidden custom message" });
expect(byId.get("compaction")).toMatchObject({ kind: "compaction", summary: "compact summary" });
expect(byId.get("branch")).toMatchObject({ kind: "branch-summary", summary: "branch summary" });
expect(byId.get("model")).toMatchObject({ kind: "model-change", summary: "Model: anthropic/claude" });
expect(byId.get("thinking")).toMatchObject({ kind: "thinking-level-change", summary: "Thinking level: high" });
expect(byId.get("info")).toMatchObject({ kind: "session-info", summary: "Session name: Tree work" });
expect(byId.get("label")).toMatchObject({ kind: "label", summary: "Label: checkpoint" });
expect(JSON.stringify(snapshot)).not.toContain("private");
});
it("rejects malformed SDK node wrappers and empty entry identities with clear boundary errors", () => {
const malformedChild = {
entry: entry("root", null, "custom", { customType: "root" }),
children: [null],
};
const malformedChildren = {
entry: entry("root", null, "custom", { customType: "root" }),
children: "not-an-array",
};
expect(() => { Reflect.apply(projectSessionTree, undefined, [[malformedChild], null]); }).toThrow("Pi returned a malformed session-tree node");
expect(() => { Reflect.apply(projectSessionTree, undefined, [[malformedChildren], null]); }).toThrow("Pi returned a malformed session-tree node");
expect(() => projectSessionTree([treeNode(entry("", null, "custom"))], null)).toThrow("Pi returned a malformed session-tree entry");
expect(() => projectSessionTree([treeNode(entry(" ", null, "custom"))], null)).toThrow("Pi returned a malformed session-tree entry");
expect(() => projectSessionTree([treeNode(entry("child", " ", "custom"))], null)).toThrow("Pi returned a malformed session-tree entry");
expect(() => projectSessionTree([
treeNode(entry("duplicate", null, "custom")),
treeNode(entry("duplicate", null, "custom")),
], null)).toThrow("Pi returned duplicate session-tree entry IDs");
expect(() => projectSessionTree([treeNode(entry("root", null, "custom"))], "missing")).toThrow("Pi returned an invalid active session-tree leaf");
});
it("keeps an existing active orphan as a one-node active path", () => {
const snapshot = projectSessionTree([treeNode(entry("orphan", "missing-parent", "custom"))], "orphan");
expect(snapshot.activeLeafId).toBe("orphan");
expect(snapshot.activePathIds).toEqual(["orphan"]);
});
it("bounds summaries and traverses deep trees and malformed parent chains without recursion", () => {
const depth = 5_000;
let current = treeNode(entry(`node-${String(depth - 1)}`, `node-${String(depth - 2)}`, "message", {
message: { role: "user", content: `${"word \n".repeat(200)}tail` },
}));
for (let index = depth - 2; index >= 0; index -= 1) {
current = treeNode(entry(`node-${String(index)}`, index === 0 ? null : `node-${String(index - 1)}`, "message", {
message: { role: "user", content: `message ${String(index)}` },
}), [current]);
}
const snapshot = projectSessionTree([current], `node-${String(depth - 1)}`);
const leaf = snapshot.nodes.at(-1);
expect(snapshot.nodes).toHaveLength(depth);
expect(snapshot.activePathIds).toHaveLength(depth);
expect(leaf?.summary.length).toBeLessThanOrEqual(360);
expect(leaf?.summary).not.toContain("\n");
expect(leaf?.summary.endsWith("…")).toBe(true);
const cycleAChildren: ProjectableSessionTreeNode[] = [];
const cycleBChildren: ProjectableSessionTreeNode[] = [];
const cycleA = treeNode(entry("cycle-a", "cycle-b", "custom", { customType: "a" }), cycleAChildren);
const cycleB = treeNode(entry("cycle-b", "cycle-a", "custom", { customType: "b" }), cycleBChildren);
cycleAChildren.push(cycleB);
cycleBChildren.push(cycleA);
const cyclicSnapshot = projectSessionTree([cycleA], "cycle-a");
expect(cyclicSnapshot.nodes.map((node) => node.id)).toEqual(["cycle-a", "cycle-b"]);
expect(cyclicSnapshot.activePathIds).toEqual(["cycle-b", "cycle-a"]);
});
});
@@ -0,0 +1,270 @@
import type { SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../shared/apiTypes.js";
const SUMMARY_MAX_LENGTH = 360;
const SUMMARY_SOURCE_MAX_LENGTH = SUMMARY_MAX_LENGTH * 2;
const LABEL_MAX_LENGTH = 160;
const NAMED_FIELD_MAX_LENGTH = 80;
const TIMESTAMP_MAX_LENGTH = 80;
/** Narrow structural view of the tree returned by Pi's SessionManager.getTree(). */
export interface ProjectableSessionTreeNode {
readonly entry: unknown;
readonly children: readonly ProjectableSessionTreeNode[];
readonly label?: unknown;
}
interface ProjectableSessionEntry extends Record<string, unknown> {
id: string;
parentId: string | null;
type: string;
}
interface EntryProjection {
kind: SessionTreeNodeKind;
summary: string;
}
/**
* Project Pi's complete append-only session tree into the strict browser contract.
* Only explicitly selected plain-text fields leave this boundary.
*/
export function projectSessionTree(
roots: readonly ProjectableSessionTreeNode[],
activeLeafId: string | null,
): SessionTreeSnapshot {
const nodes: SessionTreeNode[] = [];
const projectedIds = new Set<string>();
const stack = [...roots].reverse();
const visitedNodes = new WeakSet<ProjectableSessionTreeNode>();
while (stack.length > 0) {
const candidate = stack.pop();
if (candidate === undefined) continue;
if (!isProjectableSessionTreeNode(candidate)) throw new Error("Pi returned a malformed session-tree node");
const node = candidate;
if (visitedNodes.has(node)) continue;
visitedNodes.add(node);
if (!isProjectableSessionEntry(node.entry)) {
throw new Error("Pi returned a malformed session-tree entry");
}
if (projectedIds.has(node.entry.id)) throw new Error("Pi returned duplicate session-tree entry IDs");
projectedIds.add(node.entry.id);
nodes.push(projectNode(node, node.entry));
for (let index = node.children.length - 1; index >= 0; index -= 1) {
const child = node.children[index];
if (child !== undefined) stack.push(child);
}
}
if (activeLeafId !== null && (activeLeafId.trim() === "" || !projectedIds.has(activeLeafId))) {
throw new Error("Pi returned an invalid active session-tree leaf");
}
return {
nodes,
activeLeafId,
activePathIds: buildActivePath(nodes, activeLeafId),
};
}
function projectNode(node: ProjectableSessionTreeNode, entry: ProjectableSessionEntry): SessionTreeNode {
const projection = projectEntry(entry);
const timestamp = optionalPlainText(entry["timestamp"], TIMESTAMP_MAX_LENGTH);
const label = optionalPlainText(node.label, LABEL_MAX_LENGTH);
return {
id: entry.id,
parentId: entry.parentId,
kind: projection.kind,
summary: projection.summary,
...(timestamp === undefined ? {} : { timestamp }),
...(label === undefined ? {} : { label }),
};
}
function projectEntry(entry: ProjectableSessionEntry): EntryProjection {
switch (entry.type) {
case "message":
return projectMessage(entry["message"]);
case "custom_message":
return projectCustomMessage(entry);
case "compaction":
return { kind: "compaction", summary: summary(entry["summary"], "Compaction summary") };
case "branch_summary":
return { kind: "branch-summary", summary: summary(entry["summary"], "Branch summary") };
case "model_change":
return { kind: "model-change", summary: modelChangeSummary(entry) };
case "thinking_level_change":
return { kind: "thinking-level-change", summary: namedSummary("Thinking level", entry["thinkingLevel"], "Thinking level changed") };
case "session_info":
return { kind: "session-info", summary: namedSummary("Session name", entry["name"], "Session info changed") };
case "label":
return { kind: "label", summary: namedSummary("Label", entry["label"], "Label removed") };
case "custom":
return { kind: "custom", summary: namedSummary("Custom entry", entry["customType"], "Custom entry") };
default:
return { kind: "other", summary: namedSummary("Entry", entry.type, "Other session entry") };
}
}
function projectMessage(value: unknown): EntryProjection {
if (!isRecord(value)) return { kind: "other", summary: "Message" };
switch (value["role"]) {
case "user":
return { kind: "user", summary: summary(contentPreview(value["content"], true), "User message") };
case "assistant":
return { kind: "assistant", summary: assistantSummary(value) };
case "toolResult":
return { kind: "tool-result", summary: toolResultSummary(value) };
case "bashExecution":
return { kind: "bash", summary: namedSummary("Shell", value["command"], "Shell command") };
case "custom":
return projectCustomMessage(value);
case "compactionSummary":
return { kind: "compaction", summary: summary(value["summary"], "Compaction summary") };
case "branchSummary":
return { kind: "branch-summary", summary: summary(value["summary"], "Branch summary") };
default:
return { kind: "other", summary: "Message" };
}
}
function projectCustomMessage(value: Record<string, unknown>): EntryProjection {
if (value["display"] !== true) return { kind: "custom-message", summary: "Hidden custom message" };
const customType = optionalPlainText(value["customType"], NAMED_FIELD_MAX_LENGTH);
const content = contentPreview(value["content"], true);
const prefix = customType === undefined ? "Custom message" : `Custom message (${customType})`;
return { kind: "custom-message", summary: summary(content === "" ? prefix : `${prefix}: ${content}`, prefix) };
}
function assistantSummary(message: Record<string, unknown>): string {
const text = contentPreview(message["content"], false);
if (text !== "") return summary(text, "Assistant response");
const toolNames = assistantToolNames(message["content"]);
if (toolNames.length > 0) return summary(`Tool call: ${toolNames.join(", ")}`, "Assistant tool call");
if (message["stopReason"] === "error") return "Assistant error";
if (message["stopReason"] === "aborted") return "Assistant response aborted";
return "Assistant response";
}
function assistantToolNames(content: unknown): string[] {
if (!Array.isArray(content)) return [];
const names: string[] = [];
for (const part of content) {
if (!isRecord(part) || part["type"] !== "toolCall") continue;
const name = optionalPlainText(part["name"], NAMED_FIELD_MAX_LENGTH);
if (name !== undefined && !names.includes(name)) names.push(name);
if (names.length === 3) break;
}
return names;
}
function toolResultSummary(message: Record<string, unknown>): string {
const isError = message["isError"] === true;
const toolName = optionalPlainText(message["toolName"], NAMED_FIELD_MAX_LENGTH);
const prefix = toolName === undefined
? isError ? "Tool error" : "Tool result"
: `${isError ? "Tool error" : "Tool result"} (${toolName})`;
const content = contentPreview(message["content"], true);
return summary(content === "" ? prefix : `${prefix}: ${content}`, prefix);
}
function modelChangeSummary(entry: Record<string, unknown>): string {
const provider = optionalPlainText(entry["provider"], NAMED_FIELD_MAX_LENGTH);
const modelId = optionalPlainText(entry["modelId"], NAMED_FIELD_MAX_LENGTH);
if (provider !== undefined && modelId !== undefined) return summary(`Model: ${provider}/${modelId}`, "Model changed");
return namedSummary("Model", modelId ?? provider, "Model changed");
}
function contentPreview(content: unknown, includeImageMarkers: boolean): string {
const fragments: string[] = [];
let remaining = SUMMARY_SOURCE_MAX_LENGTH;
const append = (text: string): void => {
if (remaining <= 0 || text === "") return;
const fragment = text.slice(0, remaining);
fragments.push(fragment);
remaining -= fragment.length;
};
if (typeof content === "string") {
append(content);
} else if (Array.isArray(content)) {
for (const part of content) {
if (!isRecord(part)) continue;
if (part["type"] === "text" && typeof part["text"] === "string") append(part["text"]);
else if (includeImageMarkers && part["type"] === "image") append("[image]");
if (remaining <= 0) break;
}
}
return plainText(fragments.join(" "));
}
function namedSummary(prefix: string, value: unknown, fallback: string): string {
const field = optionalPlainText(value, NAMED_FIELD_MAX_LENGTH);
return field === undefined ? fallback : summary(`${prefix}: ${field}`, fallback);
}
function summary(value: unknown, fallback: string): string {
if (typeof value !== "string") return fallback;
const normalized = plainText(value);
return normalized === "" ? fallback : truncate(normalized, SUMMARY_MAX_LENGTH);
}
function optionalPlainText(value: unknown, maxLength: number): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = plainText(value);
return normalized === "" ? undefined : truncate(normalized, maxLength);
}
function plainText(value: string): string {
// Browser previews should not retain non-whitespace C0/C1 controls.
// eslint-disable-next-line no-control-regex
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ").replace(/\s+/gu, " ").trim();
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
let end = maxLength - 1;
const finalCodeUnit = value.charCodeAt(end - 1);
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) end -= 1;
return `${value.slice(0, end)}`;
}
function buildActivePath(nodes: readonly SessionTreeNode[], activeLeafId: string | null): string[] {
if (activeLeafId === null) return [];
const byId = new Map<string, SessionTreeNode>();
for (const node of nodes) {
if (!byId.has(node.id)) byId.set(node.id, node);
}
const path: string[] = [];
const visitedIds = new Set<string>();
let currentId: string | null = activeLeafId;
while (currentId !== null && !visitedIds.has(currentId)) {
const node = byId.get(currentId);
if (node === undefined) break;
visitedIds.add(currentId);
path.push(currentId);
currentId = node.parentId;
}
path.reverse();
return path;
}
function isProjectableSessionTreeNode(value: unknown): value is ProjectableSessionTreeNode {
return isRecord(value) && Array.isArray(value["children"]);
}
function isProjectableSessionEntry(value: unknown): value is ProjectableSessionEntry {
return isRecord(value)
&& typeof value["id"] === "string"
&& value["id"].trim() !== ""
&& (value["parentId"] === null || (typeof value["parentId"] === "string" && value["parentId"].trim() !== ""))
&& typeof value["type"] === "string";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}