Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
183 lines
6.5 KiB
TypeScript
183 lines
6.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { FileEntry, SessionEntry, SessionMessageEntry } from "../../src/core/session-manager.ts";
|
|
import { SessionManager } from "../../src/core/session-manager.ts";
|
|
|
|
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
|
|
function userMessage(text: string) {
|
|
return { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() };
|
|
}
|
|
|
|
function storedEntries(build: (source: SessionManager) => void): SessionEntry[] {
|
|
const source = SessionManager.inMemory("/project");
|
|
build(source);
|
|
return source.getEntries();
|
|
}
|
|
|
|
describe("SessionManager.inMemory with preloaded entries", () => {
|
|
it("adopts entries verbatim", () => {
|
|
const entries = storedEntries((source) => {
|
|
source.appendMessage(userMessage("hello"));
|
|
source.appendModelChange("anthropic", "claude-opus-4-5");
|
|
source.appendMessage(userMessage("again"));
|
|
});
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
|
|
expect(session.getEntries()).toEqual(entries);
|
|
});
|
|
|
|
it("keeps the loaded leaf so appends continue the conversation", () => {
|
|
const entries = storedEntries((source) => {
|
|
source.appendMessage(userMessage("hello"));
|
|
source.appendMessage(userMessage("again"));
|
|
});
|
|
const lastId = entries[entries.length - 1].id;
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
const appendedId = session.appendMessage(userMessage("continued"));
|
|
|
|
expect(session.getLeafId()).toBe(appendedId);
|
|
expect(session.getEntry(appendedId)?.parentId).toBe(lastId);
|
|
});
|
|
|
|
it("never mints an id that collides with a loaded entry", () => {
|
|
const entries = storedEntries((source) => {
|
|
for (let i = 0; i < 50; i++) source.appendMessage(userMessage(`message ${i}`));
|
|
});
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
const appendedId = session.appendMessage(userMessage("continued"));
|
|
|
|
expect(entries.some((entry) => entry.id === appendedId)).toBe(false);
|
|
});
|
|
|
|
it("rebuilds the branch structure rather than a flat chain", () => {
|
|
const entries = storedEntries((source) => {
|
|
const firstId = source.appendMessage(userMessage("hello"));
|
|
source.appendMessage(userMessage("abandoned"));
|
|
source.branch(firstId);
|
|
source.appendMessage(userMessage("kept"));
|
|
});
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
const roots = session.getTree();
|
|
|
|
expect(roots).toHaveLength(1);
|
|
expect(roots[0].children).toHaveLength(2);
|
|
});
|
|
|
|
it("rebuilds labels", () => {
|
|
let labelledId = "";
|
|
const entries = storedEntries((source) => {
|
|
labelledId = source.appendMessage(userMessage("hello"));
|
|
source.appendLabelChange(labelledId, "checkpoint");
|
|
});
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
|
|
expect(session.getLabel(labelledId)).toBe("checkpoint");
|
|
});
|
|
|
|
it("resolves a compaction against the entry it was written against", () => {
|
|
let keptId = "";
|
|
const entries = storedEntries((source) => {
|
|
source.appendMessage(userMessage("dropped"));
|
|
keptId = source.appendMessage(userMessage("kept"));
|
|
source.appendCompaction("summary so far", keptId, 1000);
|
|
});
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
const context = session.buildContextEntries();
|
|
|
|
expect(context.some((entry) => entry.id === keptId)).toBe(true);
|
|
});
|
|
|
|
it("creates a header from the options when the entries carry none", () => {
|
|
const entries = storedEntries((source) => source.appendMessage(userMessage("hello")));
|
|
|
|
const session = SessionManager.inMemory("/project", { id: "restored-session" }, entries);
|
|
|
|
expect(session.getSessionId()).toBe("restored-session");
|
|
expect(session.getHeader()!.id).toBe("restored-session");
|
|
expect(session.getHeader()!.cwd).toBe("/project");
|
|
});
|
|
|
|
it("generates a session id when the options carry none", () => {
|
|
const entries = storedEntries((source) => source.appendMessage(userMessage("hello")));
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
|
|
expect(session.getSessionId()).toMatch(UUID_V7_RE);
|
|
expect(session.getHeader()!.id).toBe(session.getSessionId());
|
|
});
|
|
|
|
it("stays off the filesystem", () => {
|
|
const entries = storedEntries((source) => source.appendMessage(userMessage("hello")));
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
session.appendMessage(userMessage("continued"));
|
|
|
|
expect(session.getSessionFile()).toBeUndefined();
|
|
expect(session.isPersisted()).toBe(false);
|
|
});
|
|
|
|
it("starts an empty session when the entries are empty", () => {
|
|
const session = SessionManager.inMemory("/project", { id: "empty-session" }, []);
|
|
|
|
expect(session.getSessionId()).toBe("empty-session");
|
|
expect(session.getEntries()).toEqual([]);
|
|
expect(session.getLeafId()).toBeNull();
|
|
});
|
|
|
|
it("takes the session identity from a header among the entries", () => {
|
|
const body = storedEntries((source) => source.appendMessage(userMessage("hello")));
|
|
const entries: FileEntry[] = [
|
|
{ type: "session", version: 3, id: "stored-session", timestamp: "2026-01-01T00:00:00Z", cwd: "/stored" },
|
|
...body,
|
|
];
|
|
|
|
const session = SessionManager.inMemory("/project", { id: "ignored" }, entries);
|
|
|
|
expect(session.getSessionId()).toBe("stored-session");
|
|
expect(session.getHeader()!.cwd).toBe("/stored");
|
|
});
|
|
|
|
it("migrates entries restored with an older header", () => {
|
|
const entries: FileEntry[] = [
|
|
{ type: "session", version: 2, id: "v2-session", timestamp: "2026-01-01T00:00:00Z", cwd: "/project" },
|
|
{
|
|
type: "message",
|
|
id: "abc12345",
|
|
parentId: null,
|
|
timestamp: "2026-01-01T00:00:01Z",
|
|
message: { role: "hookMessage", content: "from a hook", timestamp: 1 },
|
|
} as unknown as SessionMessageEntry,
|
|
];
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
const restored = session.getEntries()[0] as SessionMessageEntry;
|
|
|
|
expect(session.getHeader()!.version).toBe(3);
|
|
expect(restored.message.role).toBe("custom");
|
|
expect(restored.id).toBe("abc12345");
|
|
});
|
|
|
|
it("adopts headerless entries as current-version without migrating them", () => {
|
|
const entries: SessionEntry[] = [
|
|
{
|
|
type: "message",
|
|
id: "abc12345",
|
|
parentId: null,
|
|
timestamp: "2026-01-01T00:00:01Z",
|
|
message: { role: "hookMessage", content: "from a hook", timestamp: 1 },
|
|
} as unknown as SessionMessageEntry,
|
|
];
|
|
|
|
const session = SessionManager.inMemory("/project", undefined, entries);
|
|
const restored = session.getEntries()[0] as SessionMessageEntry;
|
|
|
|
expect(restored.message.role).toBe("hookMessage");
|
|
});
|
|
});
|