595 lines
19 KiB
TypeScript
595 lines
19 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mock } from "node:test";
|
|
|
|
import { createChatPipelineHarness } from "./_chatPipelineHarness.ts";
|
|
|
|
const harness = await createChatPipelineHarness("memory-pipeline");
|
|
|
|
// Dynamic imports — MUST happen after harness creation to avoid premature DB init.
|
|
// The harness sets DATA_DIR before importing DB modules, so these must resolve after that.
|
|
const { extractFactsFromText } = await import("../../src/lib/memory/extraction.ts");
|
|
const { retrieveMemories } = await import("../../src/lib/memory/retrieval.ts");
|
|
const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts");
|
|
const { injectMemory, formatMemoryContext } = await import("../../src/lib/memory/injection.ts");
|
|
const {
|
|
BaseExecutor,
|
|
buildOpenAIResponse,
|
|
buildRequest,
|
|
handleChat,
|
|
memoryStore,
|
|
memoryTools,
|
|
resetStorage,
|
|
seedApiKey,
|
|
seedConnection,
|
|
settingsDb,
|
|
waitFor,
|
|
} = harness;
|
|
|
|
const { createMemory, listMemories } = memoryStore;
|
|
|
|
/** Drop FTS5 triggers/table that cause SQLITE_MISMATCH (TEXT id used as INTEGER rowid). */
|
|
function dropFts5Artifacts() {
|
|
try {
|
|
const db = harness.core.getDbInstance();
|
|
db.exec(
|
|
"DROP TRIGGER IF EXISTS memory_fts_ai;" +
|
|
"DROP TRIGGER IF EXISTS memory_fts_ad;" +
|
|
"DROP TRIGGER IF EXISTS memory_fts_au;" +
|
|
"DROP TABLE IF EXISTS memory_fts;"
|
|
);
|
|
} catch (_: any) {
|
|
/* ignore if already dropped or DB not yet initialized */
|
|
}
|
|
}
|
|
|
|
test.beforeEach(async () => {
|
|
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
|
await resetStorage();
|
|
invalidateMemorySettingsCache();
|
|
dropFts5Artifacts();
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
BaseExecutor.RETRY_CONFIG.delayMs = harness.originalRetryDelayMs;
|
|
await resetStorage();
|
|
invalidateMemorySettingsCache();
|
|
});
|
|
|
|
test.after(async () => {
|
|
await harness.cleanup();
|
|
});
|
|
|
|
async function enableMemory(
|
|
maxTokens = 400,
|
|
strategy: "recent" | "semantic" | "hybrid" = "recent"
|
|
) {
|
|
await settingsDb.updateSettings({
|
|
memoryEnabled: true,
|
|
memoryMaxTokens: maxTokens,
|
|
memoryRetentionDays: 30,
|
|
memoryStrategy: strategy,
|
|
});
|
|
}
|
|
|
|
test("first request proceeds without injected context when the store is empty", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-memory-empty" });
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory();
|
|
|
|
const fetchCalls = [];
|
|
globalThis.fetch = async (_url, init = {}) => {
|
|
fetchCalls.push(init.body ? JSON.parse(String(init.body)) : null);
|
|
return buildOpenAIResponse("No memory yet");
|
|
};
|
|
|
|
const response = await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
body: {
|
|
model: "openai/gpt-4o-mini",
|
|
stream: false,
|
|
messages: [{ role: "user", content: "First turn" }],
|
|
},
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(fetchCalls.length, 1);
|
|
assert.equal(fetchCalls[0].messages[0].role, "user");
|
|
assert.equal(fetchCalls[0].messages[0].content, "First turn");
|
|
});
|
|
|
|
test("successful responses extract facts and persist them as memories", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-extract" });
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory();
|
|
|
|
globalThis.fetch = async () =>
|
|
buildOpenAIResponse("I prefer concise answers. I usually answer in bullet points.");
|
|
|
|
const response = await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
headers: { "x-omniroute-session-id": "session-extract" },
|
|
body: {
|
|
model: "openai/gpt-4o-mini",
|
|
stream: false,
|
|
messages: [{ role: "user", content: "Remember my preferences" }],
|
|
},
|
|
})
|
|
);
|
|
|
|
const memories = await waitFor(async () => {
|
|
dropFts5Artifacts();
|
|
const result = await listMemories({ apiKeyId: apiKey.id });
|
|
const list = Array.isArray(result) ? result : (result.data ?? []);
|
|
return list.length >= 2 ? list : null;
|
|
}, 5000);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.ok(memories, "expected extracted memories to be stored");
|
|
assert.ok(memories.some((memory) => /concise answers/i.test(memory.content)));
|
|
assert.ok(memories.some((memory) => /bullet points/i.test(memory.content)));
|
|
assert.ok(memories.every((memory) => memory.sessionId === "session-extract"));
|
|
});
|
|
|
|
test("later requests inject retrieved memories into upstream messages", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-inject" });
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory();
|
|
|
|
await createMemory({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "session-inject",
|
|
type: "factual",
|
|
key: "preference:concise",
|
|
content: "User prefers concise answers.",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
const fetchCalls = [];
|
|
globalThis.fetch = async (_url, init = {}) => {
|
|
fetchCalls.push(init.body ? JSON.parse(String(init.body)) : null);
|
|
return buildOpenAIResponse("Memory injected");
|
|
};
|
|
|
|
const response = await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
headers: { "x-omniroute-session-id": "session-inject" },
|
|
body: {
|
|
model: "openai/gpt-4o-mini",
|
|
stream: false,
|
|
messages: [{ role: "user", content: "What do you remember?" }],
|
|
},
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(fetchCalls.length, 1);
|
|
assert.equal(fetchCalls[0].messages[0].role, "system");
|
|
assert.match(fetchCalls[0].messages[0].content, /User prefers concise answers/);
|
|
});
|
|
|
|
test("memory search ranks query-relevant memories first", async () => {
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory(400, "hybrid");
|
|
|
|
await memoryTools.omniroute_memory_add.handler({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "search",
|
|
type: "factual",
|
|
key: "pref:language",
|
|
content: "The user writes TypeScript services every day.",
|
|
metadata: {},
|
|
});
|
|
await memoryTools.omniroute_memory_add.handler({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "search",
|
|
type: "factual",
|
|
key: "pref:hobby",
|
|
content: "The user enjoys gardening on weekends.",
|
|
metadata: {},
|
|
});
|
|
await memoryTools.omniroute_memory_add.handler({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "search",
|
|
type: "factual",
|
|
key: "pref:stack",
|
|
content: "TypeScript and Node.js are the preferred backend stack.",
|
|
metadata: {},
|
|
});
|
|
|
|
const result = await memoryTools.omniroute_memory_search.handler({
|
|
apiKeyId: apiKey.id,
|
|
query: "typescript backend",
|
|
limit: 2,
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.equal(result.data.count, 2);
|
|
assert.match(result.data.memories[0].content, /TypeScript/i);
|
|
assert.ok(result.data.memories.every((memory) => /TypeScript|backend/i.test(memory.content)));
|
|
});
|
|
|
|
test("memory injection respects the configured token budget", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-budget" });
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory(20);
|
|
|
|
await createMemory({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "budget",
|
|
type: "factual",
|
|
key: "older",
|
|
content: "Older preference that should be trimmed when the context budget is tight.",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
await createMemory({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "budget",
|
|
type: "factual",
|
|
key: "newer",
|
|
content: "Newest preference should fit first.",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
const fetchCalls = [];
|
|
globalThis.fetch = async (_url, init = {}) => {
|
|
fetchCalls.push(init.body ? JSON.parse(String(init.body)) : null);
|
|
return buildOpenAIResponse("Budget respected");
|
|
};
|
|
|
|
const response = await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
body: {
|
|
model: "openai/gpt-4o-mini",
|
|
stream: false,
|
|
messages: [{ role: "user", content: "Use only the relevant memory." }],
|
|
},
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(fetchCalls.length, 1);
|
|
assert.match(fetchCalls[0].messages[0].content, /Newest preference should fit first/);
|
|
assert.doesNotMatch(fetchCalls[0].messages[0].content, /Older preference that should be trimmed/);
|
|
});
|
|
|
|
test("disabled memory skips both extraction and injection", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-memory-off" });
|
|
const apiKey = await seedApiKey();
|
|
await settingsDb.updateSettings({
|
|
memoryEnabled: false,
|
|
memoryMaxTokens: 400,
|
|
memoryRetentionDays: 30,
|
|
memoryStrategy: "recent",
|
|
});
|
|
|
|
const fetchCalls = [];
|
|
globalThis.fetch = async (_url, init = {}) => {
|
|
fetchCalls.push(init.body ? JSON.parse(String(init.body)) : null);
|
|
return buildOpenAIResponse("I prefer dark mode.");
|
|
};
|
|
|
|
const response = await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
body: {
|
|
model: "openai/gpt-4o-mini",
|
|
stream: false,
|
|
messages: [{ role: "user", content: "This should not be remembered." }],
|
|
},
|
|
})
|
|
);
|
|
|
|
const memories = await waitFor(async () => {
|
|
const result = await listMemories({ apiKeyId: apiKey.id });
|
|
const list = Array.isArray(result) ? result : (result.data ?? []);
|
|
return list.length > 0 ? list : [];
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(fetchCalls[0].messages[0].role, "user");
|
|
assert.deepEqual(memories, []);
|
|
});
|
|
|
|
test("memory clear removes all stored memories for an API key", async () => {
|
|
const apiKey = await seedApiKey();
|
|
|
|
await memoryTools.omniroute_memory_add.handler({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "clear",
|
|
type: "factual",
|
|
key: "pref:one",
|
|
content: "First memory",
|
|
metadata: {},
|
|
});
|
|
await memoryTools.omniroute_memory_add.handler({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "clear",
|
|
type: "episodic",
|
|
key: "event:two",
|
|
content: "Second memory",
|
|
metadata: {},
|
|
});
|
|
|
|
const cleared = await memoryTools.omniroute_memory_clear.handler({
|
|
apiKeyId: apiKey.id,
|
|
});
|
|
const remaining = await listMemories({ apiKeyId: apiKey.id });
|
|
const remainingList = Array.isArray(remaining) ? remaining : (remaining.data ?? []);
|
|
|
|
assert.equal(cleared.success, true);
|
|
assert.equal(cleared.data.deletedCount, 2);
|
|
assert.equal(remainingList.length, 0);
|
|
});
|
|
|
|
test("extracted memories remain isolated by session id", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-session-memory" });
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory();
|
|
|
|
globalThis.fetch = async () => buildOpenAIResponse("I prefer tea.");
|
|
await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
headers: { "x-omniroute-session-id": "session-a" },
|
|
body: {
|
|
model: "openai/gpt-4o-mini",
|
|
stream: false,
|
|
messages: [{ role: "user", content: "Remember drink A" }],
|
|
},
|
|
})
|
|
);
|
|
|
|
globalThis.fetch = async () => buildOpenAIResponse("I prefer coffee.");
|
|
await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
headers: { "x-omniroute-session-id": "session-b" },
|
|
body: {
|
|
model: "openai/gpt-4o-mini",
|
|
stream: false,
|
|
messages: [{ role: "user", content: "Remember drink B" }],
|
|
},
|
|
})
|
|
);
|
|
|
|
const sessionAMemories = await waitFor(async () => {
|
|
dropFts5Artifacts();
|
|
const result = await listMemories({ apiKeyId: apiKey.id, sessionId: "session-a" });
|
|
const list = Array.isArray(result) ? result : (result.data ?? []);
|
|
return list.length > 0 ? list : null;
|
|
}, 5000);
|
|
const sessionBMemories = await waitFor(async () => {
|
|
dropFts5Artifacts();
|
|
const result = await listMemories({ apiKeyId: apiKey.id, sessionId: "session-b" });
|
|
const list = Array.isArray(result) ? result : (result.data ?? []);
|
|
return list.length > 0 ? list : null;
|
|
}, 5000);
|
|
|
|
assert.ok(sessionAMemories, "expected session A memories");
|
|
assert.ok(sessionBMemories, "expected session B memories");
|
|
assert.ok(sessionAMemories.every((memory) => /tea/i.test(memory.content)));
|
|
assert.ok(sessionBMemories.every((memory) => /coffee/i.test(memory.content)));
|
|
});
|
|
|
|
// ─── Module-to-Module Pipeline Tests ──────────────────────────────────────────
|
|
|
|
test("extraction→storage: extractFactsFromText output persists via createMemory", async () => {
|
|
const apiKey = await seedApiKey();
|
|
|
|
// 1. Extract facts synchronously (no LLM call)
|
|
const text = "I prefer TypeScript. I usually write tests first. I'll use Vitest for unit tests.";
|
|
const facts = extractFactsFromText(text);
|
|
|
|
assert.ok(facts.length >= 3, `expected ≥3 facts, got ${facts.length}`);
|
|
assert.ok(facts.some((f) => f.category === "preference"));
|
|
assert.ok(facts.some((f) => f.category === "pattern"));
|
|
assert.ok(facts.some((f) => f.category === "decision"));
|
|
|
|
// 2. Store each extracted fact via createMemory
|
|
const stored = [];
|
|
for (const fact of facts) {
|
|
const memory = await createMemory({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "extract-store-test",
|
|
type: fact.type,
|
|
key: fact.key,
|
|
content: fact.content,
|
|
metadata: { category: fact.category, source: "test" },
|
|
expiresAt: null,
|
|
});
|
|
stored.push(memory);
|
|
}
|
|
|
|
// 3. Verify all are persisted in DB
|
|
assert.equal(stored.length, facts.length);
|
|
for (const mem of stored) {
|
|
assert.ok(mem.id, "stored memory should have an id");
|
|
assert.equal(mem.apiKeyId, apiKey.id);
|
|
assert.equal(mem.sessionId, "extract-store-test");
|
|
}
|
|
|
|
// 4. Verify via listMemories
|
|
const rows = await listMemories({ apiKeyId: apiKey.id, sessionId: "extract-store-test" });
|
|
// listMemories may return { data, total } or flat array — handle both like existing tests
|
|
const list = Array.isArray(rows) ? rows : (rows.data ?? []);
|
|
assert.equal(list.length, facts.length, "all extracted facts should be persisted");
|
|
});
|
|
|
|
test("retrieval→injection: retrieveMemories feeds into injectMemory context", async () => {
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory(2000);
|
|
|
|
// 1. Seed two memories
|
|
await createMemory({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "retrieval-inject-test",
|
|
type: "factual",
|
|
key: "pref:editor",
|
|
content: "User prefers VS Code.",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
await createMemory({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "retrieval-inject-test",
|
|
type: "factual",
|
|
key: "pref:lang",
|
|
content: "User works with TypeScript.",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
|
|
// 2. Retrieve memories via the retrieval module
|
|
const memories = await retrieveMemories(apiKey.id, {
|
|
maxTokens: 2000,
|
|
retrievalStrategy: "exact",
|
|
retentionDays: 30,
|
|
});
|
|
|
|
assert.ok(memories.length >= 2, `expected ≥2 memories, got ${memories.length}`);
|
|
|
|
// 3. Inject into a request
|
|
const request = {
|
|
model: "openai/gpt-4o-mini",
|
|
messages: [{ role: "user", content: "What editor do I use?" }],
|
|
};
|
|
const injected = injectMemory(request, memories, "openai");
|
|
|
|
// 4. Verify injection
|
|
assert.ok(injected.messages.length > request.messages.length, "should prepend memory message");
|
|
assert.equal(injected.messages[0].role, "system", "memory should be injected as system message");
|
|
assert.match(injected.messages[0].content, /Memory context:/);
|
|
assert.match(injected.messages[0].content, /VS Code/);
|
|
assert.match(injected.messages[0].content, /TypeScript/);
|
|
// Original user message should still be present
|
|
assert.equal(injected.messages[injected.messages.length - 1].content, "What editor do I use?");
|
|
});
|
|
|
|
test("full pipeline: extract → store → retrieve → inject end-to-end", async () => {
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory(2000);
|
|
|
|
// 1. Extract facts from simulated LLM response text
|
|
const llmResponse =
|
|
"I prefer dark mode editors. I usually commit small changes. I'll use pnpm for package management.";
|
|
const facts = extractFactsFromText(llmResponse);
|
|
assert.ok(facts.length >= 3, `expected ≥3 facts from LLM response, got ${facts.length}`);
|
|
|
|
// 2. Store all extracted facts
|
|
for (const fact of facts) {
|
|
await createMemory({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "full-pipeline-test",
|
|
type: fact.type,
|
|
key: fact.key,
|
|
content: fact.content,
|
|
metadata: { category: fact.category, source: "llm_response" },
|
|
expiresAt: null,
|
|
});
|
|
}
|
|
|
|
// 3. Retrieve stored memories
|
|
const memories = await retrieveMemories(apiKey.id, {
|
|
maxTokens: 2000,
|
|
retrievalStrategy: "exact",
|
|
retentionDays: 30,
|
|
});
|
|
assert.ok(memories.length >= 3, `expected ≥3 retrieved memories, got ${memories.length}`);
|
|
|
|
// 4. Inject into a new request
|
|
const request = {
|
|
model: "openai/gpt-4o-mini",
|
|
messages: [{ role: "user", content: "What are my preferences?" }],
|
|
};
|
|
const injected = injectMemory(request, memories, "openai");
|
|
|
|
// 5. Full pipeline assertions
|
|
assert.equal(injected.messages[0].role, "system");
|
|
assert.match(injected.messages[0].content, /Memory context:/);
|
|
assert.match(injected.messages[0].content, /dark mode/);
|
|
assert.match(injected.messages[0].content, /small changes/);
|
|
assert.match(injected.messages[0].content, /pnpm/);
|
|
assert.equal(injected.messages.length, 2, "system memory + original user message");
|
|
|
|
// 6. Verify for non-system providers (o1-mini) — should inject as user message
|
|
const injectedForO1 = injectMemory(request, memories, "o1-mini");
|
|
assert.equal(injectedForO1.messages[0].role, "user", "o1-mini should get user-role memory");
|
|
assert.match(injectedForO1.messages[0].content, /Memory context:/);
|
|
});
|
|
|
|
test("logging verification: observability logs fire during pipeline operations", async () => {
|
|
const apiKey = await seedApiKey();
|
|
await enableMemory(2000);
|
|
|
|
// Spy on console methods used by the logger
|
|
const logSpy = mock.method(console, "log", () => {});
|
|
const debugSpy = mock.method(console, "debug", () => {});
|
|
|
|
try {
|
|
// 1. createMemory should trigger "memory.stored" log
|
|
const mem = await createMemory({
|
|
apiKeyId: apiKey.id,
|
|
sessionId: "log-test",
|
|
type: "factual",
|
|
key: "pref:logging",
|
|
content: "User likes verbose logging.",
|
|
metadata: {},
|
|
expiresAt: null,
|
|
});
|
|
assert.ok(mem.id, "memory should be created");
|
|
|
|
// 2. retrieveMemories should trigger "memory.retrieval.start" + "memory.retrieval.complete"
|
|
const memories = await retrieveMemories(apiKey.id, {
|
|
maxTokens: 2000,
|
|
retrievalStrategy: "exact",
|
|
retentionDays: 30,
|
|
});
|
|
assert.ok(memories.length >= 1, "should retrieve at least one memory");
|
|
|
|
// 3. injectMemory should trigger "memory.injection.injected"
|
|
const request = {
|
|
model: "openai/gpt-4o-mini",
|
|
messages: [{ role: "user", content: "Test" }],
|
|
};
|
|
injectMemory(request, memories, "openai");
|
|
|
|
// 4. injectMemory with empty memories should trigger "memory.injection.skipped"
|
|
injectMemory(request, [], "openai");
|
|
|
|
// 5. Verify that logs were emitted (console.log/debug were called)
|
|
const allCalls = [...logSpy.mock.calls, ...debugSpy.mock.calls];
|
|
assert.ok(
|
|
allCalls.length > 0,
|
|
"expected console.log or console.debug to be called by logger during pipeline operations"
|
|
);
|
|
|
|
// 6. Check for specific log event strings in the log output
|
|
const allLogOutput = allCalls.map((c) => c.arguments.join(" ")).join("\n");
|
|
assert.match(allLogOutput, /memory\.stored/i, "should log memory.stored event");
|
|
assert.match(
|
|
allLogOutput,
|
|
/memory\.retrieval\.(start|complete)/i,
|
|
"should log memory retrieval events"
|
|
);
|
|
assert.match(
|
|
allLogOutput,
|
|
/memory\.injection\.(injected|skipped)/i,
|
|
"should log memory injection events"
|
|
);
|
|
} finally {
|
|
// Restore console methods
|
|
logSpy.mock.restore();
|
|
debugSpy.mock.restore();
|
|
}
|
|
});
|