import { describe, expect, it } from "vitest"; import { allocateAtoms, atomWeight, combineUsage, createSourceChunks, type DecayDetails, findPreviousDecayDetails, formatFileAppendix, isTransientFailure, type MemoryAtom, mergeFileLists, mergeMemory, missingChunks, parseDecayDetails, parseModelReference, type RecordChunk, reconcileRecords, renderFallbackSummary, type SourceChunk, validateRecordChunk, } from "../core"; function user(text: string) { return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; } function record( chunk: number, of: number, key: string, overrides: Partial = {}, ): RecordChunk { return { chunk, of, atoms: [ { key, text: `fact ${key}`, kind: "context", status: "active", ...overrides, }, ], }; } function memory(overrides: Partial = {}): MemoryAtom { return { key: "fact", text: "fact", kind: "context", firstSeen: 1, lastSeen: 1, seenCount: 1, status: "active", sourcePosition: 1, ...overrides, }; } function chunk( chunkNumber: number, of: number, source: SourceChunk["source"] = "history", ): SourceChunk { return { chunk: chunkNumber, of, source, firstPosition: chunkNumber, lastPosition: chunkNumber, text: "source", estimatedTokens: 1, }; } const zeroUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; describe("model references", () => { it("splits provider from model id", () => { expect(parseModelReference("provider/model/variant")).toEqual({ provider: "provider", modelId: "model/variant", }); expect(parseModelReference("broken")).toBeUndefined(); }); }); describe("source chunking", () => { it("keeps legacy summary, history, and split prefix in distinct ordered chunks", () => { const chunks = createSourceChunks({ legacySummary: "old checkpoint", messages: [user("history one"), user("history two")] as never, turnPrefixMessages: [user("split") as never], targetTokens: 100, }); expect(chunks.map((item) => item.source)).toEqual([ "legacy-summary", "history", "split-prefix", ]); expect(chunks.map((item) => item.chunk)).toEqual([1, 2, 3]); expect(chunks.every((item) => item.of === 3)).toBe(true); expect(chunks[1].text).toContain("MESSAGE 1 SOURCE history"); expect(chunks[1].text).toContain("MESSAGE 2 SOURCE history"); }); it("never splits one oversized message", () => { const chunks = createSourceChunks({ messages: [user("x".repeat(1_000))] as never, turnPrefixMessages: [], targetTokens: 10, }); expect(chunks).toHaveLength(1); expect(chunks[0].text).toContain("x".repeat(1_000)); }); it("keeps short exchanges separate and groups consecutive user inputs", () => { const chunks = createSourceChunks({ messages: [ user("first"), user("clarification"), { role: "assistant", content: [{ type: "text", text: "done" }] }, user("second"), ] as never, turnPrefixMessages: [], targetTokens: 10_000, }); expect(chunks).toHaveLength(2); expect(chunks[0].text).toContain("clarification"); expect(chunks[0].text).toContain("done"); expect(chunks[1].text).toContain("second"); expect(chunks[1].text).not.toContain("first"); }); it("keeps multi-tool batches intact and separates steered requests", () => { const chunks = createSourceChunks({ messages: [ user("investigate"), { role: "assistant", content: [ { type: "text", text: "checking" }, { type: "toolCall", id: "one", name: "read", arguments: {} }, { type: "toolCall", id: "two", name: "read", arguments: {} }, ], }, { role: "toolResult", toolCallId: "one", toolName: "read", content: [{ type: "text", text: "a".repeat(800) }], }, { role: "toolResult", toolCallId: "two", toolName: "read", content: [{ type: "text", text: "b".repeat(800) }], }, { role: "assistant", content: [{ type: "text", text: "second result is stale" }], }, user("actually, stop"), ] as never, turnPrefixMessages: [], targetTokens: 100, }); expect(chunks.at(-2)?.text).toContain("b".repeat(800)); expect(chunks.at(-2)?.text).toContain("a".repeat(800)); expect(chunks.at(-2)?.text).toContain("second result is stale"); expect(chunks.at(-1)?.text).toContain("actually, stop"); }); }); describe("classifier records", () => { it("accepts more than 16 atoms without dropping facts", () => { const many = record(1, 1, "fact-0"); many.atoms = Array.from({ length: 20 }, (_, index) => ({ ...many.atoms[0], key: `fact-${index}`, })); expect(validateRecordChunk(many)?.atoms).toHaveLength(20); expect( mergeMemory([], reconcileRecords([many], 1), [chunk(1, 1)]), ).toHaveLength(20); }); it("strictly validates schema and rejects unknown fields", () => { expect(validateRecordChunk(record(1, 1, "one"))).toBeDefined(); expect( validateRecordChunk({ ...record(1, 1, "one"), extra: true }), ).toBeUndefined(); expect( validateRecordChunk({ ...record(1, 1, "one"), atoms: [{ ...record(1, 1, "one").atoms[0], extra: true }], }), ).toBeUndefined(); expect( validateRecordChunk({ ...record(1, 1, "one"), atoms: [{ ...record(1, 1, "one").atoms[0], sourcePosition: 99 }], }), ).toBeUndefined(); }); it("sorts complete out-of-order records", () => { expect( reconcileRecords([record(2, 2, "two"), record(1, 2, "one")], 2), ).toEqual([record(1, 2, "one"), record(2, 2, "two")]); }); it("tolerates exact duplicates and reports missing chunks", () => { expect( reconcileRecords( [record(1, 2, "one"), record(1, 2, "one"), record(2, 2, "two")], 2, ), ).toHaveLength(2); expect(missingChunks([record(2, 3, "two")], 3)).toEqual([1, 3]); expect(missingChunks([record(1, 1, "one")], 1)).toEqual([]); }); it("rejects conflicts, missing chunks, wrong totals, and invalid indexes", () => { expect(() => reconcileRecords([record(1, 2, "one"), record(1, 2, "again")], 2), ).toThrow("reclassified chunk 1"); expect(() => reconcileRecords([record(1, 2, "one")], 2)).toThrow( "omitted chunks: 2", ); expect(() => reconcileRecords([record(1, 3, "one")], 2)).toThrow( "reported 3 chunks, expected 2", ); expect(() => reconcileRecords([record(3, 2, "three")], 2)).toThrow( "invalid chunk 3", ); }); }); describe("structured memory", () => { it("migrates legacy summary at epoch zero and counts semantic recurrence", () => { const records = [ record(1, 3, "same", { text: "old wording" }), record(2, 3, "same", { text: "new wording" }), record(3, 3, "same", { text: "newest wording", kind: "decision" }), ]; const atoms = mergeMemory([], records, [ chunk(1, 3, "legacy-summary"), chunk(2, 3), chunk(3, 3), ]); expect(atoms).toEqual([ expect.objectContaining({ key: "same", text: "newest wording", kind: "decision", firstSeen: 0, lastSeen: 2, seenCount: 3, sourcePosition: 3, }), ]); }); it("does not count duplicate atoms inside one chunk as recurrence", () => { const duplicated = record(1, 1, "same"); duplicated.atoms.push({ ...duplicated.atoms[0], text: "latest" }); const atoms = mergeMemory([], [duplicated], [chunk(1, 1)]); expect(atoms[0]).toEqual( expect.objectContaining({ seenCount: 1, text: "latest" }), ); }); it("updates prior atoms and lets latest current status supersede", () => { const atoms = mergeMemory( [memory({ key: " Rule ", seenCount: 2, lastSeen: 4 })], [record(1, 1, "rule", { status: "superseded", text: "replaced" })], [chunk(1, 1)], ); expect(atoms[0]).toEqual( expect.objectContaining({ key: "rule", text: "replaced", seenCount: 3, lastSeen: 5, status: "superseded", }), ); }); it("weights recency and logarithmically bounded recurrence", () => { const newest = memory({ lastSeen: 4, seenCount: 1 }); const oldRecurring = memory({ lastSeen: 1, seenCount: 16 }); expect(atomWeight(newest, 4)).toBe(1); expect(atomWeight(oldRecurring, 4)).toBe(0.75); }); it("records recency inside the current discarded history", () => { const atoms = mergeMemory( [], [record(1, 2, "old"), record(2, 2, "new")], [chunk(1, 2), chunk(2, 2)], ); expect(atoms.map((atom) => [atom.key, atom.lastSeen])).toEqual([ ["old", 1], ["new", 2], ]); const allocated = new Map( allocateAtoms(atoms, 200).map((atom) => [atom.key, atom.allocation]), ); expect(allocated.get("new")).toBeGreaterThan(allocated.get("old") ?? 0); }); it("always allocates protected active atoms and favors newer ordinary atoms", () => { const allocated = allocateAtoms( [ memory({ key: "law", kind: "constraint", lastSeen: 1 }), memory({ key: "old", lastSeen: 1 }), memory({ key: "new", lastSeen: 3 }), memory({ key: "gone", status: "superseded", lastSeen: 4 }), ], 200, ); const byKey = new Map(allocated.map((atom) => [atom.key, atom])); expect(byKey.get("law")?.protected).toBe(true); expect(byKey.get("law")?.allocation).toBeGreaterThanOrEqual(24); expect(byKey.get("new")?.allocation).toBeGreaterThan( byKey.get("old")?.allocation ?? 0, ); expect(byKey.has("gone")).toBe(false); }); it("reads only valid details from the latest compaction boundary", () => { const details: DecayDetails = { decay: { version: 1, model: "p/m", elapsedMs: 10, chunkCount: 1, keepRecentTokens: 20_000, atoms: [memory()], readFiles: ["read"], modifiedFiles: ["write"], }, }; expect(parseDecayDetails(details)).toEqual(details); expect( findPreviousDecayDetails([ { type: "compaction", details, id: "1", parentId: null } as never, ]), ).toEqual(details.decay); expect( findPreviousDecayDetails([ { type: "compaction", details, id: "1", parentId: null } as never, { type: "compaction", details: {}, id: "2", parentId: "1" } as never, ]), ).toBeUndefined(); }); }); describe("deterministic appendices and usage", () => { it("merges files, moves later modifications out of read-only, and sorts", () => { const files = mergeFileLists( { readFiles: ["z", "both"], modifiedFiles: ["old"] }, { read: new Set(["a"]), written: new Set(["both"]), edited: new Set(["new"]), }, ); expect(files).toEqual({ readFiles: ["a", "z"], modifiedFiles: ["both", "new", "old"], }); expect(formatFileAppendix(files.readFiles, files.modifiedFiles)).toBe( "\n\n\na\nz\n\n\n\nboth\nnew\nold\n", ); }); it("combines every usage and cost field", () => { const combined = combineUsage( { ...zeroUsage, input: 2, totalTokens: 2, reasoning: 1 }, { ...zeroUsage, output: 3, totalTokens: 3, cacheWrite1h: 2, cost: { ...zeroUsage.cost, output: 0.2, total: 0.2 }, }, ); expect(combined).toEqual( expect.objectContaining({ input: 2, output: 3, totalTokens: 5, reasoning: 1, cacheWrite1h: 2, cost: expect.objectContaining({ output: 0.2, total: 0.2 }), }), ); }); }); describe("degraded summarizer", () => { function atom(overrides: Partial): MemoryAtom { return { key: "k", text: "t", kind: "context", firstSeen: 1, lastSeen: 1, seenCount: 1, status: "active", sourcePosition: 0, ...overrides, }; } it("renders every heading and only classified facts", () => { const summary = renderFallbackSummary( allocateAtoms( [ atom({ key: "goal", text: "Ship decay", kind: "goal" }), atom({ key: "blocked", text: "Waiting on auth", kind: "blocker" }), atom({ key: "path", text: "src/main.ts", kind: "exact" }), atom({ key: "dropped", text: "Old idea", status: "superseded" }), ], 4_000, ), ); expect(summary).toContain("Decay summarizer unavailable"); for (const heading of [ "## Goal", "## Constraints & Preferences", "## Progress", "### Done", "### In Progress", "### Blocked", "## Key Decisions", "## Next Steps", "## Critical Context", ]) expect(summary).toContain(heading); expect(summary).toContain("- Ship decay"); expect(summary).toContain("- Waiting on auth"); expect(summary).toContain("- src/main.ts"); expect(summary).not.toContain("Old idea"); }); it("classifies transient failures without treating aborts as retryable", () => { for (const message of [ "429 rate limit exceeded", "upstream returned 503", "socket hang up", "fetch failed", "Decay classifier stream ended without completion", ]) expect(isTransientFailure(new Error(message))).toBe(true); const abort = new Error("The operation was aborted"); abort.name = "AbortError"; expect(isTransientFailure(abort)).toBe(false); expect( isTransientFailure(new Error("Decay classifier called unknown tool: x")), ).toBe(false); expect( isTransientFailure(new Error("Decay model authentication failed")), ).toBe(false); }); });