283 lines
9.2 KiB
TypeScript
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
|
|
});
|