273 lines
9.7 KiB
TypeScript
273 lines
9.7 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";
|
|
|
|
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 and rewrites the compacted file", 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)}`;
|
|
session.appendCompaction(firstSummary, undefined, firstKeptEntryId, 1000, undefined, undefined, {
|
|
openaiRemoteCompaction: { provider: "anthropic", replacementHistory: [] },
|
|
});
|
|
session.appendCompaction(secondSummary, undefined, firstKeptEntryId, 1000);
|
|
await session.flush();
|
|
|
|
const compactions = session.getEntries().filter(entry => entry.type === "compaction");
|
|
expect(compactions).toHaveLength(2);
|
|
expect(compactions[0]?.summary).not.toBe(firstSummary);
|
|
expect(compactions[0]?.summary).toContain("Superseded compaction");
|
|
expect(compactions[0]?.preserveData).toBeUndefined();
|
|
expect(compactions[1]?.summary).toBe(secondSummary);
|
|
|
|
const sessionFile = session.getSessionFile();
|
|
if (!sessionFile) throw new Error("Expected session file");
|
|
const persisted = await storage.readText(sessionFile);
|
|
expect(persisted).not.toContain(firstSummary);
|
|
expect(persisted).toContain(secondSummary);
|
|
});
|
|
|
|
it("streams large session files and keeps only the latest compaction summary", 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).not.toBe(oldSummary);
|
|
expect(compactions[0]?.summary).toContain("Superseded compaction");
|
|
expect(compactions[0]?.preserveData).toBeUndefined();
|
|
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("only elides loaded compactions on the active 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).toContain("Superseded compaction");
|
|
expect(branchBOld.preserveData).toBeUndefined();
|
|
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");
|
|
});
|
|
});
|