import { describe, expect, it } from "bun:test"; import { microcompact, summarizeSession, extractAndPersist, distillSession, type ConversationMessage, type SummaryResult, } from "../session-distiller.js"; // ============================================================================ // Helpers // ============================================================================ function msg(role: ConversationMessage["role"], content: string, tool_name?: string): ConversationMessage { return { role, content, tool_name }; } function toolMsg(name: string, content: string): ConversationMessage { return { role: "tool", content, tool_name: name }; } function createMockLLM(response: string | null = null) { return { chatLong: async (_system: string, _user: string, _maxTokens?: number) => response, breaker: { canAttempt: () => true, recordSuccess: () => {}, recordFailure: () => {}, getState: () => "closed" as const, getFailureCount: () => 0 }, } as ReturnType & { chatLong: (s: string, u: string, m?: number) => Promise }; } function createMockDeps() { const stored: Array> = []; let seq = 1; return { stored, deps: { store: { async store(entry: Record) { const record = { ...entry, id: `distill-${String(seq).padStart(8, "0")}`, timestamp: 1_700_000_000_000 + seq, }; seq++; stored.push(record); return record; }, async list() { return stored; }, async update(id: string, updates: Record) { const idx = stored.findIndex((e) => e.id === id); if (idx < 0) return null; stored[idx] = { ...stored[idx], ...updates }; return stored[idx]; }, async getById(id: string) { return stored.find((e) => e.id === id) || null; }, async get(id: string) { return stored.find((e) => e.id === id) || null; }, async vectorSearch() { return []; }, }, embedder: { async embedPassage(_text: string) { return [0.1, 0.2, 0.3]; }, }, conflictStore: { async save(record: Record) { return record; }, async replace(record: Record) { return record; }, async getOpenByFingerprint() { return null; }, async getLatestByFingerprint() { return null; }, }, }, }; } // ============================================================================ // Layer 1: microcompact // ============================================================================ describe("microcompact", () => { it("returns empty for empty input", () => { const { messages, result } = microcompact([]); expect(messages).toEqual([]); expect(result.tokens_freed).toBe(0); expect(result.tools_cleared).toBe(0); }); it("preserves all messages when count <= preserveRecent", () => { const input = [ msg("user", "hello"), msg("assistant", "hi there"), msg("user", "bye"), ]; const { messages, result } = microcompact(input, { preserveRecent: 6 }); expect(messages).toHaveLength(3); expect(result.tools_cleared).toBe(0); expect(messages[0].content).toBe("hello"); }); it("clears old tool outputs beyond the preserve window", () => { const longContent = "x".repeat(400); const input = [ msg("user", "start"), toolMsg("Read", longContent), toolMsg("Bash", longContent), toolMsg("Grep", longContent), msg("assistant", "found it"), msg("user", "ok"), msg("assistant", "done"), msg("user", "next"), msg("assistant", "working"), msg("user", "final"), msg("assistant", "complete"), ]; // preserveRecent=4 means last 4 messages are safe (indices 7-10) // keepRecentTools=1 means only 1 most recent tool result is kept const { messages, result } = microcompact(input, { preserveRecent: 4, keepRecentTools: 1, }); expect(messages).toHaveLength(11); // First two tool messages should be cleared, third kept (most recent) expect(messages[1].content).toContain("[Cleared: Read"); expect(messages[2].content).toContain("[Cleared: Bash"); expect(messages[3].content).toBe(longContent); // Grep kept as most recent tool expect(result.tools_cleared).toBe(2); expect(result.tokens_freed).toBeGreaterThan(0); }); it("does not clear non-clearable tool names", () => { const input = [ msg("user", "start"), toolMsg("custom_tool", "result here"), msg("assistant", "got it"), msg("user", "a"), msg("assistant", "b"), msg("user", "c"), msg("assistant", "d"), msg("user", "e"), msg("assistant", "f"), ]; const { messages, result } = microcompact(input, { preserveRecent: 2 }); expect(result.tools_cleared).toBe(0); expect(messages[1].content).toBe("result here"); }); it("estimates token count as content.length / 4", () => { const content = "a".repeat(100); // 100 chars = 25 tokens const input = [ toolMsg("Read", content), msg("user", "a"), msg("assistant", "b"), msg("user", "c"), msg("assistant", "d"), msg("user", "e"), msg("assistant", "f"), ]; const { messages, result } = microcompact(input, { preserveRecent: 6, keepRecentTools: 0 }); expect(result.tokens_freed).toBe(25); expect(result.tools_cleared).toBe(1); expect(messages[0].content).toContain("~25 tokens"); }); it("keeps all clearable tools when keepRecentTools >= tool count", () => { const input = [ toolMsg("Read", "file content"), toolMsg("Bash", "command output"), msg("user", "a"), msg("assistant", "b"), msg("user", "c"), msg("assistant", "d"), msg("user", "e"), msg("assistant", "f"), msg("user", "g"), ]; const { result } = microcompact(input, { preserveRecent: 2, keepRecentTools: 5 }); expect(result.tools_cleared).toBe(0); }); it("preserves tool_name and other fields on cleared messages", () => { const input = [ { role: "tool" as const, content: "long output", tool_name: "Read", timestamp: "2024-01-01" }, msg("user", "a"), msg("assistant", "b"), msg("user", "c"), msg("assistant", "d"), msg("user", "e"), msg("assistant", "f"), ]; const { messages } = microcompact(input, { preserveRecent: 6, keepRecentTools: 0 }); expect(messages[0].tool_name).toBe("Read"); expect(messages[0].timestamp).toBe("2024-01-01"); expect(messages[0].content).toContain("[Cleared: Read"); }); it("handles all clearable tool names", () => { const clearableTools = [ "read_file", "bash", "grep", "glob", "web_search", "web_fetch", "edit_file", "write_file", "Read", "Write", "Edit", "Bash", "Grep", "Glob", "WebSearch", "WebFetch", ]; const filler: ConversationMessage[] = []; for (let i = 0; i < 20; i++) { filler.push(msg("user", `msg ${i}`)); } const input = [ ...clearableTools.map((t) => toolMsg(t, "output")), ...filler, ]; const { result } = microcompact(input, { preserveRecent: 10, keepRecentTools: 0 }); expect(result.tools_cleared).toBe(clearableTools.length); }); it("uses default values when opts not provided", () => { const input: ConversationMessage[] = []; for (let i = 0; i < 10; i++) { input.push(toolMsg("Read", "content")); input.push(msg("user", `q${i}`)); } // Default: preserveRecent=6, keepRecentTools=5 const { result } = microcompact(input); // Last 6 messages are preserved, so tools in first 14 are candidates // 7 tools are in the clearable zone, keepRecentTools=5 means 2 cleared expect(result.tools_cleared).toBe(2); }); }); // ============================================================================ // Layer 2: summarizeSession // ============================================================================ describe("summarizeSession", () => { it("returns empty result when LLM returns null", async () => { const mockLLM = createMockLLM(null); const result = await summarizeSession( [msg("user", "hello")], mockLLM as unknown as import("../llm-client.js").LLMClient, ); expect(result.text).toBe(""); expect(Object.keys(result.dimensions)).toHaveLength(0); }); it("parses structured summary from LLM response", async () => { const response = `Thinking about the session... ## 1. User intent and requests Wanted to build a feature ## 2. Key technical concepts TypeScript, MCP protocol ## 3. Files and code segments involved src/main.ts, src/utils.ts ## 4. Errors and fix records N/A ## 5. Problem solving process Iterative approach with testing ## 6. User original quotes preserved "Make it simple and clean" ## 7. Unfinished tasks Unit tests pending ## 8. Current work state Implementation 80% complete ## 9. Suggested next steps Write tests, review code `; const mockLLM = createMockLLM(response); const result = await summarizeSession( [msg("user", "build feature"), msg("assistant", "ok")], mockLLM as unknown as import("../llm-client.js").LLMClient, ); expect(result.text).toContain("User intent"); expect(result.dimensions.user_intent).toContain("build a feature"); expect(result.dimensions.technical_concepts).toContain("TypeScript"); expect(result.dimensions.files_and_code).toContain("src/main.ts"); expect(result.dimensions.errors_and_fixes).toBeUndefined(); // N/A is excluded expect(result.dimensions.problem_solving).toContain("Iterative"); expect(result.dimensions.user_quotes).toContain("simple and clean"); expect(result.dimensions.unfinished_tasks).toContain("Unit tests"); expect(result.dimensions.current_state).toContain("80%"); expect(result.dimensions.next_steps).toContain("Write tests"); }); it("handles response without XML tags gracefully", async () => { const response = `## 1. User intent and requests Debug a crash ## 2. Key technical concepts Memory management`; const mockLLM = createMockLLM(response); const result = await summarizeSession( [msg("user", "help")], mockLLM as unknown as import("../llm-client.js").LLMClient, ); expect(result.text).toContain("User intent"); }); it("truncates long messages to avoid token overflow", async () => { let capturedUser = ""; const mockLLM = { chatLong: async (_system: string, user: string) => { capturedUser = user; return "okshort"; }, }; const longMsg = msg("user", "x".repeat(10000)); await summarizeSession( [longMsg], mockLLM as unknown as import("../llm-client.js").LLMClient, ); // Each message content is capped at 500 chars, and total at 8000 expect(capturedUser.length).toBeLessThanOrEqual(8000); }); }); // ============================================================================ // Layer 3: extractAndPersist // ============================================================================ describe("extractAndPersist", () => { it("persists memories for relevant dimensions", async () => { const { stored, deps } = createMockDeps(); const summary: SummaryResult = { text: "full summary text", dimensions: { user_intent: "User wanted to implement a session distiller feature", files_and_code: "src/session-distiller.ts, src/mcp-server.ts", errors_and_fixes: "Fixed import error in llm-client.ts by adding chatLong method", problem_solving: "Iterative approach: read code first, then implement layer by layer", user_quotes: "User said: prefer clean code over clever code", }, }; const result = await extractAndPersist(summary, deps as Parameters[1], "project:test"); expect(result.memories_stored).toBe(5); expect(result.ids).toHaveLength(5); // Verify categories const categories = stored.map((s) => s.category); expect(categories).toContain("events"); expect(categories).toContain("entities"); expect(categories).toContain("cases"); expect(categories).toContain("patterns"); expect(categories).toContain("preferences"); // Verify session_distill tag is in metadata for (const entry of stored) { const metadata = typeof entry.metadata === "string" ? entry.metadata : JSON.stringify(entry.metadata); expect(metadata).toContain("session_distill"); } }); it("skips dimensions with too-short content", async () => { const { deps } = createMockDeps(); const summary: SummaryResult = { text: "summary", dimensions: { user_intent: "short", // < 10 chars errors_and_fixes: "Found and fixed a critical bug in the parser module", }, }; const result = await extractAndPersist(summary, deps as Parameters[1], "project:test"); expect(result.memories_stored).toBe(1); expect(result.ids).toHaveLength(1); }); it("skips dimensions not in DIMENSION_TO_MEMORY mapping", async () => { // Note: 2026-05-13 P1.2 added unfinished_tasks + next_steps to DIMENSION_TO_MEMORY. // Remaining unmapped dims from DEFAULT_DIMENSION_LABELS: technical_concepts, current_state. const { deps } = createMockDeps(); const summary: SummaryResult = { text: "summary", dimensions: { technical_concepts: "TypeScript and Bun runtime details", current_state: "Implementation complete, testing phase", }, }; const result = await extractAndPersist(summary, deps as Parameters[1], "project:test"); expect(result.memories_stored).toBe(0); }); it("persists unfinished_tasks (P1.2 regression 2026-05-13: dim added to DIMENSION_TO_MEMORY)", async () => { const { deps } = createMockDeps(); const summary: SummaryResult = { text: "summary", dimensions: { unfinished_tasks: "TODO: integrate Sentinel-Core monitoring for aws-podcast bot", }, }; const result = await extractAndPersist(summary, deps as Parameters[1], "project:p1.2-unfinished"); // Before P1.2 fix: this dim was not in DIMENSION_TO_MEMORY → silently dropped → memories_stored=0 // After fix: dim mapped to cases category → persisted → memories_stored=1 expect(result.memories_stored).toBe(1); expect(result.ids).toHaveLength(1); }); it("persists next_steps (P1.2 regression 2026-05-13: dim added to DIMENSION_TO_MEMORY)", async () => { const { deps } = createMockDeps(); const summary: SummaryResult = { text: "summary", dimensions: { next_steps: "Next: review CodexCLI image_in workflow + write antibot Sentinel-Core spec", }, }; const result = await extractAndPersist(summary, deps as Parameters[1], "project:p1.2-nextsteps"); expect(result.memories_stored).toBe(1); expect(result.ids).toHaveLength(1); }); it("returns empty result for empty dimensions", async () => { const { deps } = createMockDeps(); const summary: SummaryResult = { text: "", dimensions: {} }; const result = await extractAndPersist(summary, deps as Parameters[1], "project:test"); expect(result.memories_stored).toBe(0); expect(result.memories_deduped).toBe(0); expect(result.memories_conflicted).toBe(0); expect(result.ids).toHaveLength(0); }); it("tracks disposition correctly for multiple dimensions", async () => { const { deps } = createMockDeps(); const summary: SummaryResult = { text: "summary", dimensions: { user_intent: "User wanted to build a distiller for sessions", errors_and_fixes: "Fixed a critical error in the LLM client module", }, }; const result = await extractAndPersist(summary, deps as Parameters[1], "project:test"); // Both should store successfully (no dedup in mock since vectorSearch returns []) expect(result.memories_stored).toBe(2); expect(result.ids).toHaveLength(2); expect(result.memories_deduped).toBe(0); expect(result.memories_conflicted).toBe(0); }); }); // ============================================================================ // Full Pipeline: distillSession // ============================================================================ describe("distillSession", () => { it("runs all three layers end-to-end", async () => { const { deps } = createMockDeps(); const llmResponse = `Processing ## 1. User intent and requests Implement session distiller ## 2. Key technical concepts N/A ## 3. Files and code segments involved src/session-distiller.ts ## 4. Errors and fix records N/A ## 5. Problem solving process Step by step implementation ## 6. User original quotes preserved N/A ## 7. Unfinished tasks N/A ## 8. Current work state N/A ## 9. Suggested next steps N/A `; const mockLLM = createMockLLM(llmResponse); const input: ConversationMessage[] = [ msg("user", "implement distiller"), toolMsg("Read", "long file content ".repeat(100)), toolMsg("Bash", "test output ".repeat(100)), msg("assistant", "done reading"), msg("user", "proceed"), msg("assistant", "implementing"), msg("user", "looks good"), msg("assistant", "finished"), msg("user", "test it"), msg("assistant", "all pass"), ]; const result = await distillSession( input, { ...deps, llm: mockLLM } as Parameters[1], { scope: "project:test", preserveRecent: 4, keepRecentTools: 0 }, ); // Layer 1 expect(result.microcompact.tools_cleared).toBe(2); expect(result.microcompact.tokens_freed).toBeGreaterThan(0); // Layer 2 expect(result.summary.dimensions.user_intent).toContain("session distiller"); // Layer 3 expect(result.persisted.memories_stored).toBeGreaterThan(0); // Compacted messages expect(result.compacted_messages).toHaveLength(10); expect(result.compacted_messages[1].content).toContain("[Cleared:"); }); it("skips Layer 3 when persist=false", async () => { const { deps } = createMockDeps(); const mockLLM = createMockLLM( "ok## 1. User intent and requests\nBuild something cool", ); const input = [ msg("user", "hello"), msg("assistant", "hi"), msg("user", "build it"), msg("assistant", "ok"), msg("user", "done?"), msg("assistant", "yes"), msg("user", "great"), msg("assistant", "cheers"), ]; const result = await distillSession( input, { ...deps, llm: mockLLM } as Parameters[1], { persist: false, preserveRecent: 2 }, ); expect(result.persisted.memories_stored).toBe(0); expect(result.persisted.ids).toHaveLength(0); }); it("handles empty conversation gracefully", async () => { const { deps } = createMockDeps(); const mockLLM = createMockLLM(null); const result = await distillSession( [], { ...deps, llm: mockLLM } as Parameters[1], ); expect(result.microcompact.tools_cleared).toBe(0); expect(result.summary.text).toBe(""); expect(result.persisted.memories_stored).toBe(0); expect(result.compacted_messages).toHaveLength(0); }); it("uses default scope when not provided", async () => { const { deps, stored } = createMockDeps(); const mockLLM = createMockLLM( "ok## 1. User intent and requests\nBuild a feature for the application", ); const input = [ msg("user", "hello"), msg("assistant", "hi"), msg("user", "a"), msg("assistant", "b"), msg("user", "c"), msg("assistant", "d"), msg("user", "e"), msg("assistant", "f"), ]; await distillSession( input, { ...deps, llm: mockLLM } as Parameters[1], ); // Should use "default" scope if (stored.length > 0) { expect(stored[0].scope).toBe("default"); } }); it("only summarizes old messages (before preserve window)", async () => { let capturedUser = ""; const mockLLM = { chatLong: async (_system: string, user: string) => { capturedUser = user; return "ok## 1. User intent and requests\nSomething from the old messages only"; }, }; const { deps } = createMockDeps(); const input = [ msg("user", "old message 1"), msg("assistant", "old response 1"), msg("user", "recent 1"), msg("assistant", "recent 2"), msg("user", "recent 3"), msg("assistant", "recent 4"), ]; await distillSession( input, { ...deps, llm: mockLLM } as Parameters[1], { preserveRecent: 4 }, ); expect(capturedUser).toContain("old message 1"); expect(capturedUser).not.toContain("recent 1"); }); it("skips Layer 2/3 when all messages are in preserve window", async () => { const { deps } = createMockDeps(); const mockLLM = createMockLLM("should not be called"); const input = [ msg("user", "hello"), msg("assistant", "hi"), ]; const result = await distillSession( input, { ...deps, llm: mockLLM } as Parameters[1], { preserveRecent: 6 }, ); expect(result.summary.text).toBe(""); expect(result.persisted.memories_stored).toBe(0); }); });