mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
This commit is contained in:
parent
1a773c8e7a
commit
2b768ba42c
3 changed files with 213 additions and 13 deletions
|
|
@ -788,6 +788,11 @@ const { session: opened } = await createAgentSession({
|
|||
sessionManager: SessionManager.open("/path/to/session.jsonl"),
|
||||
});
|
||||
|
||||
// Resume a session kept outside the filesystem, e.g. in a database
|
||||
const { session: restored } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(process.cwd(), { id: sessionId }, entries),
|
||||
});
|
||||
|
||||
// List sessions
|
||||
const currentProjectSessions = await SessionManager.list(process.cwd());
|
||||
const allSessions = await SessionManager.listAll(process.cwd());
|
||||
|
|
|
|||
|
|
@ -883,6 +883,8 @@ export class SessionManager {
|
|||
|
||||
if (sessionFile) {
|
||||
this._setSessionFile(sessionFile, preloadedFileEntries);
|
||||
} else if (preloadedFileEntries?.length) {
|
||||
this._loadEntries(preloadedFileEntries, newSessionOptions);
|
||||
} else {
|
||||
this.newSession(newSessionOptions);
|
||||
}
|
||||
|
|
@ -896,11 +898,11 @@ export class SessionManager {
|
|||
private _setSessionFile(sessionFile: string, preloadedFileEntries?: FileEntry[]): void {
|
||||
this.sessionFile = resolvePath(sessionFile);
|
||||
if (existsSync(this.sessionFile)) {
|
||||
this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);
|
||||
const entries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);
|
||||
|
||||
// If file was empty, initialize it with a valid session header. If it was
|
||||
// non-empty but did not parse as a pi session, fail without modifying it.
|
||||
if (this.fileEntries.length === 0) {
|
||||
if (entries.length === 0) {
|
||||
const explicitPath = this.sessionFile;
|
||||
if (statSync(explicitPath).size > 0) {
|
||||
throw new Error(`Session file is not a valid ${APP_NAME} session: ${explicitPath}`);
|
||||
|
|
@ -912,14 +914,7 @@ export class SessionManager {
|
|||
return;
|
||||
}
|
||||
|
||||
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
|
||||
this.sessionId = header?.id ?? createSessionId();
|
||||
|
||||
if (migrateToCurrentVersion(this.fileEntries)) {
|
||||
this._rewriteFile();
|
||||
}
|
||||
|
||||
this._buildIndex();
|
||||
this._loadEntries(entries);
|
||||
this.flushed = true;
|
||||
} else {
|
||||
const explicitPath = this.sessionFile;
|
||||
|
|
@ -956,6 +951,24 @@ export class SessionManager {
|
|||
return this.sessionFile;
|
||||
}
|
||||
|
||||
private _loadEntries(entries: FileEntry[], options?: NewSessionOptions): void {
|
||||
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
|
||||
|
||||
if (header) {
|
||||
this.fileEntries = entries;
|
||||
this.sessionId = header.id;
|
||||
|
||||
if (migrateToCurrentVersion(this.fileEntries)) {
|
||||
this._rewriteFile();
|
||||
}
|
||||
} else {
|
||||
this.newSession(options);
|
||||
this.fileEntries = this.fileEntries.concat(entries);
|
||||
}
|
||||
|
||||
this._buildIndex();
|
||||
}
|
||||
|
||||
private _buildIndex(): void {
|
||||
this.byId.clear();
|
||||
this.labelsById.clear();
|
||||
|
|
@ -1583,9 +1596,9 @@ export class SessionManager {
|
|||
return new SessionManager(cwd, dir, undefined, true);
|
||||
}
|
||||
|
||||
/** Create an in-memory session (no file persistence) */
|
||||
static inMemory(cwd: string = process.cwd(), options?: NewSessionOptions): SessionManager {
|
||||
return new SessionManager(cwd, "", undefined, false, options);
|
||||
/** Create an in-memory session (no file persistence), optionally from entries held outside the filesystem. */
|
||||
static inMemory(cwd: string = process.cwd(), options?: NewSessionOptions, entries?: FileEntry[]): SessionManager {
|
||||
return new SessionManager(cwd, "", undefined, false, options, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
182
packages/coding-agent/test/session-manager/load-entries.test.ts
Normal file
182
packages/coding-agent/test/session-manager/load-entries.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue