import { describe, expect, it } from "bun:test"; import { dedupCheck } from "../ingest.js"; function buildSearchResult(text: string, score: number) { return { score, entry: { id: `memory-${score}`, text, vector: [score], category: "events", scope: "cc:test-session", importance: 0.6, timestamp: 1_700_000_000_000, metadata: "{}", }, }; } describe("dedupCheck", () => { it("stores a new same-brand item preference instead of treating it as a duplicate topic", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult("喜欢吃麦当劳的麦旋风", 0.92), buildSearchResult("喜欢吃麦当劳的板烧鸡腿堡", 0.89), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "SKIP" as const, reason: "same topic" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "我喜欢吃麦当劳的麦辣鸡翅", llm as any, ); expect(result.action).toBe("store"); expect(result.reason).toBe("unique"); expect(result.existingText).toBe("喜欢吃麦当劳的板烧鸡腿堡"); expect(llmCalls).toBe(0); }); it("stores a new atomic preference even when the closest match is an aggregate summary", async () => { const store = { async vectorSearch() { return [ buildSearchResult("喜欢吃麦当劳的麦旋风、板烧鸡腿堡和藤椒鸡派", 0.94), ]; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "我喜欢吃麦当劳的麦辣鸡翅", ); expect(result.action).toBe("store"); expect(result.reason).toBe("unique"); expect(result.existingText).toContain("麦旋风"); }); it("still skips when an exact atomic preference already exists among the candidates", async () => { const store = { async vectorSearch() { return [ buildSearchResult("喜欢吃麦当劳的麦旋风、板烧鸡腿堡和藤椒鸡派", 0.94), buildSearchResult("我喜欢吃麦当劳的麦辣鸡翅", 0.82), ]; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "我喜欢吃麦当劳的麦辣鸡翅", ); expect(result.action).toBe("skip"); expect(result.reason).toBe("exact"); expect(result.existingText).toBe("我喜欢吃麦当劳的麦辣鸡翅"); }); it("does not force-create when the same brand-item preference is only rephrased", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult("我喜欢吃麦当劳的麦辣鸡翅", 0.75), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "SKIP" as const, reason: "same item, rephrased" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "用户喜欢麦当劳的麦辣鸡翅", llm as any, ); expect(result.action).toBe("skip"); expect(result.reason).toBe("llm-skip"); expect(result.existingText).toBe("我喜欢吃麦当劳的麦辣鸡翅"); expect(llmCalls).toBe(1); }); it("does not trigger the brand-item guard for non-preference text that mentions the same brand and item", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult("我喜欢喝星巴克的抹茶拿铁", 0.75), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "SKIP" as const, reason: "same context, not a new preference" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "我们刚讨论过星巴克的抹茶拿铁做法", llm as any, ); expect(result.action).toBe("skip"); expect(result.reason).toBe("llm-skip"); expect(result.existingText).toBe("我喜欢喝星巴克的抹茶拿铁"); expect(llmCalls).toBe(1); }); it("stores a new reply-style preference instead of collapsing different style traits into one topic", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult("User prefers concise, direct replies.", 0.91), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "SKIP" as const, reason: "same topic" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "User prefers colloquial, grounded replies.", llm as any, ); expect(result.action).toBe("store"); expect(result.reason).toBe("unique"); expect(result.existingText).toBe("User prefers concise, direct replies."); expect(llmCalls).toBe(0); }); it("does not trigger the reply-style guard for descriptive text about a draft", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult("User prefers colloquial, grounded replies.", 0.75), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "SKIP" as const, reason: "draft note, not a user preference" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "这段文案简洁直接,先别改。", llm as any, ); expect(result.action).toBe("skip"); expect(result.reason).toBe("llm-skip"); expect(result.existingText).toBe("User prefers colloquial, grounded replies."); expect(llmCalls).toBe(1); }); it("stores a new tool-choice preference instead of collapsing different tool choices into one topic", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult("Uses Bun over Node.", 0.91), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "SKIP" as const, reason: "same topic" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "Prefers rg over grep.", llm as any, ); expect(result.action).toBe("store"); expect(result.reason).toBe("unique"); expect(result.existingText).toBe("Uses Bun over Node."); expect(llmCalls).toBe(0); }); it("does not trigger the tool-choice guard for narrative migration text", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult("Prefers rg over grep.", 0.75), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "SKIP" as const, reason: "migration note, not a tool preference" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "文档里写了 uses Bun over Node 的迁移说明。", llm as any, ); expect(result.action).toBe("skip"); expect(result.reason).toBe("llm-skip"); expect(result.existingText).toBe("Prefers rg over grep."); expect(llmCalls).toBe(1); }); it("stores a borderline chunk when LLM says it should CREATE", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult( "A2A gateway 之前是先本地 smoke,再补 Claude SDK 配置。", 0.74, ), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "CREATE" as const, reason: "new implementation branch" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "A2A gateway 升级后要补 MCP transport 配置和 LaunchAgent 环境变量,不是原来的 smoke 流程。", llm as any, ); expect(result.action).toBe("store"); expect(result.reason).toBe("unique"); expect(llmCalls).toBe(1); }); it("stores a borderline chunk when LLM says it adds new information and should MERGE", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult( "A2A 调 Claude SDK 时,可以先看 `adapters/claude.js` 里的权限相关配置。", 0.77, ), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "MERGE" as const, reason: "same topic but new implementation detail" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "给 A2A 调用传 permissionMode: \"dontAsk\" + allowedTools,避免 Claude SDK 在后台 HTTP handler 里卡死等权限确认。", llm as any, ); expect(result.action).toBe("store"); expect(result.reason).toBe("llm-merge"); expect(result.existingText).toContain("权限相关配置"); expect(llmCalls).toBe(1); }); it("skips a hard-threshold duplicate without calling the LLM", async () => { let llmCalls = 0; const store = { async vectorSearch() { return [ buildSearchResult( "OpenClaw provider 配置里已经写过 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_AUTH_TOKEN` 的设置方法。", 0.95, ), ]; }, }; const llm = { async dedupDecision() { llmCalls += 1; return { action: "MERGE" as const, reason: "should never be called" }; }, }; const result = await dedupCheck( store as any, [1, 0, 0], "OpenClaw provider 配置里已经写过 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_AUTH_TOKEN` 的设置方法,但这里重复了一遍。", llm as any, ); expect(result.action).toBe("skip"); expect(result.reason).toBe("hard"); expect(llmCalls).toBe(0); }); });