import { describe, it, expect } from "vitest"; import { MemoSummarizer } from "../src/ai/memory/summarizer.js"; import { shouldSummarize, parseQueryList, MEMORY_KEY_SUMMARY, } from "../src/ai/agent/agent.js"; import type { LlmProvider } from "../src/ai/providers/llm.js"; import { createLogger } from "../src/utils/logger.js"; class FakeLlm implements LlmProvider { smallCalls: { system: string; user: string }[] = []; result: string; fail = false; constructor(result = "Customer speaks Chinese and is interested in CDD01.") { this.result = result; } async chat(): Promise { return "main reply"; } async chatSmall(args: { system: string; messages: { role: "user" | "assistant"; content: string }[]; }): Promise { this.smallCalls.push({ system: args.system, user: args.messages.map((m) => m.content).join("\n"), }); if (this.fail) throw new Error("small model down"); return this.result; } } const logger = createLogger("fatal"); describe("MemoSummarizer.extract", () => { it("returns the plain-text summary from the small model", async () => { const llm = new FakeLlm("Customer speaks Chinese, wants CDD01."); const s = new MemoSummarizer(llm, logger); const out = await s.extract({ priorSummary: "old", priorQueries: ["what is CDD01?", "price of CDD01"], currentTurn: "I want to buy CDD01", }); expect(out).toBe("Customer speaks Chinese, wants CDD01."); expect(llm.smallCalls).toHaveLength(1); expect(llm.smallCalls[0].system).toContain("plain-text"); expect(llm.smallCalls[0].user).toContain("Prior summary: old"); expect(llm.smallCalls[0].user).toContain("Latest customer message: I want to buy CDD01"); }); it("returns undefined when the small model throws (caller keeps old value)", async () => { const llm = new FakeLlm(); llm.fail = true; const s = new MemoSummarizer(llm, logger); const out = await s.extract({ priorSummary: "old", priorQueries: [], currentTurn: "hi", }); expect(out).toBeUndefined(); }); it("returns undefined on empty model output", async () => { const llm = new FakeLlm(" "); const s = new MemoSummarizer(llm, logger); const out = await s.extract({ priorQueries: [], currentTurn: "hi", }); expect(out).toBeUndefined(); }); }); describe("shouldSummarize", () => { const base = { langChanged: false, userText: "anything", mem: {}, priorQueries: [] as string[], turn: 3, lastSummarizedAt: 0, }; it("true when the language preference changed", () => { expect(shouldSummarize({ ...base, langChanged: true })).toBe(true); }); it("true on a brand-new SKU token", () => { expect(shouldSummarize({ ...base, userText: "how about CDD01?" })).toBe(true); }); it("false when the SKU was already seen in prior queries", () => { expect( shouldSummarize({ ...base, userText: "how about CDD01 again?", priorQueries: ["is CDD01 good?"], }), ).toBe(false); }); it("false when the SKU is already in the summary", () => { expect( shouldSummarize({ ...base, userText: "CDD01 price?", mem: { [MEMORY_KEY_SUMMARY]: "likes CDD01" }, }), ).toBe(false); }); it("true on identity keywords", () => { expect(shouldSummarize({ ...base, userText: "我是 Hoelee,想买" })).toBe(true); expect(shouldSummarize({ ...base, userText: "I need a refund" })).toBe(true); }); it("true when >=10 turns since last summary (fallback)", () => { expect( shouldSummarize({ ...base, turn: 10, lastSummarizedAt: 0 }), ).toBe(true); expect( shouldSummarize({ ...base, turn: 9, lastSummarizedAt: 0 }), ).toBe(false); }); it("false when nothing matches", () => { expect(shouldSummarize({ ...base, userText: "thanks" })).toBe(false); }); }); describe("parseQueryList", () => { it("parses a valid JSON array", () => { expect(parseQueryList('["a","b"]')).toEqual(["a", "b"]); }); it("returns [] for undefined, empty, or malformed", () => { expect(parseQueryList(undefined)).toEqual([]); expect(parseQueryList("")).toEqual([]); expect(parseQueryList("{not json")).toEqual([]); }); it("drops non-string entries", () => { expect(parseQueryList('[1,"a",null]')).toEqual(["a"]); }); });