import { test } from "bun:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { appConfig } from "../config.ts"; import { LLMService } from "../llm.ts"; import { MemoryManager } from "../memory/memoryManager.ts"; import { Store } from "../store/store.ts"; import { rmTempDir } from "../testHelpers.ts"; import { createTestSettings } from "../testSettings.ts"; import type { BotContext } from "./botContext.ts"; import { buildMediaMemoryFacts, getScopedFallbackFacts, loadFactProfile, loadRelevantMemoryFacts } from "./memorySlice.ts";
async function withTempMemoryContext( run: (ctx: BotContext & { store: Store; memory: MemoryManager }) => Promise ) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-bot-memory-slice-test-")); const dbPath = path.join(dir, "clanker.db"); const store = new Store(dbPath); store.init();
const llm = new LLMService({ appConfig, store }); const memory = new MemoryManager({ store, llm, memoryFilePath: path.join(dir, "memory.md") }); const ctx: BotContext & { store: Store; memory: MemoryManager } = { appConfig, store, llm, memory, client: { user: { id: "bot-1" }, guilds: { cache: new Map() } }, botUserId: "bot-1" };
try { await run(ctx); } finally { store.close(); await rmTempDir(dir); } }
function getLastLoggedAction(store: Store) {
return store.db
.prepare(
SELECT kind, content, metadata FROM actions ORDER BY id DESC LIMIT 1
)
.get() as {
kind: string;
content: string;
metadata: string | null;
};
}
test("buildMediaMemoryFacts merges facts, dedupes them, and clamps max items", () => { const facts = buildMediaMemoryFacts({ userFacts: [ { fact: "likes tea" }, { fact: "likes tea" }, "keeps receipts" ], relevantFacts: [ { fact: "prefers synthwave" }, { fact: "answers in lowercase" } ], maxItems: 3 });
assert.deepEqual(facts, ["likes tea", "keeps receipts", "prefers synthwave"]); });
test("getScopedFallbackFacts prioritizes same-channel facts before wider guild facts", async () => { await withTempMemoryContext(async (ctx) => { ctx.store.addMemoryFact({ guildId: "guild-1", channelId: "chan-2", subject: "topic-other", fact: "other channel fact" }); ctx.store.addMemoryFact({ guildId: "guild-1", subject: "topic-global", fact: "guild wide fact" }); ctx.store.addMemoryFact({ guildId: "guild-1", channelId: "chan-1", subject: "topic-same", fact: "same channel fact" });
const rows = getScopedFallbackFacts(ctx, {
guildId: "guild-1",
channelId: "chan-1",
limit: 3
});
assert.deepEqual(
rows.map((row) => row.fact),
["same channel fact", "guild wide fact", "other channel fact"]
);
}); });
test("loadFactProfile normalizes partial fact profiles from memory manager", async () => { await withTempMemoryContext(async (ctx) => { const settings = createTestSettings({ memory: { enabled: true } }); ctx.memory.loadFactProfile = (_payload) => { return { participantProfiles: [ { userId: "user-1", displayName: "user-1", facts: [{ fact: "likes tea" }] } ], userFacts: [{ fact: "likes tea" }], relevantFacts: [] }; };
const slice = loadFactProfile(ctx, {
settings,
userId: "user-1",
guildId: "guild-1",
channelId: "chan-1",
queryText: " hello there ",
source: "reply_memory_slice"
});
assert.equal(slice.participantProfiles.length, 1);
assert.deepEqual(slice.userFacts, [{ fact: "likes tea" }]);
assert.deepEqual(slice.relevantFacts, []);
assert.deepEqual(slice.guidanceFacts, []);
}); });
test("loadFactProfile logs and returns empty slice when memory manager throws", async () => { await withTempMemoryContext(async (ctx) => { const settings = createTestSettings({ memory: { enabled: true } });
ctx.memory.loadFactProfile = () => {
throw new Error("prompt slice failed");
};
const slice = loadFactProfile(ctx, {
settings,
userId: "user-1",
guildId: "guild-1",
channelId: "chan-1",
queryText: "hi",
source: "reply_memory_slice"
});
assert.deepEqual(slice, {
participantProfiles: [],
selfFacts: [],
loreFacts: [],
ownerFacts: [],
userFacts: [],
relevantFacts: [],
guidanceFacts: []
});
const action = getLastLoggedAction(ctx.store);
assert.equal(action.kind, "bot_error");
assert.equal(action.content, "reply_memory_slice: prompt slice failed");
}); });
test("loadRelevantMemoryFacts returns durable matches when memory search succeeds", async () => { await withTempMemoryContext(async (ctx) => { const settings = createTestSettings({ memory: { enabled: true } }); let capturedQuery = ""; let capturedSource = "";
ctx.memory.searchDurableFacts = async (payload) => {
capturedQuery = String(payload.queryText || "");
capturedSource = String(payload.trace?.source || "");
return [{ fact: "prefers tea", channel_id: "chan-1" }];
};
const facts = await loadRelevantMemoryFacts(ctx, {
settings,
guildId: "guild-1",
channelId: "chan-1",
queryText: " what does this person prefer "
});
assert.deepEqual(facts, [{ fact: "prefers tea", channel_id: "chan-1" }]);
assert.equal(capturedQuery, "what does this person prefer");
assert.equal(capturedSource, "memory_context");
}); });
test("loadRelevantMemoryFacts logs and falls back to scoped facts on durable search failure", async () => { await withTempMemoryContext(async (ctx) => { const settings = createTestSettings({ memory: { enabled: true } });
ctx.store.addMemoryFact({
guildId: "guild-1",
channelId: "chan-1",
subject: "topic-same",
fact: "same channel fallback"
});
ctx.store.addMemoryFact({
guildId: "guild-1",
subject: "topic-global",
fact: "guild fallback"
});
ctx.memory.searchDurableFacts = async () => {
throw new Error("vector index offline");
};
const facts = await loadRelevantMemoryFacts(ctx, {
settings,
guildId: "guild-1",
channelId: "chan-1",
queryText: " tell me the memory context ",
trace: {
source: "voice_operational_message"
},
limit: 2
});
assert.deepEqual(
facts.map((row) => row.fact),
["same channel fallback", "guild fallback"]
);
const action = getLastLoggedAction(ctx.store);
assert.equal(action.kind, "bot_error");
assert.equal(action.content, "memory_context: vector index offline");
assert.deepEqual(JSON.parse(String(action.metadata || "{}")), {
queryText: "tell me the memory context",
source: "voice_operational_message"
});
}); });
