import type { Usage } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { BACKGROUND_CONTEXT, type Context } from "../../src/harness/context.ts"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { JSONL_FORMAT_VERSION, JSONL_STORAGE_VERSION, type JsonlSessionMetadata, JsonlSessionRepo, JsonlStorage, } from "../../src/harness/session/jsonl/index.ts"; import type { Entry, Session } from "../../src/harness/session/types.ts"; import * as storedValues from "../../src/harness/session/values.ts"; import { err, FileError, getOrThrow, type Result } from "../../src/harness/types.ts"; import type { AgentMessage } from "../../src/types.ts"; import { createTempDir } from "./session-test-utils.ts"; const NOW = 1_700_000_000_000; class FailableRenameNodeExecutionEnv extends NodeExecutionEnv { failRename = false; override async renameFile( sourcePath: string, destinationPath: string, context: Context, ): Promise> { if (this.failRename) { return err(new FileError("unknown", "Injected rename failure", sourcePath)); } return super.renameFile(sourcePath, destinationPath, context); } } function uuidTimestamp(id: string): number { return Number.parseInt(id.replaceAll("-", "").slice(0, 12), 16); } async function mainBranch(session: Session) { const branch = await session.branch("main", BACKGROUND_CONTEXT); if (branch === undefined) throw new Error("Expected imported main Branch"); return branch; } async function mainTip(session: Session): Promise { return (await mainBranch(session)).getTipId(BACKGROUND_CONTEXT); } describe("JSONL v3 migration", () => { let fileSystem: FailableRenameNodeExecutionEnv; let repo: JsonlSessionRepo; beforeEach(() => { fileSystem = new FailableRenameNodeExecutionEnv({ cwd: createTempDir() }); repo = new JsonlSessionRepo({ fileSystem, sessionsRoot: "sessions", now: () => NOW, }); }); afterEach(async () => { await repo.close(BACKGROUND_CONTEXT); }); async function writeLegacyV3Fixture( records: readonly unknown[], headerOptions: { parentSession?: string } = {}, ): Promise<{ path: string; content: string }> { const directory = getOrThrow(await fileSystem.joinPath(["sessions", "--workspace--"], BACKGROUND_CONTEXT)); getOrThrow(await fileSystem.createDir(directory, undefined, BACKGROUND_CONTEXT)); const relativePath = getOrThrow(await fileSystem.joinPath([directory, "legacy.jsonl"], BACKGROUND_CONTEXT)); const path = getOrThrow(await fileSystem.absolutePath(relativePath, BACKGROUND_CONTEXT)); const content = `${[ { type: "session", version: 3, id: "legacy", timestamp: new Date(NOW).toISOString(), cwd: "/workspace", ...headerOptions, }, ...records, ] .map((record) => JSON.stringify(record)) .join("\n")}\n`; getOrThrow(await fileSystem.writeFile(path, content, BACKGROUND_CONTEXT)); return { path, content }; } it("discovers legacy v3 session files without rewriting them", async () => { const { path, content } = await writeLegacyV3Fixture([], { parentSession: "/old-session.jsonl", }); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); const after = getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT)); expect(metadata).toMatchObject({ id: "legacy", createdAt: NOW, storageVersion: JSONL_STORAGE_VERSION, cwd: "/workspace", path, legacyParentSessionPath: "/old-session.jsonl", }); expect(Number.isFinite(metadata?.modifiedAt)).toBe(true); expect(after).toBe(content); }); it.each([ { format: "v3", parentId: "legacy-parent", header: { type: "session", version: 3, id: "legacy-parent", timestamp: new Date(NOW - 1_000).toISOString(), cwd: "/workspace", }, }, { format: "v4", parentId: "current-parent", header: { v: 4, kind: "header", id: "current-parent", storageVersion: JSONL_STORAGE_VERSION, createdAt: NOW - 1_000, cwd: "/workspace", }, }, ])("resolves an available $format parent path to its session id", async ({ format, parentId, header }) => { const parentPath = getOrThrow(await fileSystem.absolutePath(`parent-${format}.jsonl`, BACKGROUND_CONTEXT)); getOrThrow(await fileSystem.writeFile(parentPath, `${JSON.stringify(header)}\n`, BACKGROUND_CONTEXT)); await writeLegacyV3Fixture([], { parentSession: parentPath }); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); expect(metadata).toMatchObject({ id: "legacy", parentSessionId: parentId, }); expect(metadata).not.toHaveProperty("legacyParentSessionPath"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(session.metadata).toMatchObject({ parentSessionId: parentId }); expect(session.metadata).not.toHaveProperty("legacyParentSessionPath"); await session.close(BACKGROUND_CONTEXT); }); it("preserves an invalid parent path as legacy metadata", async () => { const parentPath = getOrThrow(await fileSystem.absolutePath("invalid-parent.jsonl", BACKGROUND_CONTEXT)); getOrThrow(await fileSystem.writeFile(parentPath, '{"not":"a session header"}\n', BACKGROUND_CONTEXT)); await writeLegacyV3Fixture([], { parentSession: parentPath }); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); expect(metadata).toMatchObject({ id: "legacy", legacyParentSessionPath: parentPath, }); expect(metadata).not.toHaveProperty("parentSessionId"); }); describe("forking legacy v3 sessions", () => { const firstMessage = { role: "user", content: [{ type: "text", text: "fork me" }], timestamp: NOW + 1_000, } satisfies AgentMessage; const usage = { input: 10, output: 5, cacheRead: 2, cacheWrite: 1, totalTokens: 18, cost: { input: 0.1, output: 0.05, cacheRead: 0.02, cacheWrite: 0.01, total: 0.18, }, } satisfies Usage; const secondMessage = { role: "assistant", content: [{ type: "text", text: "forked" }], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage, stopReason: "stop", timestamp: NOW + 4_000, } satisfies AgentMessage; async function writeForkFixture() { const fixture = await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), message: firstMessage, }, { type: "label", id: "label-1", parentId: "message-1", timestamp: new Date(NOW + 2_000).toISOString(), targetId: "message-1", label: "Fork point", }, { type: "session_info", id: "session-info", parentId: "label-1", timestamp: new Date(NOW + 3_000).toISOString(), name: "Imported fork", }, { type: "message", id: "message-2", parentId: "session-info", timestamp: new Date(NOW + 4_000).toISOString(), message: secondMessage, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); return { ...fixture, metadata }; } async function expectForkedState(fork: Session): Promise { expect(fork.metadata).toMatchObject({ storageVersion: JSONL_STORAGE_VERSION, parentSessionId: "legacy", }); const entries = await fork.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(2); const [first, second] = entries; if (first === undefined || second === undefined) throw new Error("Forked entries were not written"); expect(first).toMatchObject({ type: "message", parentId: null, message: firstMessage, }); expect(second).toMatchObject({ type: "message", parentId: first.id, message: secondMessage, }); expect(first.id).not.toBe("message-1"); expect(second.id).not.toBe("message-2"); expect(await mainTip(fork)).toBe(second.id); expect(await fork.getName(BACKGROUND_CONTEXT)).toBe("Imported fork"); expect(await fork.getLabel(first.id, BACKGROUND_CONTEXT)).toBe("Fork point"); expect(await fork.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await fork.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await fork.getStats(BACKGROUND_CONTEXT)).toEqual({ messageCount: 2, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }); return entries; } it("forks a closed source into a complete v4 destination without rewriting it", async () => { const { path, content, metadata } = await writeForkFixture(); const fork = await repo.fork(metadata, { id: "closed-fork", scope: "tree" }, BACKGROUND_CONTEXT); expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content); expect(fork.metadata.id).toBe("closed-fork"); const [headerLine] = getOrThrow( await fileSystem.readTextLines(fork.metadata.path, { maxLines: 1 }, BACKGROUND_CONTEXT), ); if (headerLine === undefined) throw new Error("Fork header was not written"); expect(JSON.parse(headerLine)).toMatchObject({ v: 4, kind: "header", id: "closed-fork", storageVersion: JSONL_STORAGE_VERSION, parentSessionId: "legacy", }); await expectForkedState(fork); await fork.close(BACKGROUND_CONTEXT); const destinationStorage = await JsonlStorage.open( { fileSystem, path: fork.metadata.path, now: () => NOW }, BACKGROUND_CONTEXT, ); expect(await destinationStorage.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT)).toEqual([]); await destinationStorage.close(BACKGROUND_CONTEXT); expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content); }); it("forks an already-open source without converting or renormalizing it", async () => { const { path, content, metadata } = await writeForkFixture(); const source = await repo.open(metadata, BACKGROUND_CONTEXT); const sourceEntries = await source.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); const sourceStats = await source.getStats(BACKGROUND_CONTEXT); const fork = await repo.fork(metadata, { id: "open-fork", scope: "tree" }, BACKGROUND_CONTEXT); const forkEntries = await expectForkedState(fork); expect(fork.metadata.id).toBe("open-fork"); expect(forkEntries).toEqual(sourceEntries); expect(sourceStats.usage).toEqual(usage); expect(await source.getStats(BACKGROUND_CONTEXT)).toEqual(sourceStats); expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content); await Promise.all([source.close(BACKGROUND_CONTEXT), fork.close(BACKGROUND_CONTEXT)]); expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content); }); }); it("opens an empty legacy session with a data-only main Branch", async () => { await writeLegacyV3Fixture([]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(await session.findEntries(undefined, BACKGROUND_CONTEXT)).toEqual([]); expect(await mainTip(session)).toBeNull(); expect(await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); await session.close(BACKGROUND_CONTEXT); }); it("reports embedded legacy usage without creating usage rows or rewriting the source", async () => { const usage = (factor: number): Usage => ({ input: factor, output: factor * 2, cacheRead: factor * 3, cacheWrite: factor * 4, cacheWrite1h: factor * 5, reasoning: factor * 6, totalTokens: factor * 10, cost: { input: factor, output: factor * 2, cacheRead: factor * 3, cacheWrite: factor * 4, total: factor * 10, }, }); const assistantUsage = usage(1); const toolUsage = usage(10); const compactionUsage = usage(100); const branchSummaryUsage = usage(1_000); const { path, content } = await writeLegacyV3Fixture([ { type: "message", id: "assistant", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), message: { role: "assistant", content: [{ type: "text", text: "answer" }], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage: assistantUsage, stopReason: "stop", timestamp: NOW + 1_000, } satisfies AgentMessage, }, { type: "message", id: "tool-result", parentId: "assistant", timestamp: new Date(NOW + 2_000).toISOString(), message: { role: "toolResult", toolCallId: "call-1", toolName: "test", content: [{ type: "text", text: "result" }], usage: toolUsage, isError: false, timestamp: NOW + 2_000, } satisfies AgentMessage, }, { type: "compaction", id: "compaction", parentId: "tool-result", timestamp: new Date(NOW + 3_000).toISOString(), summary: "Earlier context", firstKeptEntryId: "assistant", tokensBefore: 1_000, usage: compactionUsage, }, { type: "branch_summary", id: "branch-summary", parentId: "compaction", timestamp: new Date(NOW + 4_000).toISOString(), fromId: "assistant", summary: "Abandoned branch", usage: branchSummaryUsage, }, ]); const storage = await JsonlStorage.open({ fileSystem, path, now: () => NOW }, BACKGROUND_CONTEXT); expect(await storage.getStats(BACKGROUND_CONTEXT)).toEqual({ messageCount: 2, usage: usage(1_111), }); expect(await storage.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT)).toEqual([]); await storage.close(BACKGROUND_CONTEXT); expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content); }); describe("upgrading legacy v3 sessions to v4", () => { const importedUsage = { input: 10, output: 5, cacheRead: 2, cacheWrite: 1, totalTokens: 18, cost: { input: 0.1, output: 0.05, cacheRead: 0.02, cacheWrite: 0.01, total: 0.18, }, } satisfies Usage; const importedMessage = { role: "assistant", content: [{ type: "text", text: "imported answer" }], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage: importedUsage, stopReason: "stop", timestamp: NOW + 1_000, } satisfies AgentMessage; function writeUsageFixture() { return writeLegacyV3Fixture([ { type: "message", id: "assistant", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), message: importedMessage, }, ]); } it("writes one zero-valued usage adjustment when converting a session without imported usage", async () => { const { path } = await writeLegacyV3Fixture([]); const storage = await JsonlStorage.open({ fileSystem, path, now: () => NOW }, BACKGROUND_CONTEXT); await storage.commit( [storedValues.setValue(storedValues.sessionName, "Converted session")], BACKGROUND_CONTEXT, ); const usageRows = await storage.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT); expect(usageRows).toHaveLength(1); expect(usageRows[0]).toMatchObject({ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, adjustment: true, details: { source: "v3-import" }, }); expect(usageRows[0]).not.toHaveProperty("entryId"); await storage.close(BACKGROUND_CONTEXT); }); it("converts to v4 and preserves the first caller transaction with one usage adjustment", async () => { // Seed a v3 session whose embedded usage must become a durable v4 ledger adjustment. const { path } = await writeUsageFixture(); // Capture the normalized read-only view so conversion can be checked for identity and stats stability. const options = { fileSystem, path, now: () => NOW }; const storage = await JsonlStorage.open(options, BACKGROUND_CONTEXT); const importedEntries = await storage.scanEntries({ order: "asc" }, BACKGROUND_CONTEXT); const statsBefore = await storage.getStats(BACKGROUND_CONTEXT); // The first caller write triggers conversion and must not expose the internal adjustment sequence. const committed = await storage.commit( [storedValues.setValue(storedValues.sessionName, "Converted session")], BACKGROUND_CONTEXT, ); expect(committed.seqs).toHaveLength(1); expect(committed.firstSeq).toBe(committed.seqs[0]); expect((await storage.getValue(storedValues.sessionName, BACKGROUND_CONTEXT))?.seq).toBe(committed.seqs[0]); const usageRows = await storage.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT); expect(usageRows).toHaveLength(1); const [adjustment] = usageRows; if (adjustment === undefined) throw new Error("Import usage adjustment was not committed"); expect(adjustment).toMatchObject({ usage: importedUsage, adjustment: true, details: { source: "v3-import" }, }); expect(adjustment).not.toHaveProperty("entryId"); expect(committed.seqs).not.toContain(adjustment.seq); expect(committed.stats).toEqual(statsBefore); expect(await storage.getStats(BACKGROUND_CONTEXT)).toEqual(statsBefore); // Conversion replaces the legacy file and retains the adjustment and caller writes as its first transaction. const convertedLines = getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT)) .trimEnd() .split("\n"); const headerLine = convertedLines[0]; const transactionLine = convertedLines.at(-1); if (headerLine === undefined || transactionLine === undefined) throw new Error("Converted file was not written"); expect(JSON.parse(headerLine)).toMatchObject({ v: JSONL_FORMAT_VERSION, kind: "header", id: "legacy", storageVersion: JSONL_STORAGE_VERSION, createdAt: NOW, cwd: "/workspace", }); expect(JSON.parse(transactionLine)).toEqual([ { kind: "usage", ...adjustment }, { kind: "value", op: "set", seq: committed.seqs[0], namespace: storedValues.sessionName.namespace, key: storedValues.sessionName.key, value: "Converted session", }, ]); await storage.close(BACKGROUND_CONTEXT); // Reopening through the ordinary v4 path must recover the complete normalized and newly committed state. const reopened = await JsonlStorage.open(options, BACKGROUND_CONTEXT); expect(await reopened.scanEntries({ order: "asc" }, BACKGROUND_CONTEXT)).toEqual(importedEntries); const [importedEntry] = importedEntries; if (importedEntry === undefined) throw new Error("Legacy message was not imported"); expect((await reopened.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT))?.value).toBe( importedEntry.id, ); expect(await reopened.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect((await reopened.getValue(storedValues.sessionName, BACKGROUND_CONTEXT))?.value).toBe( "Converted session", ); expect(await reopened.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT)).toEqual([adjustment]); expect(await reopened.getStats(BACKGROUND_CONTEXT)).toEqual(statsBefore); await reopened.close(BACKGROUND_CONTEXT); }); it("leaves the v3 source and live state unchanged when atomic publication fails", async () => { const { path, content } = await writeUsageFixture(); const options = { fileSystem, path, now: () => NOW }; const storage = await JsonlStorage.open(options, BACKGROUND_CONTEXT); const entriesBefore = await storage.scanEntries({ order: "asc" }, BACKGROUND_CONTEXT); const leafBefore = await storage.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT); const laneStateBefore = await storage.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT); const nameBefore = await storage.getValue(storedValues.sessionName, BACKGROUND_CONTEXT); const statsBefore = await storage.getStats(BACKGROUND_CONTEXT); const usageBefore = await storage.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT); if (leafBefore === undefined) throw new Error("Normalized main Branch tip is missing"); expect(laneStateBefore).toBeUndefined(); fileSystem.failRename = true; await expect( storage.commit([storedValues.setValue(storedValues.sessionName, "Converted session")], BACKGROUND_CONTEXT), ).rejects.toThrow(`Failed to publish JSONL storage ${path}`); fileSystem.failRename = false; expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content); expect(await storage.scanEntries({ order: "asc" }, BACKGROUND_CONTEXT)).toEqual(entriesBefore); expect(await storage.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT)).toEqual(leafBefore); expect(await storage.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toEqual(laneStateBefore); expect(await storage.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toEqual(nameBefore); expect(await storage.getStats(BACKGROUND_CONTEXT)).toEqual(statsBefore); expect(await storage.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT)).toEqual(usageBefore); const committed = await storage.commit( [storedValues.setValue(storedValues.sessionName, "Converted session")], BACKGROUND_CONTEXT, ); expect(committed.firstSeq).toBe(leafBefore.seq + 2); expect(committed.seqs).toEqual([committed.firstSeq]); expect(await storage.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toMatchObject({ seq: committed.firstSeq, value: "Converted session", }); const usageRows = await storage.scanUsage({ order: "asc" }, BACKGROUND_CONTEXT); expect(usageRows).toHaveLength(1); expect(usageRows[0]).toMatchObject({ usage: importedUsage, adjustment: true, details: { source: "v3-import" }, }); expect(await storage.getStats(BACKGROUND_CONTEXT)).toEqual(statsBefore); await storage.close(BACKGROUND_CONTEXT); }); }); describe("reconstructing legacy lane configuration", () => { const firstTimestamp = NOW + 1_000; const modelChangeTimestamp = NOW + 2_000; const thinkingChangeTimestamp = NOW + 3_000; const activeToolsChangeTimestamp = NOW + 4_000; const secondTimestamp = NOW + 5_000; const firstMessage = { role: "user", content: [{ type: "text", text: "first" }], timestamp: firstTimestamp, } satisfies AgentMessage; const secondMessage = { role: "user", content: [{ type: "text", text: "second" }], timestamp: secondTimestamp, } satisfies AgentMessage; const configurationChanges = [ { type: "model_change", id: "model-change", parentId: "message-1", timestamp: new Date(modelChangeTimestamp).toISOString(), provider: "anthropic", modelId: "claude-sonnet-4-5", }, { type: "thinking_level_change", id: "thinking-change", parentId: "model-change", timestamp: new Date(thinkingChangeTimestamp).toISOString(), thinkingLevel: "high", }, { type: "active_tools_change", id: "active-tools-change", parentId: "thinking-change", timestamp: new Date(activeToolsChangeTimestamp).toISOString(), activeToolNames: ["read", "bash"], }, ] as const; it("reparents a retained child through configuration changes", async () => { await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(firstTimestamp).toISOString(), message: firstMessage, }, ...configurationChanges, { type: "message", id: "message-2", parentId: "active-tools-change", timestamp: new Date(secondTimestamp).toISOString(), message: secondMessage, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(2); const [first, second] = entries; if (first === undefined || second === undefined) { throw new Error("Legacy configuration-change chain was not imported"); } expect(first).toMatchObject({ type: "message", parentId: null, message: firstMessage, }); expect(second).toMatchObject({ type: "message", parentId: first.id, message: secondMessage, }); expect(second.seq).toBeGreaterThan(first.seq); expect(await mainTip(session)).toBe(second.id); expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ model: { provider: "anthropic", modelId: "claude-sonnet-4-5" }, thinkingLevel: "high", activeToolNames: ["read", "bash"], }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, lastOperationId: null, inbox: [], }); await session.close(BACKGROUND_CONTEXT); }); it("retains only configuration changes on the selected physical branch", async () => { await writeLegacyV3Fixture([ { type: "message", id: "root", parentId: null, timestamp: new Date(firstTimestamp).toISOString(), message: firstMessage, }, { type: "model_change", id: "selected-model", parentId: "root", timestamp: new Date(modelChangeTimestamp).toISOString(), provider: "anthropic", modelId: "selected", }, { type: "thinking_level_change", id: "selected-thinking", parentId: "selected-model", timestamp: new Date(thinkingChangeTimestamp).toISOString(), thinkingLevel: "high", }, { type: "model_change", id: "abandoned-model", parentId: "root", timestamp: new Date(activeToolsChangeTimestamp).toISOString(), provider: "openai", modelId: "abandoned", }, { type: "message", id: "selected-tip", parentId: "selected-thinking", timestamp: new Date(secondTimestamp).toISOString(), message: secondMessage, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ model: { provider: "anthropic", modelId: "selected" }, thinkingLevel: "high", activeToolNames: [], }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, lastOperationId: null, inbox: [], }); await session.close(BACKGROUND_CONTEXT); }); it("omits an unsupported nearest value instead of falling back to an older change", async () => { await writeLegacyV3Fixture([ { type: "message", id: "root", parentId: null, timestamp: new Date(firstTimestamp).toISOString(), message: firstMessage, }, { type: "model_change", id: "older-model", parentId: "root", timestamp: new Date(modelChangeTimestamp).toISOString(), provider: "anthropic", modelId: "older", }, { type: "model_change", id: "invalid-model", parentId: "older-model", timestamp: new Date(thinkingChangeTimestamp).toISOString(), provider: "", modelId: "", }, { type: "message", id: "tip", parentId: "invalid-model", timestamp: new Date(secondTimestamp).toISOString(), message: secondMessage, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); await session.close(BACKGROUND_CONTEXT); }); it.each([ { name: "missing model", changes: [ { type: "thinking_level_change", id: "thinking", parentId: "root", timestamp: new Date(thinkingChangeTimestamp).toISOString(), thinkingLevel: "high", }, ], }, { name: "missing thinking level", changes: [ { type: "model_change", id: "model", parentId: "root", timestamp: new Date(modelChangeTimestamp).toISOString(), provider: "anthropic", modelId: "selected", }, ], }, { name: "invalid thinking level", changes: [ { type: "model_change", id: "model", parentId: "root", timestamp: new Date(modelChangeTimestamp).toISOString(), provider: "anthropic", modelId: "selected", }, { type: "thinking_level_change", id: "thinking", parentId: "model", timestamp: new Date(thinkingChangeTimestamp).toISOString(), thinkingLevel: "unsupported", }, ], }, ])("leaves main data-only for $name", async ({ changes }) => { await writeLegacyV3Fixture([ { type: "message", id: "root", parentId: null, timestamp: new Date(firstTimestamp).toISOString(), message: firstMessage, }, ...changes, { type: "message", id: "tip", parentId: changes.at(-1)?.id ?? "root", timestamp: new Date(secondTimestamp).toISOString(), message: secondMessage, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); await session.close(BACKGROUND_CONTEXT); }); it("normalizes malformed active-tool history without compatibility state", async () => { await writeLegacyV3Fixture([ { type: "model_change", id: "model", parentId: null, timestamp: new Date(modelChangeTimestamp).toISOString(), provider: "anthropic", modelId: "selected", }, { type: "thinking_level_change", id: "thinking", parentId: "model", timestamp: new Date(thinkingChangeTimestamp).toISOString(), thinkingLevel: "high", }, { type: "active_tools_change", id: "tools", parentId: "thinking", timestamp: new Date(activeToolsChangeTimestamp).toISOString(), activeToolNames: ["read", 42], }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ model: { provider: "anthropic", modelId: "selected" }, thinkingLevel: "high", activeToolNames: [], }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, lastOperationId: null, inbox: [], }); await session.close(BACKGROUND_CONTEXT); }); it("resolves the main tip through trailing configuration changes", async () => { await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(firstTimestamp).toISOString(), message: firstMessage, }, ...configurationChanges, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(1); const [message] = entries; if (message === undefined) throw new Error("Legacy message was not imported"); expect(await mainTip(session)).toBe(message.id); expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({ model: { provider: "anthropic", modelId: "claude-sonnet-4-5" }, thinkingLevel: "high", activeToolNames: ["read", "bash"], }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, lastOperationId: null, inbox: [], }); await session.close(BACKGROUND_CONTEXT); }); }); it("imports session info as the current name without retaining a tree entry", async () => { await writeLegacyV3Fixture([ { type: "session_info", id: "session-info", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), name: "Imported session", }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(await session.getName(BACKGROUND_CONTEXT)).toBe("Imported session"); expect(await session.findEntries(undefined, BACKGROUND_CONTEXT)).toEqual([]); expect(await mainTip(session)).toBeNull(); await session.close(BACKGROUND_CONTEXT); }); it("uses the latest session info and resolves tree structure through discarded records", async () => { const firstMessage = { role: "user", content: [{ type: "text", text: "first" }], timestamp: NOW + 1_000, } satisfies AgentMessage; const secondMessage = { role: "user", content: [{ type: "text", text: "second" }], timestamp: NOW + 3_000, } satisfies AgentMessage; await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), message: firstMessage, }, { type: "session_info", id: "session-info-1", parentId: "message-1", timestamp: new Date(NOW + 2_000).toISOString(), name: "Earlier name", }, { type: "message", id: "message-2", parentId: "session-info-1", timestamp: new Date(NOW + 3_000).toISOString(), message: secondMessage, }, { type: "session_info", id: "session-info-2", parentId: "message-2", timestamp: new Date(NOW + 4_000).toISOString(), name: "Latest name", }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(2); const [first, second] = entries; if (first === undefined || second === undefined) throw new Error("Legacy message chain was not imported"); expect(second.parentId).toBe(first.id); expect(await session.getName(BACKGROUND_CONTEXT)).toBe("Latest name"); expect(await mainTip(session)).toBe(second.id); await session.close(BACKGROUND_CONTEXT); }); it.each([{ name: undefined }, { name: "" }])("clears the session name with $name", async ({ name }) => { await writeLegacyV3Fixture([ { type: "session_info", id: "session-info-1", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), name: "Earlier name", }, { type: "session_info", id: "session-info-2", parentId: "session-info-1", timestamp: new Date(NOW + 2_000).toISOString(), ...(name === undefined ? {} : { name }), }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); expect(await session.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toBeUndefined(); await session.close(BACKGROUND_CONTEXT); }); it("imports a label for its remapped entry without retaining a tree node", async () => { const message = { role: "user", content: [{ type: "text", text: "label me" }], timestamp: NOW + 1_000, } satisfies AgentMessage; await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), message, }, { type: "label", id: "label-1", parentId: "message-1", timestamp: new Date(NOW + 2_000).toISOString(), targetId: "message-1", label: "Important", }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(1); const [entry] = entries; if (entry === undefined) throw new Error("Legacy message was not imported"); expect((await session.getValue(storedValues.entryLabel(entry.id), BACKGROUND_CONTEXT))?.value).toBe("Important"); expect(await mainTip(session)).toBe(entry.id); await session.close(BACKGROUND_CONTEXT); }); it("skips a label whose target has no retained ancestor", async () => { const message = { role: "user", content: [{ type: "text", text: "retained message" }], timestamp: NOW + 3_000, } satisfies AgentMessage; await writeLegacyV3Fixture([ { type: "session_info", id: "root-session-info", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), }, { type: "label", id: "root-label", parentId: "root-session-info", timestamp: new Date(NOW + 2_000).toISOString(), targetId: "root-session-info", label: "Skipped label", }, { type: "message", id: "message-1", parentId: "root-label", timestamp: new Date(NOW + 3_000).toISOString(), message, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(1); const [entry] = entries; if (entry === undefined) throw new Error("Legacy message was not imported"); expect(entry.parentId).toBeNull(); expect(await session.getLabel(entry.id, BACKGROUND_CONTEXT)).toBeUndefined(); expect(await mainTip(session)).toBe(entry.id); await session.close(BACKGROUND_CONTEXT); }); it("uses the latest label after remapping discarded targets", async () => { const firstMessage = { role: "user", content: [{ type: "text", text: "first" }], timestamp: NOW + 1_000, } satisfies AgentMessage; const secondMessage = { role: "user", content: [{ type: "text", text: "second" }], timestamp: NOW + 5_000, } satisfies AgentMessage; await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), message: firstMessage, }, { type: "session_info", id: "session-info", parentId: "message-1", timestamp: new Date(NOW + 2_000).toISOString(), }, { type: "label", id: "label-1", parentId: "session-info", timestamp: new Date(NOW + 3_000).toISOString(), targetId: "session-info", label: "Earlier label", }, { type: "label", id: "label-2", parentId: "label-1", timestamp: new Date(NOW + 4_000).toISOString(), targetId: "message-1", label: "Latest label", }, { type: "message", id: "message-2", parentId: "label-2", timestamp: new Date(NOW + 5_000).toISOString(), message: secondMessage, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(2); const [first, second] = entries; if (first === undefined || second === undefined) throw new Error("Legacy message chain was not imported"); expect(second.parentId).toBe(first.id); expect(await session.getLabel(first.id, BACKGROUND_CONTEXT)).toBe("Latest label"); expect(await mainTip(session)).toBe(second.id); await session.close(BACKGROUND_CONTEXT); }); it.each([{ label: undefined }, { label: "" }])("clears a label with $label", async ({ label }) => { const message = { role: "user", content: [{ type: "text", text: "clear my label" }], timestamp: NOW + 1_000, } satisfies AgentMessage; await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(NOW + 1_000).toISOString(), message, }, { type: "label", id: "label-1", parentId: "message-1", timestamp: new Date(NOW + 2_000).toISOString(), targetId: "message-1", label: "Earlier label", }, { type: "label", id: "label-2", parentId: "label-1", timestamp: new Date(NOW + 3_000).toISOString(), targetId: "message-1", ...(label === undefined ? {} : { label }), }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries(undefined, BACKGROUND_CONTEXT); const [entry] = entries; if (entry === undefined) throw new Error("Legacy message was not imported"); expect(await session.getLabel(entry.id, BACKGROUND_CONTEXT)).toBeUndefined(); expect(await mainTip(session)).toBe(entry.id); await session.close(BACKGROUND_CONTEXT); }); it("imports a custom entry without rewriting opaque data references", async () => { const messageTimestamp = NOW + 1_000; const customTimestamp = NOW + 2_000; const message = { role: "user", content: [{ type: "text", text: "first" }], timestamp: messageTimestamp, } satisfies AgentMessage; const data = { legacyReference: "message-1", nested: { legacyReference: "custom-1" }, }; await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(messageTimestamp).toISOString(), message, }, { type: "custom", id: "custom-1", parentId: "message-1", timestamp: new Date(customTimestamp).toISOString(), customType: "checkpoint", data, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(2); const [messageEntry, customEntry] = entries; if (messageEntry === undefined || customEntry === undefined) { throw new Error("Legacy custom chain was not imported"); } expect(messageEntry.seq).toBe(1); expect(customEntry).toMatchObject({ type: "custom", parentId: messageEntry.id, seq: 2, timestamp: customTimestamp, customType: "checkpoint", data, }); expect(customEntry.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(customEntry.id)).toBe(customTimestamp); expect(await mainTip(session)).toBe(customEntry.id); await session.close(BACKGROUND_CONTEXT); }); it("imports a custom message as a current message entry", async () => { const parentTimestamp = NOW + 1_000; const customMessageTimestamp = NOW + 2_000; const parentMessage = { role: "user", content: [{ type: "text", text: "first" }], timestamp: parentTimestamp, } satisfies AgentMessage; const content = [{ type: "text", text: "legacy custom message" }]; const details = { status: "complete" }; await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(parentTimestamp).toISOString(), message: parentMessage, }, { type: "custom_message", id: "custom-message-1", parentId: "message-1", timestamp: new Date(customMessageTimestamp).toISOString(), customType: "status", content, details, display: false, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(2); const [parentEntry, customMessageEntry] = entries; if (parentEntry === undefined || customMessageEntry === undefined) { throw new Error("Legacy custom message chain was not imported"); } expect(parentEntry.seq).toBe(1); expect(customMessageEntry).toMatchObject({ type: "message", parentId: parentEntry.id, seq: 2, timestamp: customMessageTimestamp, message: { role: "custom", customType: "status", content, details, display: false, timestamp: customMessageTimestamp, }, }); expect(customMessageEntry.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(customMessageEntry.id)).toBe(customMessageTimestamp); expect(await mainTip(session)).toBe(customMessageEntry.id); expect((await session.getStats(BACKGROUND_CONTEXT)).messageCount).toBe(2); await session.close(BACKGROUND_CONTEXT); }); describe("importing branch summaries", () => { const branchPointTimestamp = NOW + 1_000; const abandonedResponseTimestamp = NOW + 2_000; const summaryTimestamp = NOW + 3_000; const details = { reason: "navigation" }; const usage = { input: 11, output: 7, cacheRead: 3, cacheWrite: 2, totalTokens: 23, cost: { input: 0.11, output: 0.07, cacheRead: 0.03, cacheWrite: 0.02, total: 0.23, }, } satisfies Usage; const branchPointMessage = { role: "user", content: [{ type: "text", text: "Try the first approach" }], timestamp: branchPointTimestamp, } satisfies AgentMessage; const abandonedResponse = { role: "assistant", content: [{ type: "text", text: "Implemented the first approach" }], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage: { input: 20, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 30, cost: { input: 0.2, output: 0.1, cacheRead: 0, cacheWrite: 0, total: 0.3, }, }, stopReason: "stop", timestamp: abandonedResponseTimestamp, } satisfies AgentMessage; async function openFixture(fromHook?: true) { await writeLegacyV3Fixture([ { type: "message", id: "branch-point", parentId: null, timestamp: new Date(branchPointTimestamp).toISOString(), message: branchPointMessage, }, { type: "message", id: "abandoned-response", parentId: "branch-point", timestamp: new Date(abandonedResponseTimestamp).toISOString(), message: abandonedResponse, }, { type: "branch_summary", id: "summary", parentId: "branch-point", timestamp: new Date(summaryTimestamp).toISOString(), fromId: "branch-point", summary: "Summary of the abandoned branch", details, usage, ...(fromHook === undefined ? {} : { fromHook }), }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(3); const [branchPoint, abandoned, branchSummary] = entries; if (branchPoint === undefined || abandoned === undefined || branchSummary === undefined) { throw new Error("Legacy branch summary chain was not imported"); } return { session, branchPoint, abandoned, branchSummary }; } it("preserves payload and remaps references", async () => { const { session, branchPoint, abandoned, branchSummary } = await openFixture(); expect(abandoned.parentId).toBe(branchPoint.id); expect(branchSummary).toMatchObject({ type: "branch_summary", parentId: branchPoint.id, seq: 3, timestamp: summaryTimestamp, fromId: branchPoint.id, summary: "Summary of the abandoned branch", details, usage, fromHook: false, }); expect(branchSummary.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(branchSummary.id)).toBe(summaryTimestamp); expect(await mainTip(session)).toBe(branchSummary.id); await session.close(BACKGROUND_CONTEXT); }); it("preserves an explicit fromHook flag", async () => { const { session, branchSummary } = await openFixture(true); expect(branchSummary).toMatchObject({ type: "branch_summary", fromHook: true, }); await session.close(BACKGROUND_CONTEXT); }); it('normalizes fromId "root" to null', async () => { await writeLegacyV3Fixture([ { type: "branch_summary", id: "summary", parentId: null, timestamp: new Date(summaryTimestamp).toISOString(), fromId: "root", summary: "Summary from the root", }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ type: "branch_summary", fromId: null, }); await session.close(BACKGROUND_CONTEXT); }); it("rejects a missing fromId", async () => { await writeLegacyV3Fixture([ { type: "branch_summary", id: "summary", parentId: null, timestamp: new Date(summaryTimestamp).toISOString(), fromId: "missing-legacy-entry", summary: "Summary from a missing source", }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); await expect(repo.open(metadata, BACKGROUND_CONTEXT)).rejects.toThrow( "Missing legacy v3 entry reference: missing-legacy-entry", ); }); it("keeps fromId null when a discarded source has no retained ancestor", async () => { await writeLegacyV3Fixture([ { type: "model_change", id: "model-change", parentId: null, timestamp: new Date(branchPointTimestamp).toISOString(), provider: "anthropic", modelId: "claude-sonnet-4-5", }, { type: "branch_summary", id: "summary", parentId: "model-change", timestamp: new Date(summaryTimestamp).toISOString(), fromId: "model-change", summary: "Summary from the root", }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ type: "branch_summary", parentId: null, fromId: null, }); await session.close(BACKGROUND_CONTEXT); }); }); describe("importing compactions", () => { const excludedTimestamp = NOW + 1_000; const retainedTimestamp = NOW + 2_000; const compactionTimestamp = NOW + 3_000; const excludedMessage = { role: "user", content: [{ type: "text", text: "old context" }], timestamp: excludedTimestamp, } satisfies AgentMessage; const retainedMessage = { role: "user", content: [{ type: "text", text: "retain this context" }], timestamp: retainedTimestamp, } satisfies AgentMessage; const details = { strategy: "default" }; const usage = { input: 120, output: 30, cacheRead: 10, cacheWrite: 5, totalTokens: 165, cost: { input: 1.2, output: 0.3, cacheRead: 0.1, cacheWrite: 0.05, total: 1.65, }, } satisfies Usage; async function openFixture(fromHook?: true) { await writeLegacyV3Fixture([ { type: "message", id: "excluded-message", parentId: null, timestamp: new Date(excludedTimestamp).toISOString(), message: excludedMessage, }, { type: "message", id: "retained-message", parentId: "excluded-message", timestamp: new Date(retainedTimestamp).toISOString(), message: retainedMessage, }, { type: "compaction", id: "compaction", parentId: "retained-message", timestamp: new Date(compactionTimestamp).toISOString(), summary: "Summary of the earlier context", firstKeptEntryId: "retained-message", tokensBefore: 12_000, details, usage, ...(fromHook === undefined ? {} : { fromHook }), }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(3); const [excluded, retained, compaction] = entries; if (excluded === undefined || retained === undefined || compaction === undefined) { throw new Error("Legacy compaction chain was not imported"); } return { session, excluded, retained, compaction }; } it("materializes the retained tail and preserves the payload", async () => { const { session, excluded, retained, compaction } = await openFixture(); expect(retained).toMatchObject({ parentId: excluded.id, seq: 2 }); expect(compaction).toMatchObject({ type: "compaction", parentId: retained.id, seq: 3, timestamp: compactionTimestamp, summary: "Summary of the earlier context", retainedTail: [retainedMessage], tokensBefore: 12_000, details, usage, fromHook: false, }); expect(compaction).not.toHaveProperty("firstKeptEntryId"); expect(compaction.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(compaction.id)).toBe(compactionTimestamp); expect(await mainTip(session)).toBe(compaction.id); await session.close(BACKGROUND_CONTEXT); }); it("builds the retained tail from the compaction branch rather than physical order", async () => { const firstMainMessage = { role: "user", content: [{ type: "text", text: "first main-branch message" }], timestamp: NOW + 10_000, } satisfies AgentMessage; const firstOtherBranchMessage = { role: "user", content: [{ type: "text", text: "first unrelated-branch message" }], timestamp: NOW + 11_000, } satisfies AgentMessage; const secondMainMessage = { role: "user", content: [{ type: "text", text: "second main-branch message" }], timestamp: NOW + 12_000, } satisfies AgentMessage; const secondOtherBranchMessage = { role: "user", content: [{ type: "text", text: "second unrelated-branch message" }], timestamp: NOW + 13_000, } satisfies AgentMessage; await writeLegacyV3Fixture([ { type: "message", id: "main-1", parentId: null, timestamp: new Date(NOW + 10_000).toISOString(), message: firstMainMessage, }, { type: "message", id: "other-1", parentId: "main-1", timestamp: new Date(NOW + 11_000).toISOString(), message: firstOtherBranchMessage, }, { type: "message", id: "main-2", parentId: "main-1", timestamp: new Date(NOW + 12_000).toISOString(), message: secondMainMessage, }, { type: "message", id: "other-2", parentId: "other-1", timestamp: new Date(NOW + 13_000).toISOString(), message: secondOtherBranchMessage, }, { type: "compaction", id: "compaction", parentId: "main-2", timestamp: new Date(NOW + 14_000).toISOString(), summary: "Summary before the retained main branch", firstKeptEntryId: "main-1", tokensBefore: 8_000, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); const compaction = entries.find((entry) => entry.type === "compaction"); expect(compaction).toMatchObject({ type: "compaction", retainedTail: [firstMainMessage, secondMainMessage], }); await session.close(BACKGROUND_CONTEXT); }); it("projects every supported legacy node in a retained tail", async () => { const ordinaryTimestamp = NOW + 30_000; const customMessageTimestamp = NOW + 31_000; const customEntryTimestamp = NOW + 32_000; const branchSummaryTimestamp = NOW + 33_000; const olderCompactionTimestamp = NOW + 34_000; const finalCompactionTimestamp = NOW + 35_000; const ordinaryMessage = { role: "user", content: [{ type: "text", text: "ordinary retained message" }], timestamp: ordinaryTimestamp, } satisfies AgentMessage; const customContent = [{ type: "text" as const, text: "custom retained message" }]; const customDetails = { status: "complete" }; await writeLegacyV3Fixture([ { type: "message", id: "ordinary-message", parentId: null, timestamp: new Date(ordinaryTimestamp).toISOString(), message: ordinaryMessage, }, { type: "custom_message", id: "custom-message", parentId: "ordinary-message", timestamp: new Date(customMessageTimestamp).toISOString(), customType: "notice", content: customContent, details: customDetails, display: false, }, { type: "custom", id: "plain-custom", parentId: "custom-message", timestamp: new Date(customEntryTimestamp).toISOString(), customType: "checkpoint", data: { ignored: true }, }, { type: "branch_summary", id: "branch-summary", parentId: "plain-custom", timestamp: new Date(branchSummaryTimestamp).toISOString(), fromId: "ordinary-message", summary: "Earlier branch work", }, { type: "compaction", id: "older-compaction", parentId: "branch-summary", timestamp: new Date(olderCompactionTimestamp).toISOString(), summary: "Older compacted context", firstKeptEntryId: "ordinary-message", tokensBefore: 4_000, }, { type: "compaction", id: "final-compaction", parentId: "older-compaction", timestamp: new Date(finalCompactionTimestamp).toISOString(), summary: "Final compacted context", firstKeptEntryId: "ordinary-message", tokensBefore: 8_000, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(6); const ordinary = entries.at(0); const finalCompaction = entries.at(-1); if (ordinary === undefined || finalCompaction?.type !== "compaction") { throw new Error("Legacy retained-tail fixture was not imported"); } expect(uuidTimestamp(ordinary.id)).toBe(ordinaryTimestamp); expect(finalCompaction.retainedTail).toEqual([ ordinaryMessage, { role: "custom", customType: "notice", content: customContent, details: customDetails, display: false, timestamp: customMessageTimestamp, }, { role: "branchSummary", summary: "Earlier branch work", fromId: ordinary.id, timestamp: branchSummaryTimestamp, }, { role: "compactionSummary", summary: "Older compacted context", tokensBefore: 4_000, timestamp: olderCompactionTimestamp, }, ]); await session.close(BACKGROUND_CONTEXT); }); it.each([ { boundary: "missing", firstKeptEntryId: "missing-entry" }, { boundary: "off-branch", firstKeptEntryId: "other-branch" }, ])("rejects a $boundary firstKeptEntryId", async ({ firstKeptEntryId }) => { await writeLegacyV3Fixture([ { type: "message", id: "root", parentId: null, timestamp: new Date(NOW + 20_000).toISOString(), message: excludedMessage, }, { type: "message", id: "main-branch", parentId: "root", timestamp: new Date(NOW + 21_000).toISOString(), message: retainedMessage, }, { type: "message", id: "other-branch", parentId: "root", timestamp: new Date(NOW + 22_000).toISOString(), message: excludedMessage, }, { type: "compaction", id: "compaction", parentId: "main-branch", timestamp: new Date(NOW + 23_000).toISOString(), summary: "Summary before the retained main branch", firstKeptEntryId, tokensBefore: 8_000, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); await expect(repo.open(metadata, BACKGROUND_CONTEXT)).rejects.toThrow( "firstKeptEntryId is not on its parent branch", ); }); it("preserves an explicit fromHook flag", async () => { const { session, compaction } = await openFixture(true); expect(compaction).toMatchObject({ type: "compaction", fromHook: true }); await session.close(BACKGROUND_CONTEXT); }); }); it("remaps a legacy message chain and exposes it through current APIs", async () => { const firstTimestamp = NOW + 1_000; const secondTimestamp = NOW + 2_000; const firstMessage = { role: "user", content: [{ type: "text", text: "first" }], timestamp: firstTimestamp, } satisfies AgentMessage; const secondMessage = { role: "user", content: [{ type: "text", text: "second" }], timestamp: secondTimestamp, } satisfies AgentMessage; await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(firstTimestamp).toISOString(), message: firstMessage, }, { type: "message", id: "message-2", parentId: "message-1", timestamp: new Date(secondTimestamp).toISOString(), message: secondMessage, }, ]); const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); const session = await repo.open(metadata, BACKGROUND_CONTEXT); const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(2); const [first, second] = entries; if (first === undefined || second === undefined) throw new Error("Legacy message chain was not imported"); expect(first).toMatchObject({ parentId: null, seq: 1, timestamp: firstTimestamp, message: firstMessage, }); expect(second).toMatchObject({ parentId: first.id, seq: 2, timestamp: secondTimestamp, message: secondMessage, }); expect(await mainTip(session)).toBe(second.id); expect( (await (await mainBranch(session)).findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT)).map( (entry) => entry.id, ), ).toEqual([first.id, second.id]); expect((await session.getStats(BACKGROUND_CONTEXT)).messageCount).toBe(2); await session.close(BACKGROUND_CONTEXT); }); describe("opening a legacy v3 message session", () => { const entryTimestamp = NOW + 1_234; const message = { role: "user", content: [{ type: "text", text: "hello" }], timestamp: NOW + 1_000, } satisfies AgentMessage; let session: Session; let path: string; let content: string; let beforeMtime: number; beforeEach(async () => { ({ path, content } = await writeLegacyV3Fixture([ { type: "message", id: "message-1", parentId: null, timestamp: new Date(entryTimestamp).toISOString(), message, }, ])); beforeMtime = getOrThrow(await fileSystem.fileInfo(path, BACKGROUND_CONTEXT)).mtimeMs; const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT); if (metadata === undefined) throw new Error("Legacy fixture was not discovered"); session = await repo.open(metadata, BACKGROUND_CONTEXT); }); afterEach(async () => { await session?.close(BACKGROUND_CONTEXT); }); async function importedEntry(): Promise { const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT); expect(entries).toHaveLength(1); const [entry] = entries; if (entry === undefined) throw new Error("Legacy message was not imported"); return entry; } it("exposes the legacy message through the current entry API", async () => { expect(await importedEntry()).toMatchObject({ type: "message", parentId: null, seq: 1, timestamp: entryTimestamp, message, }); }); it("remints the entry id as a UUIDv7 with the legacy timestamp", async () => { const entry = await importedEntry(); expect(entry.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); expect(uuidTimestamp(entry.id)).toBe(entryTimestamp); }); it("initializes a data-only main Branch at the imported entry", async () => { const entry = await importedEntry(); expect(await mainTip(session)).toBe(entry.id); expect(await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT)).toBeUndefined(); expect(await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT)).toBeUndefined(); }); it("leaves the legacy source untouched after open and close", async () => { await session.close(BACKGROUND_CONTEXT); expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content); expect(getOrThrow(await fileSystem.fileInfo(path, BACKGROUND_CONTEXT)).mtimeMs).toBe(beforeMtime); }); }); });