Files
memind/memory-v2-external-adapters.test.mjs
2026-07-03 09:46:03 +08:00

309 lines
10 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { inspectMemoryV2Backend } from './memory-v2-backend-contract.mjs';
import { createLangGraphHttpClient, createLangGraphMemoryBackend } from './memory-v2-langgraph.mjs';
import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs';
import {
createRedisStreamsClient,
createRedisStreamsMemoryBackend,
} from './memory-v2-redis-streams.mjs';
import { createWeaviateHttpClient, createWeaviateMemoryBackend } from './memory-v2-weaviate.mjs';
test('weaviate backend is disabled by default and delegates only with client plus embedding', async () => {
const disabled = createWeaviateMemoryBackend();
assert.equal(disabled.name, 'weaviate');
assert.equal(disabled.category, 'semantic');
assert.equal(disabled.role, 'knowledge-graph-fusion');
assert.equal(disabled.flag, 'MEMORY_WEAVIATE_ENABLED');
assert.equal(disabled.isAvailable(), false);
assert.equal(disabled.getUnavailableReason(), 'not_configured');
assert.equal(inspectMemoryV2Backend(disabled).ok, true);
const backend = createWeaviateMemoryBackend({
enabled: true,
url: 'https://weaviate.local',
collection: 'MemindMemory',
async embedQuery(query) {
assert.equal(query, 'memory-chain');
return [0.1, 0.2];
},
client: {
async search(input) {
assert.equal(input.collection, 'MemindMemory');
assert.deepEqual(input.vector, [0.1, 0.2]);
return [{ id: 'w1', type: 'semantic', content: 'weaviate memory', score: 0.8 }];
},
},
});
const result = await backend.resolve({ userId: 'u1', query: 'memory-chain' });
assert.equal(backend.isAvailable(), true);
assert.deepEqual(result.semanticMemories, ['weaviate memory']);
});
test('weaviate backend validates url and collection', () => {
assert.throws(
() => createWeaviateMemoryBackend({ enabled: true, url: 'ftp://weaviate.local' }),
/Invalid MEMORY_WEAVIATE_URL/,
);
assert.throws(
() => createWeaviateMemoryBackend({ collection: 'bad collection' }),
/Invalid MEMORY_WEAVIATE_COLLECTION/,
);
});
test('weaviate HTTP client performs GraphQL vector search', async () => {
const client = createWeaviateHttpClient({
url: 'https://weaviate.local',
apiKey: 'secret',
async fetchImpl(url, options) {
const body = JSON.parse(options.body);
assert.equal(url, 'https://weaviate.local/v1/graphql');
assert.equal(options.headers.authorization, 'Bearer secret');
assert.deepEqual(body.variables.where, {
path: ['user_id'],
operator: 'Equal',
valueText: 'u1',
});
assert.match(body.query, /limit:\s*2/);
return {
ok: true,
async json() {
return {
data: {
Get: {
MemindMemory: [{
content: 'weaviate memory',
type: 'semantic',
_additional: { id: 'w1', certainty: 0.91 },
}],
},
},
};
},
};
},
});
const result = await client.search({
collection: 'MemindMemory',
vector: [0.1, 0.2],
userId: 'u1',
limit: 2,
});
assert.deepEqual(result, [{
id: 'w1',
label: 'semantic',
text: 'weaviate memory',
score: 0.91,
createdAt: undefined,
}]);
});
test('neo4j backend is disabled by default and delegates only with explicit client', async () => {
const disabled = createNeo4jMemoryBackend();
assert.equal(disabled.name, 'neo4j');
assert.equal(disabled.category, 'behavior');
assert.equal(disabled.role, 'behavior-graph');
assert.equal(disabled.flag, 'MEMORY_NEO4J_ENABLED');
assert.equal(disabled.isAvailable(), false);
assert.equal(disabled.getUnavailableReason(), 'not_configured');
assert.equal(inspectMemoryV2Backend(disabled).ok, true);
const backend = createNeo4jMemoryBackend({
enabled: true,
uri: 'neo4j://localhost:7687',
username: 'neo4j',
password: 'secret',
client: {
async resolve() {
return { behaviorSummary: 'prefers careful rollouts' };
},
async write() {
return { saved: 1, analyzed: 0, memories: 0 };
},
},
});
assert.equal(backend.isAvailable(), true);
assert.equal((await backend.resolve({ userId: 'u1' })).behaviorSummary, 'prefers careful rollouts');
assert.deepEqual(await backend.write({ userId: 'u1' }), { saved: 1, analyzed: 0, memories: 0 });
});
test('neo4j HTTP client writes and resolves through transactional endpoint', async () => {
const calls = [];
const client = createNeo4jHttpClient({
httpUrl: 'http://neo4j.local:7474',
username: 'neo4j',
password: 'secret',
async fetchImpl(url, options) {
calls.push({ url, body: JSON.parse(options.body), headers: options.headers });
return {
ok: true,
async json() {
if (calls.length === 1) return { results: [{ data: [{ row: [1] }] }] };
return { results: [{ data: [{ row: ['prefers careful rollouts'] }] }] };
},
};
},
});
assert.deepEqual(await client.write({
userId: 'u1',
sessionId: 's1',
messages: [{ text: 'hello' }],
}), { saved: 1, analyzed: 0, memories: 0 });
assert.equal((await client.resolve({ userId: 'u1' })).behaviorSummary, 'prefers careful rollouts');
assert.equal(calls[0].url, 'http://neo4j.local:7474/db/neo4j/tx/commit');
assert.match(calls[0].headers.authorization, /^Basic /);
assert.match(calls[0].body.statements[0].statement, /CREATE \(e:MemoryEvent/);
});
test('redis-streams backend is write-only and client-injected', async () => {
const disabled = createRedisStreamsMemoryBackend();
assert.equal(disabled.name, 'redis-streams');
assert.equal(disabled.category, 'behavior');
assert.equal(disabled.role, 'event-tracking');
assert.equal(disabled.flag, 'MEMORY_REDIS_STREAMS_ENABLED');
assert.equal(disabled.isAvailable(), false);
assert.equal(disabled.getUnavailableReason(), 'not_configured');
assert.equal(inspectMemoryV2Backend(disabled).ok, true);
assert.equal(typeof disabled.resolve, 'undefined');
const backend = createRedisStreamsMemoryBackend({
enabled: true,
url: 'redis://localhost:6379',
stream: 'memind:memory-events',
client: {
async write(input) {
assert.equal(input.stream, 'memind:memory-events');
return { saved: 1, analyzed: 0, memories: 0 };
},
},
});
assert.equal(backend.isAvailable(), true);
assert.deepEqual(await backend.write({ userId: 'u1' }), { saved: 1, analyzed: 0, memories: 0 });
});
test('redis streams client connects lazily, xadds event, and closes', async () => {
const calls = [];
const client = await createRedisStreamsClient({
url: 'redis://localhost:6379',
async importRedis() {
return {
createClient(options) {
calls.push(['createClient', options.url]);
return {
async connect() {
calls.push(['connect']);
},
async xAdd(stream, id, fields) {
calls.push(['xAdd', stream, id, fields.user_id]);
},
async quit() {
calls.push(['quit']);
},
};
},
};
},
});
assert.deepEqual(await client.write({ userId: 'u1', sessionId: 's1', messages: [] }), {
saved: 1,
analyzed: 0,
memories: 0,
});
await client.close();
assert.deepEqual(calls.map((call) => call.slice(0, 3)), [
['createClient', 'redis://localhost:6379'],
['connect'],
['xAdd', 'memind:memory-events', '*'],
['quit'],
]);
});
test('langgraph backend is disabled by default and delegates policy resolve only with client', async () => {
const disabled = createLangGraphMemoryBackend();
assert.equal(disabled.name, 'langgraph');
assert.equal(disabled.category, 'policy');
assert.equal(disabled.role, 'routing-reasoning-policy');
assert.equal(disabled.flag, 'MEMORY_LANGGRAPH_ENABLED');
assert.equal(disabled.isAvailable(), false);
assert.equal(disabled.getUnavailableReason(), 'not_configured');
assert.equal(inspectMemoryV2Backend(disabled).ok, true);
const backend = createLangGraphMemoryBackend({
enabled: true,
policyId: 'memory_policy',
client: {
async resolve(input) {
assert.equal(input.policyId, 'memory_policy');
return { activeGoals: ['route-memory'] };
},
},
});
assert.equal(backend.isAvailable(), true);
assert.deepEqual((await backend.resolve({ userId: 'u1' })).activeGoals, ['route-memory']);
});
test('langgraph HTTP client posts resolve payload', async () => {
const client = createLangGraphHttpClient({
baseUrl: 'https://langgraph.local',
apiKey: 'secret',
modelProviderKeyId: 'key_langgraph',
model: 'gpt-4.1-nano',
modelApiType: 'response',
async fetchImpl(url, options) {
assert.equal(url, 'https://langgraph.local/memory/resolve');
assert.equal(options.headers.authorization, 'Bearer secret');
assert.deepEqual(JSON.parse(options.body), {
user_id: 'u1',
session_id: 's1',
query: 'hello',
policy_id: 'memory_policy',
limit: 3,
model_provider_key_id: 'key_langgraph',
model: 'gpt-4.1-nano',
model_api_type: 'response',
});
return {
ok: true,
async json() {
return { activeGoals: ['route-memory'] };
},
};
},
});
assert.deepEqual(await client.resolve({
userId: 'u1',
sessionId: 's1',
query: 'hello',
policyId: 'memory_policy',
limit: 3,
}), { activeGoals: ['route-memory'] });
});
test('external adapter skeletons validate unsafe config values', () => {
assert.throws(
() => createNeo4jMemoryBackend({ uri: 'http://localhost:7474' }),
/Invalid MEMORY_NEO4J_URI/,
);
assert.throws(
() => createRedisStreamsMemoryBackend({ url: 'http://localhost:6379' }),
/Invalid MEMORY_REDIS_STREAMS_URL/,
);
assert.throws(
() => createRedisStreamsMemoryBackend({ stream: 'bad stream' }),
/Invalid MEMORY_REDIS_STREAMS_STREAM/,
);
assert.throws(
() => createLangGraphMemoryBackend({ policyId: 'bad policy id' }),
/Invalid MEMORY_LANGGRAPH_POLICY_ID/,
);
});