import { afterEach, describe, expect, it } from "bun:test"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MemoryStore } from "../src/agent/memory.ts"; const TEST_BASE = join(tmpdir(), "mozart-mem-test"); let stores: MemoryStore[] = []; function createStore(): MemoryStore { const id = `test-${crypto.randomUUID().slice(0, 8)}`; const dbPath = join(TEST_BASE, id, "memory.db"); const store = new MemoryStore(dbPath); stores.push(store); return store; } const AGENT = "test-agent"; afterEach(() => { for (const store of stores) { try { store.delete(); } catch {} } stores = []; }); describe("messages", () => { it("saves and retrieves messages in order", () => { const store = createStore(); const convId = "conv-1"; store.saveMessage(convId, "user", AGENT, "Hello agent"); store.saveMessage(convId, AGENT, "user", "Hello user!"); const messages = store.getConversation(convId); expect(messages).toHaveLength(2); expect(messages[0]!.fromId).toBe("user"); expect(messages[0]!.toId).toBe(AGENT); expect(messages[0]!.content).toBe("Hello agent"); expect(messages[1]!.fromId).toBe(AGENT); expect(messages[1]!.content).toBe("Hello user!"); }); it("isolates conversations by ID", () => { const store = createStore(); store.saveMessage("conv-a", "user", AGENT, "Message A"); store.saveMessage("conv-b", "user", AGENT, "Message B"); expect(store.getConversation("conv-a")).toHaveLength(1); expect(store.getConversation("conv-b")).toHaveLength(1); expect(store.getConversation("conv-a")[0]!.content).toBe("Message A"); }); it("counts messages correctly", () => { const store = createStore(); expect(store.getMessageCount()).toBe(0); store.saveMessage("c1", "user", AGENT, "Hi"); store.saveMessage("c1", AGENT, "user", "Hey"); expect(store.getMessageCount()).toBe(2); }); it("retrieves recent conversations with limit", () => { const store = createStore(); store.saveMessage("c1", "user", AGENT, "First"); store.saveMessage("c2", "user", AGENT, "Second"); store.saveMessage("c3", "user", AGENT, "Third"); const recent = store.getRecentConversations(2); expect(recent).toHaveLength(2); const all = store.getRecentConversations(10); expect(all).toHaveLength(3); }); }); describe("context messages", () => { it("saves and retrieves tool call context", () => { const store = createStore(); const convId = "conv-ctx"; store.saveMessage(convId, "user", AGENT, "Fetch my homepage", { role: "user" }); const toolCalls = JSON.stringify([ { id: "tc-1", type: "function", function: { name: "web_fetch", arguments: '{"url":"https://example.com"}' } }, ]); store.saveMessage(convId, AGENT, "tool", "", { role: "assistant", toolCallsJson: toolCalls, }); store.saveMessage(convId, "web_fetch", AGENT, "Hello", { role: "tool", toolCallId: "tc-1", name: "web_fetch", }); store.saveMessage(convId, AGENT, "user", "Here is your homepage content.", { role: "assistant" }); const ctx = store.getConversationContext(convId); expect(ctx).toHaveLength(4); expect(ctx[0]!.role).toBe("user"); expect(ctx[0]!.content).toBe("Fetch my homepage"); expect(ctx[1]!.role).toBe("assistant"); expect(ctx[1]!.toolCallsJson).toBeTruthy(); const parsed = JSON.parse(ctx[1]!.toolCallsJson!); expect(parsed[0].function.name).toBe("web_fetch"); expect(ctx[2]!.role).toBe("tool"); expect(ctx[2]!.toolCallId).toBe("tc-1"); expect(ctx[2]!.name).toBe("web_fetch"); expect(ctx[2]!.content).toBe("Hello"); expect(ctx[3]!.role).toBe("assistant"); expect(ctx[3]!.content).toBe("Here is your homepage content."); }); it("getConversationContext respects limit", () => { const store = createStore(); const convId = "conv-limit"; for (let i = 0; i < 20; i++) { store.saveMessage(convId, "user", AGENT, `msg-${i}`, { role: "user" }); } const limited = store.getConversationContext(convId, 5); expect(limited).toHaveLength(5); expect(limited[0]!.content).toBe("msg-15"); expect(limited[4]!.content).toBe("msg-19"); }); it("getLastUserConversationId returns most recent user conversation", () => { const store = createStore(); store.saveMessage("conv-old", "user", AGENT, "old message", { role: "user" }); store.saveMessage("conv-sched", "scheduler", AGENT, "cron task", { role: "user" }); store.saveMessage("conv-new", "user", AGENT, "new message", { role: "user" }); expect(store.getLastUserConversationId()).toBe("conv-new"); }); it("getLastUserConversationId returns null for empty store", () => { const store = createStore(); expect(store.getLastUserConversationId()).toBeNull(); }); it("getConversationContext expands window when first message is orphaned tool result", () => { const store = createStore(); const convId = "conv-boundary"; // Build a conversation: user, assistant+tool_calls, tool, assistant, user, assistant+tool_calls, tool, assistant store.saveMessage(convId, "user", AGENT, "msg-0", { role: "user" }); const tc1 = JSON.stringify([{ id: "tc-1", type: "function", function: { name: "search", arguments: "{}" } }]); store.saveMessage(convId, AGENT, "tool", "", { role: "assistant", toolCallsJson: tc1 }); store.saveMessage(convId, "search", AGENT, "result-1", { role: "tool", toolCallId: "tc-1", name: "search" }); store.saveMessage(convId, AGENT, "user", "Here is what I found.", { role: "assistant" }); store.saveMessage(convId, "user", AGENT, "msg-4", { role: "user" }); const tc2 = JSON.stringify([{ id: "tc-2", type: "function", function: { name: "fetch", arguments: "{}" } }]); store.saveMessage(convId, AGENT, "tool", "", { role: "assistant", toolCallsJson: tc2 }); store.saveMessage(convId, "fetch", AGENT, "result-2", { role: "tool", toolCallId: "tc-2", name: "fetch" }); store.saveMessage(convId, AGENT, "user", "Done.", { role: "assistant" }); // Request limit=4 — last 4 messages are: assistant+tc2, tool, assistant, (nothing more) // Wait, let's count: 8 messages total. limit=4 takes last 4: // msg-4 (user), assistant+tc2, tool(tc-2), assistant("Done.") // That's fine — no orphan. Let's use limit=3 to force the orphan: // assistant+tc2, tool(tc-2), assistant("Done.") — no orphan (starts with assistant) // Let's use limit=2: // tool(tc-2), assistant("Done.") — starts with tool! Should expand backward. const ctx = store.getConversationContext(convId, 2); // Should have expanded to include the assistant+tool_calls message before the tool expect(ctx.length).toBeGreaterThanOrEqual(3); expect(ctx[0]!.role).toBe("assistant"); expect(ctx[0]!.toolCallsJson).toBeTruthy(); expect(ctx[1]!.role).toBe("tool"); expect(ctx[2]!.role).toBe("assistant"); }); it("backward compat: old rows without role default to 'user'", () => { const store = createStore(); const convId = "conv-compat"; store.saveMessage(convId, "user", AGENT, "Hello from old code"); store.saveMessage(convId, AGENT, "user", "Response from old code"); const ctx = store.getConversationContext(convId); expect(ctx).toHaveLength(2); expect(ctx[0]!.role).toBe("user"); expect(ctx[1]!.role).toBe("assistant"); }); }); describe("memories (FTS)", () => { it("saves and searches via full-text search", () => { const store = createStore(); store.saveMemory("The user's favorite color is blue"); store.saveMemory("The user works at Acme Corp"); store.saveMemory("The user prefers dark mode"); const colorResults = store.searchMemory("color"); expect(colorResults.length).toBeGreaterThanOrEqual(1); expect(colorResults[0]!.content).toContain("blue"); const acmeResults = store.searchMemory("Acme"); expect(acmeResults.length).toBeGreaterThanOrEqual(1); }); it("returns empty array for no matches", () => { const store = createStore(); store.saveMemory("Something about cats"); const results = store.searchMemory("quantum"); expect(results).toHaveLength(0); }); }); describe("schedules", () => { it("creates and retrieves a schedule", () => { const store = createStore(); const sched = store.saveSchedule("s1", "*/5 * * * *", "every 5 min", "check health"); expect(sched.id).toBe("s1"); expect(sched.cron).toBe("*/5 * * * *"); expect(sched.description).toBe("every 5 min"); expect(sched.task).toBe("check health"); expect(sched.conversationId).toBeTruthy(); const fetched = store.getSchedule("s1"); expect(fetched).not.toBeNull(); expect(fetched!.cron).toBe("*/5 * * * *"); }); it("lists all schedules in creation order", () => { const store = createStore(); store.saveSchedule("s1", "*/5 * * * *", "every 5 min", "task A"); store.saveSchedule("s2", "0 9 * * *", "daily 9am", "task B"); const all = store.getSchedules(); expect(all).toHaveLength(2); expect(all[0]!.id).toBe("s1"); expect(all[1]!.id).toBe("s2"); }); it("preserves conversationId when updating an existing schedule", () => { const store = createStore(); const original = store.saveSchedule("s1", "*/5 * * * *", "every 5 min", "task A"); const originalConvId = original.conversationId; const updated = store.saveSchedule("s1", "*/10 * * * *", "every 10 min", "task A updated"); expect(updated.conversationId).toBe(originalConvId); expect(updated.cron).toBe("*/10 * * * *"); const fetched = store.getSchedule("s1"); expect(fetched!.conversationId).toBe(originalConvId); expect(fetched!.cron).toBe("*/10 * * * *"); }); it("generates a new conversationId for a brand-new schedule", () => { const store = createStore(); const s1 = store.saveSchedule("s1", "*/5 * * * *", "every 5 min", "task A"); const s2 = store.saveSchedule("s2", "0 9 * * *", "daily 9am", "task B"); expect(s1.conversationId).not.toBe(s2.conversationId); }); it("deletes a schedule and returns true", () => { const store = createStore(); store.saveSchedule("s1", "*/5 * * * *", "every 5 min", "task A"); expect(store.deleteSchedule("s1")).toBe(true); expect(store.getSchedule("s1")).toBeNull(); expect(store.getSchedules()).toHaveLength(0); }); it("returns false when deleting a non-existent schedule", () => { const store = createStore(); expect(store.deleteSchedule("ghost")).toBe(false); }); it("updates lastRun timestamp", () => { const store = createStore(); store.saveSchedule("s1", "*/5 * * * *", "every 5 min", "task A"); expect(store.getSchedule("s1")!.lastRun).toBeNull(); store.updateScheduleLastRun("s1"); const after = store.getSchedule("s1"); expect(after!.lastRun).not.toBeNull(); }); }); describe("delete", () => { it("removes the database file", () => { const store = createStore(); store.saveMessage("c1", "user", AGENT, "Hi"); store.delete(); stores = stores.filter((s) => s !== store); const fresh = createStore(); expect(fresh.getMessageCount()).toBe(0); }); });