Files
dev/packages/agent/test/harness/jsonl-v3-migration.test.ts
alexion 67b361f0e9 Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi
git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
2026-09-14 10:40:08 -04:00

2014 lines
66 KiB
TypeScript

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<Result<void, FileError>> {
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<string | null> {
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<JsonlSessionMetadata>): Promise<Entry[]> {
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<JsonlSessionMetadata>;
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<Entry> {
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);
});
});
});