Files
clawdbot/extensions/memory-lancedb/index.test.ts
2026-01-18 22:27:22 +00:00

283 lines
9.2 KiB
TypeScript

/**
* Memory Plugin E2E Tests
*
* Tests the memory plugin functionality including:
* - Plugin registration and configuration
* - Memory storage and retrieval
* - Auto-recall via hooks
* - Auto-capture filtering
*/
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
// Skip if no OpenAI API key
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
describeWithKey("memory plugin e2e", () => {
let tmpDir: string;
let dbPath: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-test-"));
dbPath = path.join(tmpDir, "lancedb");
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test("memory plugin registers and initializes correctly", async () => {
// Dynamic import to avoid loading LanceDB when not testing
const { default: memoryPlugin } = await import("./index.js");
expect(memoryPlugin.id).toBe("memory-lancedb");
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
expect(memoryPlugin.kind).toBe("memory");
expect(memoryPlugin.configSchema).toBeDefined();
expect(memoryPlugin.register).toBeInstanceOf(Function);
});
test("config schema parses valid config", async () => {
const { default: memoryPlugin } = await import("./index.js");
const config = memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath,
autoCapture: true,
autoRecall: true,
});
expect(config).toBeDefined();
expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY);
expect(config?.dbPath).toBe(dbPath);
});
test("config schema resolves env vars", async () => {
const { default: memoryPlugin } = await import("./index.js");
// Set a test env var
process.env.TEST_MEMORY_API_KEY = "test-key-123";
const config = memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: "${TEST_MEMORY_API_KEY}",
},
dbPath,
});
expect(config?.embedding?.apiKey).toBe("test-key-123");
delete process.env.TEST_MEMORY_API_KEY;
});
test("config schema rejects missing apiKey", async () => {
const { default: memoryPlugin } = await import("./index.js");
expect(() => {
memoryPlugin.configSchema?.parse?.({
embedding: {},
dbPath,
});
}).toThrow("embedding.apiKey is required");
});
test("shouldCapture filters correctly", async () => {
// Test the capture filtering logic by checking the rules
const triggers = [
{ text: "I prefer dark mode", shouldMatch: true },
{ text: "Remember that my name is John", shouldMatch: true },
{ text: "My email is test@example.com", shouldMatch: true },
{ text: "Call me at +1234567890123", shouldMatch: true },
{ text: "We decided to use TypeScript", shouldMatch: true },
{ text: "I always want verbose output", shouldMatch: true },
{ text: "Just a random short message", shouldMatch: false },
{ text: "x", shouldMatch: false }, // Too short
{ text: "<relevant-memories>injected</relevant-memories>", shouldMatch: false }, // Skip injected
];
// The shouldCapture function is internal, but we can test via the capture behavior
// For now, just verify the patterns we expect to match
for (const { text, shouldMatch } of triggers) {
const hasPreference = /prefer|radši|like|love|hate|want/i.test(text);
const hasRemember = /zapamatuj|pamatuj|remember/i.test(text);
const hasEmail = /[\w.-]+@[\w.-]+\.\w+/.test(text);
const hasPhone = /\+\d{10,}/.test(text);
const hasDecision = /rozhodli|decided|will use|budeme/i.test(text);
const hasAlways = /always|never|important/i.test(text);
const isInjected = text.includes("<relevant-memories>");
const isTooShort = text.length < 10;
const wouldCapture =
!isTooShort &&
!isInjected &&
(hasPreference || hasRemember || hasEmail || hasPhone || hasDecision || hasAlways);
if (shouldMatch) {
expect(wouldCapture).toBe(true);
}
}
});
test("detectCategory classifies correctly", async () => {
// Test category detection patterns
const cases = [
{ text: "I prefer dark mode", expected: "preference" },
{ text: "We decided to use React", expected: "decision" },
{ text: "My email is test@example.com", expected: "entity" },
{ text: "The server is running on port 3000", expected: "fact" },
];
for (const { text, expected } of cases) {
const lower = text.toLowerCase();
let category: string;
if (/prefer|radši|like|love|hate|want/i.test(lower)) {
category = "preference";
} else if (/rozhodli|decided|will use|budeme/i.test(lower)) {
category = "decision";
} else if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) {
category = "entity";
} else if (/is|are|has|have|je|má|jsou/i.test(lower)) {
category = "fact";
} else {
category = "other";
}
expect(category).toBe(expected);
}
});
});
// Live tests that require OpenAI API key and actually use LanceDB
describeWithKey("memory plugin live tests", () => {
let tmpDir: string;
let dbPath: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-live-"));
dbPath = path.join(tmpDir, "lancedb");
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test("memory tools work end-to-end", async () => {
const { default: memoryPlugin } = await import("./index.js");
// Mock plugin API
const registeredTools: any[] = [];
const registeredClis: any[] = [];
const registeredServices: any[] = [];
const registeredHooks: Record<string, any[]> = {};
const logs: string[] = [];
const mockApi = {
id: "memory-lancedb",
name: "Memory (LanceDB)",
source: "test",
config: {},
pluginConfig: {
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath,
autoCapture: false,
autoRecall: false,
},
runtime: {},
logger: {
info: (msg: string) => logs.push(`[info] ${msg}`),
warn: (msg: string) => logs.push(`[warn] ${msg}`),
error: (msg: string) => logs.push(`[error] ${msg}`),
debug: (msg: string) => logs.push(`[debug] ${msg}`),
},
registerTool: (tool: any, opts: any) => {
registeredTools.push({ tool, opts });
},
registerCli: (registrar: any, opts: any) => {
registeredClis.push({ registrar, opts });
},
registerService: (service: any) => {
registeredServices.push(service);
},
on: (hookName: string, handler: any) => {
if (!registeredHooks[hookName]) registeredHooks[hookName] = [];
registeredHooks[hookName].push(handler);
},
resolvePath: (p: string) => p,
};
// Register plugin
await memoryPlugin.register(mockApi as any);
// Check registration
expect(registeredTools.length).toBe(3);
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_recall");
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_store");
expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_forget");
expect(registeredClis.length).toBe(1);
expect(registeredServices.length).toBe(1);
// Get tool functions
const storeTool = registeredTools.find((t) => t.opts?.name === "memory_store")?.tool;
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool;
// Test store
const storeResult = await storeTool.execute("test-call-1", {
text: "The user prefers dark mode for all applications",
importance: 0.8,
category: "preference",
});
expect(storeResult.details?.action).toBe("created");
expect(storeResult.details?.id).toBeDefined();
const storedId = storeResult.details?.id;
// Test recall
const recallResult = await recallTool.execute("test-call-2", {
query: "dark mode preference",
limit: 5,
});
expect(recallResult.details?.count).toBeGreaterThan(0);
expect(recallResult.details?.memories?.[0]?.text).toContain("dark mode");
// Test duplicate detection
const duplicateResult = await storeTool.execute("test-call-3", {
text: "The user prefers dark mode for all applications",
});
expect(duplicateResult.details?.action).toBe("duplicate");
// Test forget
const forgetResult = await forgetTool.execute("test-call-4", {
memoryId: storedId,
});
expect(forgetResult.details?.action).toBe("deleted");
// Verify it's gone
const recallAfterForget = await recallTool.execute("test-call-5", {
query: "dark mode preference",
limit: 5,
});
expect(recallAfterForget.details?.count).toBe(0);
}, 60000); // 60s timeout for live API calls
});