Files
oh-my-pi/packages/coding-agent/test/session-manager/large-session-memory.test.ts
T
roboomp cb63ebd9f5 fix(session): preserved compaction data for rewinds
Kept superseded summaries and preserveData durable while deriving forward transcript elision from the active compaction.

Added branch and rewind coverage for snapcompact archives and OpenAI remote replacement history.

Fixes #4090
2026-07-23 19:53:05 +00:00

312 lines
11 KiB
TypeScript

import { afterEach, describe, expect, it } from "bun:test";
import * as fsp from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { listSessions } from "@oh-my-pi/pi-coding-agent/session/session-listing";
import { loadEntriesFromFile } from "@oh-my-pi/pi-coding-agent/session/session-loader";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { MemorySessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage";
import * as snapcompact from "@oh-my-pi/snapcompact";
class CountingMemorySessionStorage extends MemorySessionStorage {
writeTextSyncCalls = 0;
writeTextSync(filePath: string, content: string): void {
this.writeTextSyncCalls++;
super.writeTextSync(filePath, content);
}
}
function makeAssistantMessage(text: string) {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected built-in anthropic model to exist");
return {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: 2,
};
}
describe("large session memory guards", () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.map(dir => fsp.rm(dir, { recursive: true, force: true })));
tempDirs.length = 0;
});
it("does not rewrite an already-current session during sync flush", () => {
const storage = new CountingMemorySessionStorage();
const session = SessionManager.create("/work", "/sessions", storage);
session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
session.appendMessage(makeAssistantMessage("hi"));
storage.writeTextSyncCalls = 0;
session.flushSync();
expect(storage.writeTextSyncCalls).toBe(0);
});
it("elides superseded compactions only in the forward transcript", async () => {
const storage = new CountingMemorySessionStorage();
const session = SessionManager.create("/work", "/sessions", storage);
const firstKeptEntryId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
session.appendMessage(makeAssistantMessage("hi"));
const firstSummary = `first-${"x".repeat(4096)}`;
const secondSummary = `second-${"y".repeat(4096)}`;
const archivedFrame = btoa("archived frame");
const replacementHistory = [
{ type: "message", role: "user", content: [{ type: "input_text", text: "Preserved user" }] },
];
const firstPreserve = {
openaiRemoteCompaction: { provider: "openai", replacementHistory },
[snapcompact.PRESERVE_KEY]: {
frames: [{ data: archivedFrame, mimeType: "image/png", cols: 10, rows: 10, chars: 14 }],
totalChars: 14,
truncatedChars: 0,
textHead: "archived",
textTail: "frame",
},
};
const firstCompactionId = session.appendCompaction(
firstSummary,
undefined,
firstKeptEntryId,
1000,
undefined,
undefined,
firstPreserve,
);
const rewindId = session.appendMessage({ role: "user", content: "between compactions", timestamp: 3 });
session.appendCompaction(secondSummary, undefined, rewindId, 2000);
await session.flush();
const firstCompaction = session.getEntry(firstCompactionId);
if (firstCompaction?.type !== "compaction") throw new Error("Expected first compaction");
expect(firstCompaction.summary).toBe(firstSummary);
expect(firstCompaction.preserveData).toEqual(firstPreserve);
const transcriptCompactions = session
.buildSessionContext({ transcript: true })
.messages.filter(message => message.role === "compactionSummary");
const supersededDisplay = transcriptCompactions[0];
if (supersededDisplay?.role !== "compactionSummary") throw new Error("Expected superseded transcript compaction");
expect(supersededDisplay.summary).toContain("Superseded compaction");
expect((supersededDisplay.blocks ?? []).some(block => block.type === "image")).toBeFalse();
session.branch(rewindId);
const rewoundSummary = session.buildSessionContext().messages[0];
if (rewoundSummary?.role !== "compactionSummary") throw new Error("Expected rewound compaction summary");
expect(rewoundSummary.summary).toBe(firstSummary);
expect(rewoundSummary.providerPayload).toEqual({
type: "openaiResponsesHistory",
provider: "openai",
items: replacementHistory,
});
expect(rewoundSummary.blocks?.find(block => block.type === "image")).toMatchObject({ data: archivedFrame });
const sessionFile = session.getSessionFile();
if (!sessionFile) throw new Error("Expected session file");
const persisted = await storage.readText(sessionFile);
expect(persisted).toContain(firstSummary);
expect(persisted).toContain(archivedFrame);
expect(persisted).toContain(secondSummary);
});
it("streams large session files without discarding historical compactions", async () => {
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-large-session-"));
tempDirs.push(tempDir);
const sessionFile = path.join(tempDir, "large.jsonl");
const oldSummary = `old-${"x".repeat(5 * 1024 * 1024)}`;
const latestSummary = `latest-${"y".repeat(5 * 1024 * 1024)}`;
const lines = [
{ type: "session", version: 3, id: "sess", timestamp: "2026-01-01T00:00:00.000Z", cwd: tempDir },
{
type: "message",
id: "u1",
parentId: null,
timestamp: "2026-01-01T00:00:01.000Z",
message: { role: "user", content: "hi", timestamp: 1 },
},
{
type: "compaction",
id: "c1",
parentId: "u1",
timestamp: "2026-01-01T00:00:02.000Z",
summary: oldSummary,
firstKeptEntryId: "u1",
tokensBefore: 1000,
preserveData: { stale: true },
},
{
type: "message",
id: "a1",
parentId: "c1",
timestamp: "2026-01-01T00:00:03.000Z",
message: makeAssistantMessage("hello"),
},
{
type: "compaction",
id: "c2",
parentId: "a1",
timestamp: "2026-01-01T00:00:04.000Z",
summary: latestSummary,
firstKeptEntryId: "a1",
tokensBefore: 1000,
},
].map(entry => `${JSON.stringify(entry)}\n`);
await fsp.writeFile(sessionFile, lines.join(""));
const entries = await loadEntriesFromFile(sessionFile);
const compactions = entries.filter(entry => entry.type === "compaction");
expect(compactions).toHaveLength(2);
expect(compactions[0]?.summary).toBe(oldSummary);
expect(compactions[0]?.preserveData).toEqual({ stale: true });
expect(compactions[1]?.summary).toBe(latestSummary);
});
it("preserves sibling-branch compactions when a newer compaction lands on another branch", async () => {
const storage = new CountingMemorySessionStorage();
const session = SessionManager.create("/work", "/sessions", storage);
const rootId = session.appendMessage({ role: "user", content: "shared root", timestamp: 1 });
session.appendMessage(makeAssistantMessage("root reply"));
const branchACompactionSummary = `branch-a-${"x".repeat(1024)}`;
const branchAPreserve = { openaiRemoteCompaction: { provider: "anthropic", replacementHistory: [] } };
session.appendCompaction(
branchACompactionSummary,
undefined,
rootId,
1000,
undefined,
undefined,
branchAPreserve,
);
const branchACompactionId = session.getLeafId();
if (!branchACompactionId) throw new Error("Expected branch A compaction id");
session.branch(rootId);
session.appendMessage(makeAssistantMessage("branch B reply"));
const branchBCompactionSummary = `branch-b-${"y".repeat(1024)}`;
session.appendCompaction(branchBCompactionSummary, undefined, rootId, 1000);
const branchACompaction = session.getEntry(branchACompactionId);
if (branchACompaction?.type !== "compaction") throw new Error("Expected sibling compaction entry");
expect(branchACompaction.summary).toBe(branchACompactionSummary);
expect(branchACompaction.preserveData).toEqual(branchAPreserve);
const branchBCompactions = session
.getEntries()
.filter(entry => entry.type === "compaction" && entry.summary === branchBCompactionSummary);
expect(branchBCompactions).toHaveLength(1);
});
it("preserves loaded compactions on every branch", async () => {
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-branch-load-"));
tempDirs.push(tempDir);
const sessionFile = path.join(tempDir, "branched.jsonl");
const branchASummary = `branch-a-${"x".repeat(1024)}`;
const branchBOldSummary = `branch-b-old-${"y".repeat(1024)}`;
const branchBNewSummary = `branch-b-new-${"z".repeat(1024)}`;
const lines = [
{ type: "session", version: 3, id: "sess", timestamp: "2026-01-01T00:00:00.000Z", cwd: tempDir },
{
type: "message",
id: "u1",
parentId: null,
timestamp: "2026-01-01T00:00:01.000Z",
message: { role: "user", content: "shared", timestamp: 1 },
},
{
type: "compaction",
id: "ca",
parentId: "u1",
timestamp: "2026-01-01T00:00:02.000Z",
summary: branchASummary,
firstKeptEntryId: "u1",
tokensBefore: 1000,
preserveData: { openaiRemoteCompaction: { provider: "anthropic", replacementHistory: [] } },
},
{
type: "compaction",
id: "cb1",
parentId: "u1",
timestamp: "2026-01-01T00:00:03.000Z",
summary: branchBOldSummary,
firstKeptEntryId: "u1",
tokensBefore: 1000,
preserveData: { stale: true },
},
{
type: "message",
id: "a1",
parentId: "cb1",
timestamp: "2026-01-01T00:00:04.000Z",
message: makeAssistantMessage("branch b reply"),
},
{
type: "compaction",
id: "cb2",
parentId: "a1",
timestamp: "2026-01-01T00:00:05.000Z",
summary: branchBNewSummary,
firstKeptEntryId: "a1",
tokensBefore: 1000,
},
].map(entry => `${JSON.stringify(entry)}\n`);
await fsp.writeFile(sessionFile, lines.join(""));
const entries = await loadEntriesFromFile(sessionFile);
const byId = new Map(entries.map(entry => [(entry as { id?: string }).id, entry] as const));
const branchA = byId.get("ca");
const branchBOld = byId.get("cb1");
const branchBNew = byId.get("cb2");
if (branchA?.type !== "compaction" || branchBOld?.type !== "compaction" || branchBNew?.type !== "compaction") {
throw new Error("Expected compaction entries");
}
expect(branchA.summary).toBe(branchASummary);
expect(branchA.preserveData).toBeDefined();
expect(branchBOld.summary).toBe(branchBOldSummary);
expect(branchBOld.preserveData).toEqual({ stale: true });
expect(branchBNew.summary).toBe(branchBNewSummary);
});
it("uses developer prefix text when a fork has no early user message", async () => {
const storage = new MemorySessionStorage();
const sessionDir = "/sessions/project";
const sessionFile = `${sessionDir}/fork.jsonl`;
const lines = [
{ type: "session", version: 3, id: "fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/work" },
{
type: "message",
id: "d1",
parentId: null,
timestamp: "2026-01-01T00:00:01.000Z",
message: { role: "developer", content: "Plan fork context", timestamp: 1 },
},
].map(entry => `${JSON.stringify(entry)}\n`);
storage.writeTextSync(sessionFile, lines.join(""));
const sessions = await listSessions(sessionDir, storage);
expect(sessions).toHaveLength(1);
expect(sessions[0]?.firstMessage).toBe("Plan fork context");
});
});