pi-mono/packages/coding-agent/test/session-manager/load-entries.test.ts
Julien Barbay 2b768ba42c
feat(coding-agent): ingest external entries in in-memory sessions (#8980)
* feat(coding-agent): ingest external entries in in-memory sessions

SessionManager.inMemory() takes the entries to start from, so a session
held outside the filesystem can be resumed as it was stored.

Replaying through appendMessage/appendCompaction cannot do this: ids are
minted fresh, so every stored firstKeptEntryId, targetId and fromId has to
be remapped, timestamps are rewritten, and parentId always comes from the
current leaf, so a branched tree needs the original branch() choreography
reproduced call by call.

The entry-adoption half of _setSessionFile becomes _loadEntries, which the
constructor also uses when there are entries but no file. Entries carrying
a session header keep it; without one, the header comes from the options
and the entries become its body.

* fix(coding-agent): carry the stored version through restored entries

inMemory() took SessionEntry[], which excludes SessionHeader, so an
external store could not pass the header it saved and the header branch of
_loadEntries was unreachable through the public API. A restore therefore
always synthesised a current-version header, and the migration that ran
next read that header and skipped: a v2 session kept its hookMessage roles
forever.

Widening to FileEntry[] lets the stored header through, version included.
Migration moves inside the header branch, where a version exists to compare
against; without a header the entries are adopted as current-version, which
is the only thing they can mean.
2026-09-03 10:49:41 +02:00

182 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");
});
});