From 2b768ba42cdbd7336474d6986a3ef1efbb0a44f7 Mon Sep 17 00:00:00 2001 From: Julien Barbay Date: Thu, 3 Sep 2026 10:49:41 +0200 Subject: [PATCH] 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. --- packages/coding-agent/docs/sdk.md | 5 + .../coding-agent/src/core/session-manager.ts | 39 ++-- .../test/session-manager/load-entries.test.ts | 182 ++++++++++++++++++ 3 files changed, 213 insertions(+), 13 deletions(-) create mode 100644 packages/coding-agent/test/session-manager/load-entries.test.ts diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 5b75b2f8a..095bf2493 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -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()); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 678c0517b..d25b196a5 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -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); } /** diff --git a/packages/coding-agent/test/session-manager/load-entries.test.ts b/packages/coding-agent/test/session-manager/load-entries.test.ts new file mode 100644 index 000000000..463dfadf0 --- /dev/null +++ b/packages/coding-agent/test/session-manager/load-entries.test.ts @@ -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"); + }); +});