import { describe, it, expect, beforeEach } from "vitest"; import { updateCache, getRecentTurns, getAllTurns, getSummary, updateSummary, markSummaryInProgress, markSummaryComplete, isSummaryInProgress, isSystemTrigger, getAgentSystemPrompt, setAgentSystemPrompt, hasSession, getTotalTurns, clearCache, cacheSize, extractConversationTurns, } from "./message-cache.js"; const NO_FILTER = new Set(); describe("message-cache", () => { beforeEach(() => { clearCache(); }); describe("extractConversationTurns", () => { it("pairs user messages with preceding assistant replies", () => { const history = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi! How can I help?" }, { role: "user", content: "Delete those files" }, ]; const turns = extractConversationTurns(history); expect(turns).toEqual([ { user: "Hello", assistant: undefined }, { user: "Delete those files", assistant: "Hi! How can I help?" }, ]); }); it("handles confirmation flow: assistant proposes, user confirms", () => { const history = [ { role: "user", content: "Clean up temp files" }, { role: "assistant", content: "I found 5 old temp files. Should I delete them?" }, { role: "user", content: "Yes" }, ]; const turns = extractConversationTurns(history); expect(turns).toEqual([ { user: "Clean up temp files", assistant: undefined }, { user: "Yes", assistant: "I found 5 old temp files. Should I delete them?", }, ]); }); it("merges multiple assistant messages before a user message", () => { const history = [ { role: "assistant", content: "Let me check..." }, { role: "assistant", content: "Found 5 old files. Should I delete them?" }, { role: "user", content: "Yes" }, ]; const turns = extractConversationTurns(history); expect(turns).toEqual([ { user: "Yes", assistant: "Let me check...\nFound 5 old files. Should I delete them?", }, ]); }); it("handles user messages without preceding assistant", () => { const history = [ { role: "system", content: "Be helpful" }, { role: "user", content: "Hello world" }, ]; const turns = extractConversationTurns(history); expect(turns).toEqual([{ user: "Hello world", assistant: undefined }]); }); it("skips slash commands in user messages", () => { const history = [ { role: "user", content: "/reset" }, { role: "assistant", content: "Session reset." }, { role: "user", content: "Hello" }, ]; const turns = extractConversationTurns(history); expect(turns).toEqual([{ user: "Hello", assistant: "Session reset." }]); }); it("preserves long assistant messages without truncation", () => { const longText = "x".repeat(2000); const history = [ { role: "assistant", content: longText }, { role: "user", content: "Ok" }, ]; const turns = extractConversationTurns(history); expect(turns[0].assistant).toBe(longText); }); it("appends trailing assistant messages to last turn", () => { const history = [ { role: "user", content: "Check files" }, { role: "assistant", content: "OK, executing" }, { role: "assistant", content: "Now starting service" }, ]; const turns = extractConversationTurns(history); expect(turns).toHaveLength(1); expect(turns[0].user).toBe("Check files"); expect(turns[0].assistant).toContain("OK, executing"); expect(turns[0].assistant).toContain("Now starting service"); }); it("ignores trailing assistant messages when there are no turns", () => { const history = [ { role: "assistant", content: "Hello" }, { role: "assistant", content: "I'm doing something" }, ]; const turns = extractConversationTurns(history); expect(turns).toHaveLength(0); }); it("handles multimodal assistant content", () => { const history = [ { role: "assistant", content: [ { type: "text", text: "Here is the result" }, { type: "tool_use", id: "tool-1", name: "exec" }, ], }, { role: "user", content: "Thanks" }, ]; const turns = extractConversationTurns(history); expect(turns).toEqual([{ user: "Thanks", assistant: "Here is the result" }]); }); it("strips channel metadata from user messages", () => { const history = [ { role: "user", content: 'Conversation info (untrusted metadata):\n```json\n{"message_id": "1778"}\n```\n\nCheck disk', }, ]; const turns = extractConversationTurns(history); expect(turns).toEqual([{ user: "Check disk", assistant: undefined }]); }); it("resets assistant pairing after each user message", () => { const history = [ { role: "assistant", content: "Reply A" }, { role: "user", content: "Msg 1" }, { role: "user", content: "Msg 2" }, ]; const turns = extractConversationTurns(history); expect(turns).toEqual([ { user: "Msg 1", assistant: "Reply A" }, { user: "Msg 2", assistant: undefined }, ]); }); }); describe("extractConversationTurns — toolResult handling", () => { it("includes toolResult messages as [tool: name] in assistant context", () => { const history = [ { role: "user", content: "Deploy my project" }, { role: "assistant", content: "Let me check your memory" }, { role: "toolResult", toolName: "memory_search", content: [{ type: "text", text: "User prefers make build for deployment" }], }, { role: "assistant", content: "I'll run make build" }, { role: "user", content: "Yes go ahead" }, ]; const turns = extractConversationTurns(history); expect(turns).toHaveLength(2); expect(turns[1].assistant).toContain("[tool: memory_search]"); expect(turns[1].assistant).toContain("User prefers make build"); expect(turns[1].assistant).toContain("I'll run make build"); }); it("handles toolResult with string content", () => { const history = [ { role: "user", content: "Read the file" }, { role: "toolResult", toolName: "read", content: "file contents here", }, { role: "user", content: "Thanks" }, ]; const turns = extractConversationTurns(history); expect(turns[1].assistant).toContain("[tool: read] file contents here"); }); it("handles toolResult with empty content", () => { const history = [ { role: "user", content: "Test" }, { role: "toolResult", toolName: "read", content: "" }, ]; const turns = extractConversationTurns(history); expect(turns).toHaveLength(1); // Empty tool result should not add anything expect(turns[0].assistant).toBeUndefined(); }); it("handles toolResult with missing toolName", () => { const history = [ { role: "user", content: "Test" }, { role: "toolResult", content: "some result" }, ]; const turns = extractConversationTurns(history); expect(turns[0].assistant).toContain("[tool: unknown_tool]"); }); it("attaches trailing toolResults to last turn", () => { const history = [ { role: "user", content: "Run something" }, { role: "assistant", content: "Executing" }, { role: "toolResult", toolName: "exec", content: "command output here", }, ]; const turns = extractConversationTurns(history); expect(turns).toHaveLength(1); expect(turns[0].assistant).toContain("Executing"); expect(turns[0].assistant).toContain("[tool: exec] command output here"); }); }); describe("extractConversationTurns — context_tools filtering", () => { it("filters out tool results not in context_tools allowlist", () => { const contextTools = new Set(["memory_search"]); const history = [ { role: "user", content: "Do things" }, { role: "toolResult", toolName: "write_file", content: "wrote file" }, { role: "toolResult", toolName: "memory_search", content: "memory result" }, { role: "user", content: "ok" }, ]; const turns = extractConversationTurns(history, contextTools); expect(turns[1].assistant).toContain("[tool: memory_search]"); expect(turns[1].assistant).not.toContain("write_file"); }); it("empty context_tools set includes all tool results", () => { const contextTools = new Set(); const history = [ { role: "user", content: "Test" }, { role: "toolResult", toolName: "write_file", content: "wrote file" }, ]; const turns = extractConversationTurns(history, contextTools); expect(turns[0].assistant).toContain("[tool: write_file]"); }); it("undefined context_tools includes all tool results", () => { const history = [ { role: "user", content: "Test" }, { role: "toolResult", toolName: "write_file", content: "wrote file" }, ]; const turns = extractConversationTurns(history, undefined); expect(turns[0].assistant).toContain("[tool: write_file]"); }); it("context_tools filtering is case-insensitive", () => { const contextTools = new Set(["memory_search"]); const history = [ { role: "user", content: "Test" }, { role: "toolResult", toolName: "Memory_Search", content: "result" }, ]; // toolName "Memory_Search" lowercased = "memory_search" which IS in the set const turns = extractConversationTurns(history, contextTools); expect(turns[0].assistant).toContain("[tool: Memory_Search]"); }); }); describe("updateCache + getRecentTurns (lazy extraction)", () => { it("extracts conversation turns from history lazily", () => { const history = [ { role: "system", content: "You are a helpful assistant." }, { role: "user", content: "Hello world" }, { role: "assistant", content: "Hi there!" }, { role: "user", content: "What is 2+2?" }, ]; updateCache("session-1", history, undefined, 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toEqual([ { user: "Hello world", assistant: undefined }, { user: "What is 2+2?", assistant: "Hi there!" }, ]); }); it("keeps only the last N turns", () => { const history = [ { role: "user", content: "Message 1" }, { role: "assistant", content: "Reply 1" }, { role: "user", content: "Message 2" }, { role: "assistant", content: "Reply 2" }, { role: "user", content: "Message 3" }, { role: "assistant", content: "Reply 3" }, { role: "user", content: "Message 4" }, { role: "assistant", content: "Reply 4" }, { role: "user", content: "Message 5" }, ]; updateCache("session-1", history, undefined, 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toHaveLength(3); expect(turns[0].user).toBe("Message 3"); expect(turns[2].user).toBe("Message 5"); }); it("appends currentPrompt as the latest turn", () => { const history = [ { role: "user", content: "Previous message" }, { role: "assistant", content: "Response" }, ]; updateCache("session-1", history, "Current user prompt", 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toEqual([ { user: "Previous message", assistant: "Response" }, { user: "Current user prompt" }, ]); }); it("skips slash commands in currentPrompt", () => { updateCache("session-1", [], "/reset", 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toEqual([]); }); it("skips empty currentPrompt", () => { updateCache("session-1", [{ role: "user", content: "Hello" }], "", 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toEqual([{ user: "Hello", assistant: undefined }]); }); it("sees tool results added to live array after updateCache", () => { const history: unknown[] = [ { role: "user", content: "Deploy my project" }, { role: "assistant", content: "Let me search memory" }, ]; updateCache("session-1", history, undefined, 5, NO_FILTER); // Simulate agent loop adding toolResult after llm_input history.push({ role: "toolResult", toolName: "memory_search", content: "User prefers make build", }); history.push({ role: "assistant", content: "Found deployment steps", }); const turns = getRecentTurns("session-1"); expect(turns).toHaveLength(1); expect(turns[0].assistant).toContain("[tool: memory_search]"); expect(turns[0].assistant).toContain("Found deployment steps"); }); it("handles non-message objects gracefully", () => { const history = [null, undefined, 42, "not an object", { role: "user", content: "Works" }]; updateCache("session-1", history as unknown[], undefined, 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toEqual([{ user: "Works", assistant: undefined }]); }); it("replaces old cache on update but preserves summary", () => { updateCache("session-1", [{ role: "user", content: "Old message" }], undefined, 3, NO_FILTER); updateSummary("session-1", "User was working on deployment"); updateCache("session-1", [{ role: "user", content: "New message" }], undefined, 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toEqual([{ user: "New message", assistant: undefined }]); expect(getSummary("session-1")).toBe("User was working on deployment"); }); }); describe("getAllTurns", () => { it("returns all turns without slicing", () => { const history = [ { role: "user", content: "Message 1" }, { role: "assistant", content: "Reply 1" }, { role: "user", content: "Message 2" }, { role: "assistant", content: "Reply 2" }, { role: "user", content: "Message 3" }, ]; updateCache("session-1", history, "Current prompt", 2, NO_FILTER); const allTurns = getAllTurns("session-1"); expect(allTurns).toHaveLength(4); // 3 from history + 1 current prompt const recentTurns = getRecentTurns("session-1"); expect(recentTurns).toHaveLength(2); // only last 2 }); }); describe("summary storage", () => { it("stores and retrieves summary", () => { updateCache("session-1", [{ role: "user", content: "Test" }], undefined, 3, NO_FILTER); expect(getSummary("session-1")).toBeUndefined(); updateSummary("session-1", "User is deploying a web app"); expect(getSummary("session-1")).toBe("User is deploying a web app"); }); it("returns undefined for unknown session", () => { expect(getSummary("nonexistent")).toBeUndefined(); }); it("tracks summary in-progress state", () => { updateCache("session-1", [{ role: "user", content: "Test" }], undefined, 3, NO_FILTER); expect(isSummaryInProgress("session-1")).toBe(false); markSummaryInProgress("session-1"); expect(isSummaryInProgress("session-1")).toBe(true); updateSummary("session-1", "Summary text"); expect(isSummaryInProgress("session-1")).toBe(false); }); it("markSummaryComplete resets in-progress without requiring a summary value", () => { updateCache("session-1", [{ role: "user", content: "Test" }], undefined, 3, NO_FILTER); markSummaryInProgress("session-1"); expect(isSummaryInProgress("session-1")).toBe(true); markSummaryComplete("session-1"); expect(isSummaryInProgress("session-1")).toBe(false); // Summary should remain undefined (not set by markSummaryComplete) expect(getSummary("session-1")).toBeUndefined(); }); it("preserves summary across cache updates", () => { updateCache("session-1", [{ role: "user", content: "Msg 1" }], undefined, 3, NO_FILTER); updateSummary("session-1", "Initial summary"); updateCache("session-1", [{ role: "user", content: "Msg 2" }], undefined, 3, NO_FILTER); expect(getSummary("session-1")).toBe("Initial summary"); }); }); describe("getTotalTurns", () => { it("counts total user messages including currentPrompt", () => { const history = [ { role: "user", content: "Msg 1" }, { role: "assistant", content: "Reply 1" }, { role: "user", content: "Msg 2" }, ]; const total = updateCache("session-1", history, "Current", 3, NO_FILTER); expect(total).toBe(3); expect(getTotalTurns("session-1")).toBe(3); }); it("returns 0 for unknown session", () => { expect(getTotalTurns("nonexistent")).toBe(0); }); }); describe("isSystemTrigger", () => { it("detects heartbeat prompts", () => { updateCache("s1", [], "heartbeat", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); }); it("detects heartbeat variants", () => { updateCache("s1", [], "HEARTBEAT_OK", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); updateCache("s2", [], "heartbeat_check", 3, NO_FILTER); expect(isSystemTrigger("s2")).toBe(true); }); it("detects cron triggers", () => { updateCache("s1", [], "/cron daily-report", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); updateCache("s2", [], "[cron] generate pdf", 3, NO_FILTER); expect(isSystemTrigger("s2")).toBe(true); }); it("detects ping/pong/health check", () => { updateCache("s1", [], "ping", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); updateCache("s2", [], "health_check", 3, NO_FILTER); expect(isSystemTrigger("s2")).toBe(true); }); it("returns false for normal user messages", () => { updateCache("s1", [], "Write a report", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(false); }); it("returns false for undefined/empty prompts", () => { updateCache("s1", [], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(false); updateCache("s2", [], "", 3, NO_FILTER); expect(isSystemTrigger("s2")).toBe(false); }); it("detects the real heartbeat prompt (contains HEARTBEAT_OK)", () => { const realPrompt = "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."; updateCache("s1", [], realPrompt, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); }); it("detects heartbeat prompts mentioning HEARTBEAT.md", () => { updateCache("s1", [], "Check HEARTBEAT.md for tasks", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); }); it("returns false for unknown sessions", () => { expect(isSystemTrigger("nonexistent")).toBe(false); }); it("stays true when heartbeat is in historyMessages on subsequent llm_input", () => { // Heartbeat fires with prompt → isSystemTrigger=true updateCache("s1", [], "heartbeat", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); // Agent loop continues — heartbeat is now in historyMessages updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); }); it("resets isSystemTrigger when a real user message arrives", () => { updateCache("s1", [], "heartbeat", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); // Real user message arrives — now the last user message in history is the real one updateCache( "s1", [ { role: "user", content: "heartbeat" }, { role: "assistant", content: "HEARTBEAT_OK" }, { role: "user", content: "Deploy my project" }, ], undefined, 3, NO_FILTER, ); expect(isSystemTrigger("s1")).toBe(false); }); it("does not inherit system trigger from a different session's history", () => { // Fresh session with no prompt → should be false (not inherited) updateCache("s1", [], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(false); }); it("detects heartbeat from last user message in historyMessages when currentPrompt is undefined", () => { // Heartbeat prompt arrives via historyMessages, not currentPrompt const heartbeatPrompt = "Read HEARTBEAT.md if it exists (workspace context). If nothing needs attention, reply HEARTBEAT_OK."; updateCache("s1", [{ role: "user", content: heartbeatPrompt }], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); }); it("detects heartbeat from historyMessages even on first llm_input (no existing entry)", () => { updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); }); it("resets when historyMessages last user message is not a system trigger", () => { updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); updateCache("s1", [{ role: "user", content: "Deploy my project" }], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(false); }); }); describe("getRecentTurns filters system turns", () => { it("filters out heartbeat turns from recent context", () => { const history = [ { role: "user", content: "Hello, help me with code" }, { role: "assistant", content: [{ type: "text", text: "Sure!" }] }, { role: "user", content: "HEARTBEAT_OK" }, { role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] }, { role: "user", content: "Now fix the bug" }, ]; updateCache("s1", history, undefined, 10, NO_FILTER); const turns = getRecentTurns("s1"); // The "HEARTBEAT_OK" user turn is filtered out. // "Sure!" was paired with the heartbeat turn so it's also dropped. // "HEARTBEAT_OK" assistant reply gets attached to "Now fix the bug". expect(turns).toEqual([ { user: "Hello, help me with code", assistant: undefined }, { user: "Now fix the bug", assistant: "HEARTBEAT_OK" }, ]); }); it("filters out real heartbeat prompt turns", () => { const heartbeatPrompt = "Read HEARTBEAT.md if it exists (workspace context). If nothing needs attention, reply HEARTBEAT_OK."; const history = [ { role: "user", content: "Deploy the app" }, { role: "assistant", content: [{ type: "text", text: "Deploying..." }] }, { role: "user", content: heartbeatPrompt }, ]; updateCache("s1", history, undefined, 10, NO_FILTER); const turns = getRecentTurns("s1"); // "Deploying..." was paired with the heartbeat turn, so it's dropped expect(turns).toEqual([{ user: "Deploy the app", assistant: undefined }]); }); it("filters ping/pong turns", () => { const history = [ { role: "user", content: "ok" }, { role: "user", content: "Do something" }, ]; updateCache("s1", history, undefined, 10, NO_FILTER); const turns = getRecentTurns("s1"); expect(turns).toEqual([{ user: "Do something", assistant: undefined }]); }); }); describe("cache isolation", () => { it("keeps sessions isolated", () => { updateCache("session-a", [{ role: "user", content: "Message A" }], undefined, 3, NO_FILTER); updateCache("session-b", [{ role: "user", content: "Message B" }], undefined, 3, NO_FILTER); expect(getRecentTurns("session-a")).toEqual([{ user: "Message A", assistant: undefined }]); expect(getRecentTurns("session-b")).toEqual([{ user: "Message B", assistant: undefined }]); }); it("returns empty array for unknown sessions", () => { expect(getRecentTurns("nonexistent")).toEqual([]); }); }); describe("cacheSize", () => { it("reports the correct size", () => { expect(cacheSize()).toBe(0); updateCache("s1", [{ role: "user", content: "hi" }], undefined, 3, NO_FILTER); expect(cacheSize()).toBe(1); updateCache("s2", [{ role: "user", content: "hi" }], undefined, 3, NO_FILTER); expect(cacheSize()).toBe(2); }); }); describe("clearCache", () => { it("empties the cache", () => { updateCache("s1", [{ role: "user", content: "hi" }], undefined, 3, NO_FILTER); clearCache(); expect(cacheSize()).toBe(0); expect(getRecentTurns("s1")).toEqual([]); }); }); describe("channel metadata stripping", () => { it("strips Telegram conversation metadata from history messages", () => { const history = [ { role: "user", content: 'Conversation info (untrusted metadata):\n```json\n{"message_id": "1778"}\n```\n\nCheck disk', }, ]; updateCache("session-1", history, undefined, 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toEqual([{ user: "Check disk", assistant: undefined }]); }); it("strips metadata from currentPrompt", () => { updateCache( "session-1", [], 'Conversation info (untrusted metadata):\n```json\n{"message_id": "1800"}\n```\n\nHello world', 3, NO_FILTER, ); const turns = getRecentTurns("session-1"); expect(turns).toEqual([{ user: "Hello world", assistant: undefined }]); }); it("handles messages with only metadata (no actual content)", () => { const history = [ { role: "user", content: 'Conversation info (untrusted metadata):\n```json\n{"message_id": "1"}\n```', }, ]; updateCache("session-1", history, undefined, 3, NO_FILTER); const turns = getRecentTurns("session-1"); expect(turns).toEqual([]); }); }); describe("agentSystemPrompt", () => { it("starts as undefined for new sessions", () => { updateCache("s1", [{ role: "user", content: "test" }], undefined, 3, NO_FILTER); expect(getAgentSystemPrompt("s1")).toBeUndefined(); }); it("is set via setAgentSystemPrompt", () => { updateCache("s1", [{ role: "user", content: "test" }], undefined, 3, NO_FILTER); setAgentSystemPrompt("s1", "You are a helpful assistant."); expect(getAgentSystemPrompt("s1")).toBe("You are a helpful assistant."); }); it("is not overwritten on subsequent setAgentSystemPrompt calls", () => { updateCache("s1", [{ role: "user", content: "test" }], undefined, 3, NO_FILTER); setAgentSystemPrompt("s1", "First system prompt"); setAgentSystemPrompt("s1", "Second system prompt"); expect(getAgentSystemPrompt("s1")).toBe("First system prompt"); }); it("persists across updateCache calls", () => { updateCache("s1", [{ role: "user", content: "msg1" }], undefined, 3, NO_FILTER); setAgentSystemPrompt("s1", "Cached prompt"); updateCache("s1", [{ role: "user", content: "msg2" }], undefined, 3, NO_FILTER); expect(getAgentSystemPrompt("s1")).toBe("Cached prompt"); }); it("returns undefined for unknown sessions", () => { expect(getAgentSystemPrompt("nonexistent")).toBeUndefined(); }); }); describe("hasSession", () => { it("returns true for existing sessions", () => { updateCache("s1", [{ role: "user", content: "test" }], undefined, 3, NO_FILTER); expect(hasSession("s1")).toBe(true); }); it("returns false for unknown sessions", () => { expect(hasSession("nonexistent")).toBe(false); }); it("returns false after clearCache", () => { updateCache("s1", [{ role: "user", content: "test" }], undefined, 3, NO_FILTER); clearCache(); expect(hasSession("s1")).toBe(false); }); }); });