Merge Memory V2 runtime facade
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user