Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
20 kB · 567 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568import type { AgentMessage } from "@earendil-works/pi-agent-core";import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai";import { readFileSync } from "fs";import { join } from "path";import { beforeEach, describe, expect, it } from "vitest";import { type CompactionSettings, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, findCutPoint, parseCompactCommandArgs, prepareCompaction, shouldCompact,} from "../src/core/compaction/index.js";import { serializeConversation } from "../src/core/compaction/utils.js";import { buildSessionContext, type CompactionEntry, migrateSessionEntries, parseSessionEntries, type SessionEntry, type SessionMessageEntry,} from "../src/core/session-manager.js";
// ============================================================================// Test fixtures// ============================================================================
function loadLargeSessionEntries(): SessionEntry[] { const sessionPath = join(__dirname, "fixtures/large-session.jsonl"); const content = readFileSync(sessionPath, "utf-8"); const entries = parseSessionEntries(content); migrateSessionEntries(entries); // Add id/parentId for v1 fixtures return entries.filter((e): e is SessionEntry => e.type !== "session");}
function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage { return { input, output, cacheRead, cacheWrite, totalTokens: input + output + cacheRead + cacheWrite, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, };}
function createUserMessage(text: string): AgentMessage { return { role: "user", content: text, timestamp: Date.now() };}
function createAssistantMessage(text: string, usage?: Usage): AssistantMessage { return { role: "assistant", content: [{ type: "text", text }], usage: usage || createMockUsage(100, 50), stopReason: "stop", timestamp: Date.now(), api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", };}
let entryCounter = 0;let lastId: string | null = null;
function resetEntryCounter() { entryCounter = 0; lastId = null;}
// Reset counter before each test to get predictable IDsbeforeEach(() => { resetEntryCounter();});
function createMessageEntry(message: AgentMessage): SessionMessageEntry { const id = `test-id-${entryCounter++}`; const entry: SessionMessageEntry = { type: "message", id, parentId: lastId, timestamp: new Date().toISOString(), message, }; lastId = id; return entry;}
function createCompactionEntry(summary: string, firstKeptEntryId: string): CompactionEntry { const id = `test-id-${entryCounter++}`; const entry: CompactionEntry = { type: "compaction", id, parentId: lastId, timestamp: new Date().toISOString(), summary, firstKeptEntryId, tokensBefore: 10000, }; lastId = id; return entry;}
function extractText(messages: AgentMessage[]): string { return messages .map((message) => { switch (message.role) { case "user": return typeof message.content === "string" ? message.content : message.content .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) .join(" "); case "assistant": return message.content .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) .join(" "); case "branchSummary": case "compactionSummary": return message.summary; case "custom": case "toolResult": return typeof message.content === "string" ? message.content : message.content .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) .join(" "); case "bashExecution": return `${message.command}\n${message.output}`; default: return ""; } }) .join("\n");}
// ============================================================================// Unit tests// ============================================================================
describe("shouldCompact", () => { const settings: CompactionSettings = { enabled: true, reserveTokens: 10000, keepRecentTokens: 20000 };
it.each([ ["context exceeds the threshold", settings, 95000, 100000, true], ["context is below the threshold", settings, 89000, 100000, false], ["compaction is disabled", { ...settings, enabled: false }, 95000, 100000, false], ["the context window is unknown", settings, 95000, 0, false], ])("returns %s => %s when %s", (_label, config, contextTokens, contextWindow, expected) => { expect(shouldCompact(contextTokens, contextWindow, config)).toBe(expected); });
it("should respect maxContextTokens override when smaller than context window", () => { const settings: CompactionSettings = { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000, maxContextTokens: 600000, };
// 1M context window, but maxContextTokens is 600k. // Threshold is 600,000 - 16,384 = 583,616 tokens. expect(shouldCompact(580000, 1000000, settings)).toBe(false); expect(shouldCompact(590000, 1000000, settings)).toBe(true); expect(shouldCompact(650000, 1000000, settings)).toBe(true);
// Without maxContextTokens, 590k would not trigger compaction in a 1M window const defaultSettings = { ...settings, maxContextTokens: undefined }; expect(shouldCompact(590000, 1000000, defaultSettings)).toBe(false); });
it("should clamp to contextWindow when model context is smaller than maxContextTokens", () => { const settings: CompactionSettings = { enabled: true, reserveTokens: 10000, keepRecentTokens: 20000, maxContextTokens: 600000, };
// 200k model window is smaller than 600k maxContextTokens. // Threshold is 200,000 - 10,000 = 190,000 tokens. expect(shouldCompact(185000, 200000, settings)).toBe(false); expect(shouldCompact(195000, 200000, settings)).toBe(true); });});
describe("parseCompactCommandArgs", () => { it("should parse empty args as immediate compaction", () => { expect(parseCompactCommandArgs("")).toEqual({ kind: "immediate" }); expect(parseCompactCommandArgs(" ")).toEqual({ kind: "immediate" }); });
it("should parse instructions without at prefix as immediate compaction", () => { expect(parseCompactCommandArgs("focus on the test suite")).toEqual({ kind: "immediate", instructions: "focus on the test suite", }); });
it("should parse status queries", () => { expect(parseCompactCommandArgs("at")).toEqual({ kind: "status" }); expect(parseCompactCommandArgs("at status")).toEqual({ kind: "status" }); expect(parseCompactCommandArgs("AT STATUS")).toEqual({ kind: "status" }); });
it("should parse reset and off commands", () => { expect(parseCompactCommandArgs("at reset")).toEqual({ kind: "reset", global: false }); expect(parseCompactCommandArgs("at off")).toEqual({ kind: "reset", global: false }); expect(parseCompactCommandArgs("at clear")).toEqual({ kind: "reset", global: false }); expect(parseCompactCommandArgs("at default")).toEqual({ kind: "reset", global: false }); expect(parseCompactCommandArgs("at reset --global")).toEqual({ kind: "reset", global: true }); expect(parseCompactCommandArgs("at off --global")).toEqual({ kind: "reset", global: true }); });
it("should parse token thresholds with k, m, and numbers", () => { expect(parseCompactCommandArgs("at 600k")).toEqual({ kind: "set_threshold", threshold: 600000, rawValue: "600k", isPercent: false, global: false, instructions: undefined, });
expect(parseCompactCommandArgs("at 600K --global")).toEqual({ kind: "set_threshold", threshold: 600000, rawValue: "600K", isPercent: false, global: true, instructions: undefined, });
expect(parseCompactCommandArgs("at 1m")).toEqual({ kind: "set_threshold", threshold: 1000000, rawValue: "1m", isPercent: false, global: false, instructions: undefined, });
expect(parseCompactCommandArgs("at 1.5M")).toEqual({ kind: "set_threshold", threshold: 1500000, rawValue: "1.5M", isPercent: false, global: false, instructions: undefined, });
expect(parseCompactCommandArgs("at 600,000")).toEqual({ kind: "set_threshold", threshold: 600000, rawValue: "600,000", isPercent: false, global: false, instructions: undefined, });
expect(parseCompactCommandArgs("at 600000")).toEqual({ kind: "set_threshold", threshold: 600000, rawValue: "600000", isPercent: false, global: false, instructions: undefined, }); });
it("should parse percentage thresholds", () => { expect(parseCompactCommandArgs("at 50%", 1000000)).toEqual({ kind: "set_threshold", threshold: 500000, rawValue: "50%", isPercent: true, global: false, instructions: undefined, });
expect(parseCompactCommandArgs("at 75% --global", 200000)).toEqual({ kind: "set_threshold", threshold: 150000, rawValue: "75%", isPercent: true, global: true, instructions: undefined, }); });
it("should parse threshold with following instructions", () => { expect(parseCompactCommandArgs("at 600k focus on tests")).toEqual({ kind: "set_threshold", threshold: 600000, rawValue: "600k", isPercent: false, global: false, instructions: "focus on tests", });
expect(parseCompactCommandArgs("at 600k --global keep plan")).toEqual({ kind: "set_threshold", threshold: 600000, rawValue: "600k", isPercent: false, global: true, instructions: "keep plan", }); });
it("should fall back to immediate compaction when at is followed by non-threshold text", () => { expect(parseCompactCommandArgs("at the end of the day focus on tests")).toEqual({ kind: "immediate", instructions: "at the end of the day focus on tests", }); });});
describe("findCutPoint", () => { const hugeToolResult = () => ({ role: "toolResult" as const, toolCallId: "tc1", toolName: "ipython", content: [{ type: "text" as const, text: "x".repeat(40_000) }], isError: false, timestamp: Date.now(), });
// The final assistant run alone blows the budget, so the cut lands on an // assistant message mid-turn. const splitTurn = (): SessionEntry[] => [ createMessageEntry(createUserMessage("Turn 1")), createMessageEntry(createAssistantMessage("A1")), createMessageEntry(createUserMessage("Turn 2")), // index 2: turn start createMessageEntry(createAssistantMessage("x".repeat(40_000))), createMessageEntry(createAssistantMessage("x".repeat(40_000))), // index 4: cut here ];
it.each([ [ "keeps everything when the range holds no valid cut point", () => [createMessageEntry(createAssistantMessage("a"))], 1000, { firstKeptEntryIndex: 0 }, ], [ "keeps everything when the whole history fits the budget", (): SessionEntry[] => [ createMessageEntry(createUserMessage("1")), createMessageEntry(createAssistantMessage("a", createMockUsage(0, 50, 500, 0))), createMessageEntry(createUserMessage("2")), createMessageEntry(createAssistantMessage("b", createMockUsage(0, 50, 1000, 0))), ], 50000, { firstKeptEntryIndex: 0 }, ], [ // The budget is crossed inside the trailing tool results, past every cut // point; the whole history must not be silently kept. "keeps only the final turn when the budget is crossed inside trailing tool results", (): SessionEntry[] => [ createMessageEntry(createUserMessage("Turn 1")), createMessageEntry(createAssistantMessage("A1", createMockUsage(0, 100, 1000, 0))), createMessageEntry(createUserMessage("Turn 2")), createMessageEntry(createAssistantMessage("A2", createMockUsage(0, 100, 2000, 0))), // last cut point createMessageEntry(hugeToolResult()), createMessageEntry(hugeToolResult()), ], 1000, { firstKeptEntryIndex: 3 }, ], [ "reports a split turn when the cut lands inside an assistant run", splitTurn, 3000, { firstKeptEntryIndex: 4, isSplitTurn: true, turnStartIndex: 2 }, ], ])("%s", (_label, makeEntries, keepRecentTokens, expected) => { const entries = makeEntries(); const result = findCutPoint(entries, 0, entries.length, keepRecentTokens); expect(result).toMatchObject(expected); });});
describe("prepareCompaction with small sessions", () => { it("returns undefined when everything fits in the keep-recent window", () => { // Session well under keepRecentTokens (20k default): nothing to summarize, // so compaction should be skipped instead of summarizing an empty conversation const entries: SessionEntry[] = [ createMessageEntry(createUserMessage("hello")), createMessageEntry(createAssistantMessage("hi there", createMockUsage(5000, 1000))), createMessageEntry(createUserMessage("how are you")), createMessageEntry(createAssistantMessage("great", createMockUsage(8000, 2000))), ];
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS); expect(preparation).toBeUndefined(); });});
describe("prepareCompaction with previous compaction", () => { it("should preserve kept messages across repeated compactions when they still fit", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)")); const a1 = createMessageEntry(createAssistantMessage("assistant msg 1")); const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1")); const a2 = createMessageEntry(createAssistantMessage("assistant msg 2")); const u3 = createMessageEntry(createUserMessage("user msg 3 - kept by compaction1")); const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(5000, 1000))); const compaction1 = createCompactionEntry("First summary", u2.id); const u4 = createMessageEntry(createUserMessage("user msg 4 (new after compaction1)")); const a4 = createMessageEntry(createAssistantMessage("assistant msg 4", createMockUsage(8000, 2000)));
const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4]; const contextBefore = buildSessionContext(pathEntries); const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toBeDefined(); expect(preparation!.firstKeptEntryId).toBe(u2.id); expect(preparation!.previousSummary).toBe("First summary"); expect(extractText(preparation!.messagesToSummarize)).not.toContain("First summary"); expect(preparation!.tokensBefore).toBe(estimateContextTokens(contextBefore.messages).tokens);
const compaction2: CompactionEntry = { type: "compaction", id: "compaction2-id", parentId: a4.id, timestamp: new Date().toISOString(), summary: "Second summary", firstKeptEntryId: preparation!.firstKeptEntryId, tokensBefore: preparation!.tokensBefore, }; const contextAfter = buildSessionContext([...pathEntries, compaction2]); const contextAfterText = extractText(contextAfter.messages);
expect(contextAfterText).toContain("user msg 2 - kept by compaction1"); expect(contextAfterText).toContain("user msg 3 - kept by compaction1"); });
it("should re-summarize previously kept messages when the recent window moves past them", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)".repeat(4))); const a1 = createMessageEntry(createAssistantMessage("assistant msg 1".repeat(4))); const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1 ".repeat(12))); const a2 = createMessageEntry(createAssistantMessage("assistant msg 2 ".repeat(12))); const u3 = createMessageEntry(createUserMessage("user msg 3 - kept by compaction1 ".repeat(12))); const a3 = createMessageEntry(createAssistantMessage("assistant msg 3 ".repeat(12), createMockUsage(5000, 1000))); const compaction1 = createCompactionEntry("First summary", u2.id); const u4 = createMessageEntry(createUserMessage("user msg 4 (new after compaction1) ".repeat(12))); const a4 = createMessageEntry(createAssistantMessage("assistant msg 4 ".repeat(12), createMockUsage(8000, 2000)));
const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 100, }; const preparation = prepareCompaction([u1, a1, u2, a2, u3, a3, compaction1, u4, a4], settings);
expect(preparation).toBeDefined(); const summarizedText = extractText(preparation!.messagesToSummarize); expect(summarizedText).toContain("user msg 2 - kept by compaction1"); expect(summarizedText).toContain("user msg 3 - kept by compaction1"); expect(summarizedText).not.toContain("First summary"); expect(preparation!.previousSummary).toBe("First summary"); });});
// ============================================================================// Integration tests with real session data// ============================================================================
describe("Large session fixture", () => { it("should parse the large session", () => { const entries = loadLargeSessionEntries(); expect(entries.length).toBeGreaterThan(100);
const messageCount = entries.filter((e) => e.type === "message").length; expect(messageCount).toBeGreaterThan(100); });
it("should find cut point in large session", () => { const entries = loadLargeSessionEntries(); const result = findCutPoint(entries, 0, entries.length, DEFAULT_COMPACTION_SETTINGS.keepRecentTokens);
// Cut point should be at a message entry (user or assistant) expect(entries[result.firstKeptEntryIndex].type).toBe("message"); const role = (entries[result.firstKeptEntryIndex] as SessionMessageEntry).message.role; expect(role === "user" || role === "assistant").toBe(true); });
it("should load session correctly", () => { const entries = loadLargeSessionEntries(); const loaded = buildSessionContext(entries);
expect(loaded.messages.length).toBeGreaterThan(100); expect(loaded.model).not.toBeNull(); });});
// ============================================================================// LLM integration tests (skipped without API key)// ============================================================================// ============================================================================// Conversation serialization (merged from compaction-serialization.test.ts)// ============================================================================
function toolResult(text: string): Message { return { role: "toolResult", toolCallId: "tc1", toolName: "ipython", content: [{ type: "text", text }], isError: false, timestamp: Date.now(), };}
describe("serializeConversation", () => { it("truncates long tool results keeping head and tail within the summary budget", () => { const head = "A".repeat(1431); const tail = "T".repeat(500); const result = serializeConversation([toolResult(head + "B".repeat(3069) + tail)]);
expect(result).toContain("[Tool result]:"); expect(result).toContain(head); expect(result).toContain(tail); expect(result).toContain("[... 3069 characters truncated; first 1431 and last 500 kept ...]"); expect(result).not.toContain("B".repeat(10)); expect(result.length).toBeLessThanOrEqual("[Tool result]: ".length + 2000); });
it("leaves short tool results untouched", () => { const shortContent = "x".repeat(1500);
expect(serializeConversation([toolResult(shortContent)])).toBe(`[Tool result]: ${shortContent}`); });
it("does not truncate user or assistant messages", () => { const longText = "y".repeat(5000); const usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; const result = serializeConversation([ { role: "user", content: [{ type: "text", text: longText }], timestamp: Date.now() }, { role: "assistant", content: [{ type: "text", text: longText }], api: "anthropic", provider: "anthropic", model: "test", usage, stopReason: "stop", timestamp: Date.now(), }, ]);
expect(result).not.toContain("truncated"); expect(result).toContain(longText); });});