import { beforeEach, describe, expect, test } from "bun:test"; import { eq } from "drizzle-orm"; import { createConversation } from "../../../../persistence/conversation-crud.js"; import { buildExcerpt, buildRecallEvidenceExcerpt, countConversations, getMessageRoleStatsByConversation, listConversations, listConversationsBySource, listPinnedConversations, searchConversations, } from "../../../../persistence/conversation-queries.js"; import { getDb } from "../../../../persistence/db-connection.js"; import { initializeDb } from "../../../../persistence/db-init.js"; import { appendContentDeltas, resolveContentRefPath, } from "../../../../persistence/message-content-file.js"; import { rawRun } from "../../../../persistence/raw-query.js"; import { conversations } from "../../../../persistence/schema/index.js"; await initializeDb(); function resetTables(): void { const db = getDb(); db.run(`DELETE FROM messages`); db.run(`DELETE FROM conversations`); } function setConversationType(conversationId: string, type: string): void { const db = getDb(); db.update(conversations) .set({ conversationType: type }) .where(eq(conversations.id, conversationId)) .run(); } describe("buildExcerpt", () => { test("does not expose external_content tags for legacy plain-string rows", () => { const excerpt = buildExcerpt( '\nSearchable Slack text\n', "external_content", ); expect(excerpt).toBe("Searchable Slack text"); expect(excerpt).not.toContain(""); }); test("does not expose external_content tags for legacy text block rows", () => { const excerpt = buildExcerpt( JSON.stringify([ { type: "text", text: '\nSearchable block text\n', }, ]), "external_content", ); expect(excerpt).toBe("Searchable block text"); expect(excerpt).not.toContain(""); }); test("extracts thinking text instead of returning raw JSON", () => { const excerpt = buildExcerpt( JSON.stringify([ { type: "thinking", thinking: "A casual greeting, likely a reply to my earlier message", signature: "sig", }, ]), "greeting", ); expect(excerpt).toContain("casual greeting"); expect(excerpt).not.toContain('[{"type"'); }); test("falls back to legible text when the query only matches JSON structure", () => { const excerpt = buildExcerpt( JSON.stringify([ { type: "thinking", thinking: "Plain reasoning text", signature: "s" }, ]), "thinking", ); expect(excerpt).toBe("Plain reasoning text"); }); test("extracts string-form tool_result content", () => { const excerpt = buildExcerpt( JSON.stringify([ { type: "tool_result", tool_use_id: "toolu_01", content: "Command output line", }, ]), "output", ); expect(excerpt).toBe("Command output line"); }); test("does not expose external_content tags for string-form tool_result content", () => { const excerpt = buildExcerpt( JSON.stringify([ { type: "tool_result", tool_use_id: "toolu_01", content: '\nSearchable Slack text\n', }, ]), "Slack", ); expect(excerpt).toContain("Searchable Slack text"); expect(excerpt).not.toContain(" { const excerpt = buildExcerpt( JSON.stringify([ { type: "file", source: { type: "url", url: "https://example.com/report.pdf" }, extracted_text: "Quarterly revenue grew nine percent", }, ]), "revenue", ); expect(excerpt).toBe("Quarterly revenue grew nine percent"); }); test("still extracts array-form tool_result inner text", () => { const excerpt = buildExcerpt( JSON.stringify([ { type: "tool_result", tool_use_id: "toolu_01", content: [{ type: "text", text: "Inner tool text" }], }, ]), "Inner", ); expect(excerpt).toBe("Inner tool text"); }); test("centers the excerpt on a token match when the query is not contiguous", () => { const filler = "start padding ".repeat(25); const excerpt = buildExcerpt( JSON.stringify([ { type: "text", text: `${filler}alpha and shortly after beta appear` }, ]), "alpha beta", ); expect(excerpt).toContain("alpha"); expect(excerpt).toContain("beta"); expect(excerpt.startsWith("…")).toBe(true); }); test("keeps the match near the front of the display excerpt", () => { const prefix = "p".repeat(200); const excerpt = buildExcerpt( JSON.stringify([{ type: "text", text: `${prefix} alpha trailing text` }]), "alpha", ); const alphaIndex = excerpt.indexOf("alpha"); expect(alphaIndex).toBeGreaterThan(0); expect(alphaIndex).toBeLessThanOrEqual(32); }); test("whole-query matches also respect tokenizer boundaries", () => { const filler = "party planning notes ".repeat(10); const excerpt = buildExcerpt( JSON.stringify([{ type: "text", text: `${filler}art supplies list` }]), "art", ); expect(excerpt).toContain("art supplies"); }); test("ignores token substrings inside larger words when centering", () => { const filler = "party planning notes ".repeat(10); const excerpt = buildExcerpt( JSON.stringify([{ type: "text", text: `${filler}alpha appears here` }]), "alpha art", ); expect(excerpt).toContain("alpha"); }); test("keeps the excerpt window aligned when lowercasing changes string length", () => { const prefix = "İ".repeat(120); const excerpt = buildExcerpt( JSON.stringify([ { type: "text", text: `${prefix} alpha and beta follow` }, ]), "alpha beta", ); expect(excerpt).toContain("alpha"); expect(excerpt).toContain("beta"); }); test("returns an empty excerpt for messages with no legible text", () => { const excerpt = buildExcerpt( JSON.stringify([ { type: "tool_use", id: "toolu_01", name: "bash", input: {} }, ]), "bash", ); expect(excerpt).toBe(""); }); test("returns an empty excerpt for non-block JSON objects", () => { expect(buildExcerpt('{"foo":"bar"}', "foo")).toBe(""); }); test("returns an empty excerpt for unresolved content refs", () => { const excerpt = buildExcerpt( JSON.stringify({ ref: "conversations/missing-conv/msg-1.jsonl" }), "anything", ); expect(excerpt).toBe(""); }); test("folds and extracts text for resolvable content refs", () => { const ref = "conversations/excerpt-test/msg-1.jsonl"; const absPath = resolveContentRefPath(ref); expect(absPath).not.toBeNull(); appendContentDeltas(absPath!, [ { i: 0, seq: 1, block: { type: "text", text: "Folded delta text about pandas" }, }, ]); const excerpt = buildExcerpt(JSON.stringify({ ref }), "pandas"); expect(excerpt).toBe("Folded delta text about pandas"); }); }); describe("buildRecallEvidenceExcerpt", () => { test("preserves external_content boundaries for legacy plain-string rows", () => { const excerpt = buildRecallEvidenceExcerpt( '\nSearchable Slack text\n', "Slack", ); expect(excerpt).toBe( '\nSearchable Slack text\n', ); }); test("preserves external_content boundaries for legacy text block rows", () => { const excerpt = buildRecallEvidenceExcerpt( JSON.stringify([ { type: "text", text: '\nSearchable block text\n', }, ]), "block", ); expect(excerpt).toBe( '\nSearchable block text\n', ); }); test("preserves external_content boundaries when joined with other text blocks", () => { const excerpt = buildRecallEvidenceExcerpt( JSON.stringify([ { type: "text", text: '\nSearchable block text\n', }, { type: "text", text: "Local follow-up text.", }, ]), "block", ); expect(excerpt).toBe( '\nSearchable block text\n Local follow-up text.', ); }); test("does not unwrap malformed or mixed external_content text", () => { const malformed = 'Malformed Slack text'; const mixed = 'prefix \nMixed Slack text\n'; expect(buildRecallEvidenceExcerpt(malformed, "Slack")).toBe(malformed); expect(buildRecallEvidenceExcerpt(mixed, "Slack")).toBe( 'prefix Mixed Slack text ', ); }); test("extracts thinking text for recall evidence", () => { const excerpt = buildRecallEvidenceExcerpt( JSON.stringify([ { type: "thinking", thinking: "Recall reasoning detail", signature: "s", }, ]), "reasoning", ); expect(excerpt).toBe("Recall reasoning detail"); }); test("preserves external_content boundaries for string-form tool_result content", () => { const excerpt = buildRecallEvidenceExcerpt( JSON.stringify([ { type: "tool_result", tool_use_id: "toolu_01", content: '\nSearchable Slack text\n', }, ]), "Slack", ); expect(excerpt).toBe( '\nSearchable Slack text\n', ); }); test("keeps the wide leading window for recall evidence", () => { const prefix = "p".repeat(200); const excerpt = buildRecallEvidenceExcerpt( JSON.stringify([{ type: "text", text: `${prefix} alpha trailing text` }]), "alpha", ); expect(excerpt.indexOf("alpha")).toBeGreaterThan(50); }); test("keeps the raw fallback for messages with no legible text", () => { const raw = JSON.stringify([ { type: "tool_use", id: "toolu_01", name: "bash", input: {} }, ]); const excerpt = buildRecallEvidenceExcerpt(raw, "bash"); expect(excerpt).toContain("tool_use"); }); }); describe("countConversations", () => { beforeEach(() => { resetTables(); }); test("excludes 'private', 'background', and 'scheduled' rows from the foreground count", () => { createConversation("foreground-1"); createConversation("foreground-2"); const priv = createConversation("private-1"); setConversationType(priv.id, "private"); createConversation({ title: "bg-1", conversationType: "background" }); createConversation({ title: "sched-1", conversationType: "scheduled" }); expect(countConversations()).toBe(2); }); test("background-only count excludes private rows", () => { createConversation("foreground-1"); createConversation({ title: "bg-1", conversationType: "background" }); createConversation({ title: "sched-1", conversationType: "scheduled" }); const priv = createConversation("private-1"); setConversationType(priv.id, "private"); expect(countConversations({ conversationType: "background" })).toBe(2); }); test("includes standard conversations with group_id system:background in background count", () => { // GIVEN a standard conversation routed to system:background (e.g. heartbeat) createConversation({ title: "heartbeat-1", source: "heartbeat", groupId: "system:background", }); // AND a regular foreground conversation createConversation("foreground-1"); // WHEN counting background conversations const bgCount = countConversations({ conversationType: "background" }); // THEN the heartbeat conversation is included expect(bgCount).toBe(1); // AND excluded from the foreground count expect(countConversations({ conversationType: "standard" })).toBe(1); }); test("excludes standard conversations with group_id system:background from foreground count", () => { // GIVEN two foreground conversations and one heartbeat createConversation("foreground-1"); createConversation("foreground-2"); createConversation({ title: "heartbeat-1", source: "heartbeat", groupId: "system:background", }); // WHEN counting foreground conversations // THEN the heartbeat is excluded expect(countConversations({ conversationType: "standard" })).toBe(2); }); test('"scheduled" count returns only scheduled rows', () => { // GIVEN one scheduled, one background, and one foreground conversation createConversation({ title: "sched-1", conversationType: "scheduled" }); createConversation({ title: "bg-1", conversationType: "background" }); createConversation("foreground-1"); // WHEN counting scheduled conversations // THEN only the scheduled row is counted (background is excluded) expect(countConversations({ conversationType: "scheduled" })).toBe(1); }); describe("archiveStatus", () => { test("defaults to active — archived rows are excluded from the count", () => { // GIVEN one live and one archived foreground conversation createConversation("live-1"); const archived = createConversation("archived-1"); rawRun( "test:archiveConversation", "UPDATE conversations SET archived_at = ? WHERE id = ?", Date.now(), archived.id, ); expect(countConversations()).toBe(1); }); test('archiveStatus "archived" returns the archived count only', () => { createConversation("live-1"); const a1 = createConversation("archived-1"); const a2 = createConversation("archived-2"); rawRun( "test:archiveConversations", "UPDATE conversations SET archived_at = ? WHERE id IN (?, ?)", Date.now(), a1.id, a2.id, ); expect( countConversations({ conversationType: "standard", archiveStatus: "archived", }), ).toBe(2); }); test('archiveStatus "all" returns both', () => { createConversation("live-1"); const archived = createConversation("archived-1"); rawRun( "test:archiveConversation", "UPDATE conversations SET archived_at = ? WHERE id = ?", Date.now(), archived.id, ); expect( countConversations({ conversationType: "standard", archiveStatus: "all", }), ).toBe(2); }); }); }); describe("listConversations", () => { beforeEach(() => { resetTables(); }); test("background fetch includes conversations with group_id system:background regardless of conversationType", () => { // GIVEN a heartbeat conversation (conversationType standard, group_id system:background) createConversation({ title: "heartbeat-1", source: "heartbeat", groupId: "system:background", }); // AND a real background conversation createConversation({ title: "bg-1", conversationType: "background" }); // AND a foreground conversation createConversation("foreground-1"); // WHEN listing background conversations const bgList = listConversations({ limit: 100, conversationType: "background", }); // THEN both background and heartbeat conversations are returned expect(bgList).toHaveLength(2); const titles = bgList.map((c) => c.title); expect(titles).toContain("heartbeat-1"); expect(titles).toContain("bg-1"); }); test("foreground fetch excludes conversations with group_id system:background", () => { // GIVEN a heartbeat conversation (conversationType standard, group_id system:background) createConversation({ title: "heartbeat-1", source: "heartbeat", groupId: "system:background", }); // AND a foreground conversation createConversation("foreground-1"); // WHEN listing foreground conversations const fgList = listConversations({ limit: 100, conversationType: "standard", }); // THEN only the foreground conversation is returned expect(fgList).toHaveLength(1); expect(fgList[0]!.title).toBe("foreground-1"); }); test("conversations with group_id system:scheduled are included in background fetch", () => { // GIVEN a conversation with group_id system:scheduled but conversationType standard const conv = createConversation("schedule-routed"); rawRun( "test:setGroupScheduled", "UPDATE conversations SET group_id = 'system:scheduled' WHERE id = ?", conv.id, ); // WHEN listing background conversations const bgList = listConversations({ limit: 100, conversationType: "background", }); // THEN it appears in the background list expect(bgList).toHaveLength(1); expect(bgList[0]!.title).toBe("schedule-routed"); // AND not in the foreground list const fgList = listConversations({ limit: 100, conversationType: "standard", }); expect(fgList).toHaveLength(0); }); test('"scheduled" fetch returns only scheduled rows and excludes plain background', () => { // GIVEN a scheduled conversation, a background conversation, and a foreground one createConversation({ title: "sched-1", conversationType: "scheduled" }); createConversation({ title: "bg-1", conversationType: "background" }); createConversation("foreground-1"); // WHEN listing scheduled conversations const scheduledList = listConversations({ limit: 100, conversationType: "scheduled", }); // THEN only the scheduled conversation is returned expect(scheduledList).toHaveLength(1); expect(scheduledList[0]!.title).toBe("sched-1"); }); test('"scheduled" fetch includes standard rows routed to group_id system:scheduled but not system:background', () => { // GIVEN a standard conversation routed to system:scheduled const scheduledRouted = createConversation("schedule-routed"); rawRun( "test:setGroupScheduled", "UPDATE conversations SET group_id = 'system:scheduled' WHERE id = ?", scheduledRouted.id, ); // AND a standard conversation routed to system:background const backgroundRouted = createConversation("background-routed"); rawRun( "test:setGroupBackground", "UPDATE conversations SET group_id = 'system:background' WHERE id = ?", backgroundRouted.id, ); // WHEN listing scheduled conversations const scheduledList = listConversations({ limit: 100, conversationType: "scheduled", }); // THEN only the system:scheduled row appears expect(scheduledList).toHaveLength(1); expect(scheduledList[0]!.title).toBe("schedule-routed"); }); test('"scheduled" fetch excludes subagent runs', () => { // GIVEN a scheduled conversation produced by a subagent createConversation({ title: "subagent-sched", conversationType: "scheduled", source: "subagent", }); // WHEN listing scheduled conversations const scheduledList = listConversations({ limit: 100, conversationType: "scheduled", }); // THEN the subagent run is excluded expect(scheduledList).toHaveLength(0); }); describe("archiveStatus", () => { test("defaults to active — archived rows are excluded", () => { // GIVEN a live conversation and an archived one createConversation("live-1"); const archived = createConversation("archived-1"); rawRun( "test:archiveConversation", "UPDATE conversations SET archived_at = ? WHERE id = ?", Date.now(), archived.id, ); // WHEN listing without an explicit archiveStatus const rows = listConversations({ limit: 100, conversationType: "standard", }); // THEN only the live conversation appears expect(rows).toHaveLength(1); expect(rows[0]!.title).toBe("live-1"); }); test('archiveStatus "archived" returns only archived rows', () => { // GIVEN a live conversation and an archived one createConversation("live-1"); const archived = createConversation("archived-1"); rawRun( "test:archiveConversation", "UPDATE conversations SET archived_at = ? WHERE id = ?", Date.now(), archived.id, ); // WHEN listing with archiveStatus "archived" const rows = listConversations({ limit: 100, conversationType: "standard", archiveStatus: "archived", }); // THEN only the archived conversation appears expect(rows).toHaveLength(1); expect(rows[0]!.title).toBe("archived-1"); }); test('archiveStatus "all" returns both active and archived rows', () => { // GIVEN a live conversation and an archived one createConversation("live-1"); const archived = createConversation("archived-1"); rawRun( "test:archiveConversation", "UPDATE conversations SET archived_at = ? WHERE id = ?", Date.now(), archived.id, ); // WHEN listing with archiveStatus "all" const rows = listConversations({ limit: 100, conversationType: "standard", archiveStatus: "all", }); // THEN both conversations appear expect(rows).toHaveLength(2); const titles = rows.map((c) => c.title); expect(titles).toContain("live-1"); expect(titles).toContain("archived-1"); }); test('archiveStatus "archived" composes with background-only', () => { // GIVEN an archived background conversation and an archived foreground one const archivedBg = createConversation({ title: "archived-bg", conversationType: "background", }); const archivedFg = createConversation("archived-fg"); rawRun( "test:archiveConversations", "UPDATE conversations SET archived_at = ? WHERE id IN (?, ?)", Date.now(), archivedBg.id, archivedFg.id, ); // WHEN listing archived background conversations const rows = listConversations({ limit: 100, conversationType: "background", archiveStatus: "archived", }); // THEN only the archived background row appears expect(rows).toHaveLength(1); expect(rows[0]!.title).toBe("archived-bg"); }); }); }); describe("listConversationsBySource", () => { beforeEach(() => { resetTables(); }); test("returns only conversations whose source matches exactly", () => { createConversation({ title: "consol-1", source: "memory_v2_consolidation", }); createConversation({ title: "consol-2", source: "memory_v2_consolidation", }); createConversation({ title: "heartbeat-1", source: "heartbeat" }); createConversation({ title: "user-1", source: "user" }); const results = listConversationsBySource("memory_v2_consolidation"); expect(results).toHaveLength(2); expect(results.map((r) => r.title).sort()).toEqual([ "consol-1", "consol-2", ]); }); test("orders by createdAt descending", () => { const a = createConversation({ title: "a", source: "memory_v2_consolidation", }); const b = createConversation({ title: "b", source: "memory_v2_consolidation", }); const c = createConversation({ title: "c", source: "memory_v2_consolidation", }); // Force distinct createdAt regardless of ms-clock granularity. rawRun( "test:setCreatedAt", "UPDATE conversations SET created_at = ? WHERE id = ?", 1000, a.id, ); rawRun( "test:setCreatedAt", "UPDATE conversations SET created_at = ? WHERE id = ?", 3000, b.id, ); rawRun( "test:setCreatedAt", "UPDATE conversations SET created_at = ? WHERE id = ?", 2000, c.id, ); const results = listConversationsBySource("memory_v2_consolidation"); expect(results.map((r) => r.id)).toEqual([b.id, c.id, a.id]); }); test("honors the limit parameter", () => { for (let i = 0; i < 5; i++) { createConversation({ title: `consol-${i}`, source: "memory_v2_consolidation", }); } const results = listConversationsBySource("memory_v2_consolidation", 3); expect(results).toHaveLength(3); }); test("includes archived rows by default", () => { const conv = createConversation({ title: "archived", source: "memory_v2_consolidation", }); rawRun( "test:archiveConversation", "UPDATE conversations SET archived_at = ? WHERE id = ?", Date.now(), conv.id, ); const results = listConversationsBySource("memory_v2_consolidation"); expect(results).toHaveLength(1); expect(results[0]!.archivedAt).not.toBeNull(); }); test("excludes archived rows when includeArchived is false", () => { const archived = createConversation({ title: "archived", source: "memory_v2_consolidation", }); createConversation({ title: "live", source: "memory_v2_consolidation" }); rawRun( "test:archiveConversation", "UPDATE conversations SET archived_at = ? WHERE id = ?", Date.now(), archived.id, ); const results = listConversationsBySource("memory_v2_consolidation", 20, { includeArchived: false, }); expect(results).toHaveLength(1); expect(results[0]!.title).toBe("live"); }); test("does not apply the subagent-exclusion that listConversations does", () => { // The defensive `source != 'subagent'` carve-out in listConversations is // a foreground/background bucketing concern. A caller asking for the // exact `subagent` source via this query gets exactly that. createConversation({ title: "sub-1", source: "subagent" }); const results = listConversationsBySource("subagent"); expect(results).toHaveLength(1); expect(results[0]!.title).toBe("sub-1"); }); }); // Content-arm (lexical candidate) surfacing behavior is covered in // `persistence/__tests__/conversation-queries-search.test.ts`, which mocks the // lexical index; this suite covers the title arm's surfacing rules. describe("searchConversations · surfaced conversations", () => { beforeEach(() => { resetTables(); }); function setSurfaced(conversationId: string): void { rawRun( "test:setSurfaced", "UPDATE conversations SET surfaced_at = ? WHERE id = ?", Date.now(), conversationId, ); } test("a surfaced background conversation is found by title search", async () => { const surfaced = createConversation({ title: "Quarterly metrics rollup", conversationType: "background", }); setSurfaced(surfaced.id); const results = await searchConversations("Quarterly metrics"); expect(results.map((r) => r.conversationId)).toEqual([surfaced.id]); }); test("a non-surfaced background conversation stays excluded from title search", async () => { createConversation({ title: "Quarterly metrics rollup", conversationType: "background", }); expect(await searchConversations("Quarterly metrics")).toEqual([]); }); test("private conversations are never included, even with surfaced_at set", async () => { const priv = createConversation("Quarterly metrics rollup"); setConversationType(priv.id, "private"); setSurfaced(priv.id); expect(await searchConversations("Quarterly metrics")).toEqual([]); }); test("surfaced subagent runs stay excluded from title search", async () => { const subagent = createConversation({ title: "Quarterly metrics rollup", conversationType: "background", source: "subagent", }); setSurfaced(subagent.id); expect(await searchConversations("Quarterly metrics")).toEqual([]); }); }); describe("listPinnedConversations · surfaced conversations", () => { beforeEach(() => { resetTables(); }); function setSurfaced(conversationId: string): void { rawRun( "test:setSurfaced", "UPDATE conversations SET surfaced_at = ? WHERE id = ?", Date.now(), conversationId, ); } function setPinned(conversationId: string): void { rawRun( "test:setPinned", "UPDATE conversations SET is_pinned = 1 WHERE id = ?", conversationId, ); } test("a pinned surfaced background conversation is returned", () => { const surfaced = createConversation({ title: "bg-pinned-surfaced", conversationType: "background", }); setSurfaced(surfaced.id); setPinned(surfaced.id); const results = listPinnedConversations(); expect(results.map((r) => r.id)).toEqual([surfaced.id]); }); test("a pinned non-surfaced background conversation stays excluded", () => { const background = createConversation({ title: "bg-pinned-hidden", conversationType: "background", }); setPinned(background.id); expect(listPinnedConversations()).toEqual([]); }); test("pinned private conversations are never included, even with surfaced_at set", () => { const priv = createConversation("private-pinned"); setConversationType(priv.id, "private"); setSurfaced(priv.id); setPinned(priv.id); expect(listPinnedConversations()).toEqual([]); }); test("pinned surfaced subagent runs stay excluded", () => { const subagent = createConversation({ title: "subagent-pinned", conversationType: "background", source: "subagent", }); setSurfaced(subagent.id); setPinned(subagent.id); expect(listPinnedConversations()).toEqual([]); }); }); describe("getMessageRoleStatsByConversation", () => { beforeEach(() => { resetTables(); }); function insertMessage( conversationId: string, role: string, createdAt: number, ): void { rawRun( "test:insertMessage", "INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)", `msg-${conversationId}-${role}-${createdAt}`, conversationId, role, "x", createdAt, ); } test("returns empty map for empty input", () => { const result = getMessageRoleStatsByConversation([]); expect(result.size).toBe(0); }); test("returns empty map when conversations exist but no matching role", () => { const a = createConversation("a"); insertMessage(a.id, "user", 1000); const result = getMessageRoleStatsByConversation([a.id], "assistant"); expect(result.size).toBe(0); }); test("counts assistant messages and returns max createdAt", () => { const a = createConversation("a"); insertMessage(a.id, "user", 1000); insertMessage(a.id, "assistant", 1500); insertMessage(a.id, "assistant", 2500); insertMessage(a.id, "assistant", 2000); const result = getMessageRoleStatsByConversation([a.id], "assistant"); expect(result.size).toBe(1); expect(result.get(a.id)).toEqual({ count: 3, lastAt: 2500 }); }); test("does not count messages from other roles", () => { const a = createConversation("a"); insertMessage(a.id, "user", 1000); insertMessage(a.id, "user", 2000); insertMessage(a.id, "system", 1500); insertMessage(a.id, "assistant", 3000); const result = getMessageRoleStatsByConversation([a.id], "assistant"); expect(result.get(a.id)).toEqual({ count: 1, lastAt: 3000 }); }); test("scopes to the supplied conversation ids", () => { const a = createConversation("a"); const b = createConversation("b"); insertMessage(a.id, "assistant", 1000); insertMessage(b.id, "assistant", 2000); const result = getMessageRoleStatsByConversation([a.id], "assistant"); expect(result.size).toBe(1); expect(result.has(a.id)).toBe(true); expect(result.has(b.id)).toBe(false); }); test("aggregates per-conversation across many ids in a single query", () => { const a = createConversation("a"); const b = createConversation("b"); const c = createConversation("c"); insertMessage(a.id, "assistant", 1000); insertMessage(a.id, "assistant", 1500); insertMessage(b.id, "assistant", 2000); // c has no assistant messages → absent from the result. const result = getMessageRoleStatsByConversation( [a.id, b.id, c.id], "assistant", ); expect(result.size).toBe(2); expect(result.get(a.id)).toEqual({ count: 2, lastAt: 1500 }); expect(result.get(b.id)).toEqual({ count: 1, lastAt: 2000 }); expect(result.has(c.id)).toBe(false); }); test("role parameter selects the counted role (defaults to assistant)", () => { const a = createConversation("a"); insertMessage(a.id, "user", 1000); insertMessage(a.id, "user", 2000); insertMessage(a.id, "assistant", 1500); const assistants = getMessageRoleStatsByConversation([a.id]); expect(assistants.get(a.id)).toEqual({ count: 1, lastAt: 1500 }); const users = getMessageRoleStatsByConversation([a.id], "user"); expect(users.get(a.id)).toEqual({ count: 2, lastAt: 2000 }); }); });