Archived
feat: add hierarchical session tree navigator
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user