Archived
feat: add hierarchical session tree navigator
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js";
|
||||
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
|
||||
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS, SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
|
||||
import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js";
|
||||
|
||||
registerAppTestHooks();
|
||||
@@ -64,6 +64,28 @@ describe("buildApp remote machine proxy routes", () => {
|
||||
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
|
||||
});
|
||||
|
||||
it("forwards remote session tree navigation with the model-operation timeout", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ method, path, body })]),
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ request });
|
||||
const navigationBody = { cwd: "/repo", targetId: "entry-1", expectedLeafId: "leaf-1", summary: { mode: "default" } };
|
||||
|
||||
const response = await appTestContext.app.inject({
|
||||
method: "POST",
|
||||
url: `/api/machines/${remote.id}/sessions/s1/tree/navigate`,
|
||||
payload: navigationBody,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ method: "POST", path: "/api/sessions/s1/tree/navigate", body: navigationBody });
|
||||
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/tree/navigate", navigationBody, { timeoutMs: SESSION_TREE_NAVIGATION_PROXY_TIMEOUT_MS });
|
||||
});
|
||||
|
||||
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,6 +17,9 @@ export type {
|
||||
FileSuggestion as ClientFileSuggestion,
|
||||
CommandOption as ClientCommandOption,
|
||||
CommandResult as ClientCommandResult,
|
||||
SessionTreeSnapshot as ClientSessionTreeSnapshot,
|
||||
SessionTreeNavigateRequest as ClientSessionTreeNavigateRequest,
|
||||
SessionTreeNavigateResult as ClientSessionTreeNavigateResult,
|
||||
SessionActivity as ClientSessionActivity,
|
||||
SessionUiEvent,
|
||||
GlobalSessionEvent,
|
||||
|
||||
Reference in New Issue
Block a user