895 lines
30 KiB
JavaScript
895 lines
30 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createManagedMemoryV2Runtime, createMemoryV2Runtime } from './memory-v2-runtime.mjs';
|
|
|
|
function silentLogger() {
|
|
return {
|
|
warnings: [],
|
|
warn(message) {
|
|
this.warnings.push(message);
|
|
},
|
|
};
|
|
}
|
|
|
|
function legacyService(memories = []) {
|
|
return {
|
|
async listMemories() {
|
|
return memories;
|
|
},
|
|
async saveAndAnalyze() {
|
|
return { saved: 1, analyzed: 1, memories: 1 };
|
|
},
|
|
async analyzeUser() {
|
|
return { analyzed: 1, memories: 1 };
|
|
},
|
|
};
|
|
}
|
|
|
|
test('createMemoryV2Runtime keeps pgvector dormant without explicit pg URL', async () => {
|
|
let importedPg = false;
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy memory' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
},
|
|
importPg() {
|
|
importedPg = true;
|
|
throw new Error('should not import pg');
|
|
},
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1', query: 'hello' });
|
|
const pgvector = memory.getStatus().backends.find((backend) => backend.name === 'pgvector');
|
|
|
|
assert.equal(importedPg, false);
|
|
assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(pgvector.available, false);
|
|
assert.equal(pgvector.reason, 'not_configured');
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy memory' }]);
|
|
});
|
|
|
|
test('createMemoryV2Runtime does not open pg pool when embedding module is missing', async () => {
|
|
let importedPg = false;
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService(),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
|
|
},
|
|
importPg() {
|
|
importedPg = true;
|
|
throw new Error('should not import pg without embedding module');
|
|
},
|
|
});
|
|
|
|
const pgvector = memory.getStatus().backends.find((backend) => backend.name === 'pgvector');
|
|
|
|
assert.equal(importedPg, false);
|
|
assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(pgvector.available, false);
|
|
assert.equal(pgvector.reason, 'embedding_module_not_configured');
|
|
});
|
|
|
|
test('createMemoryV2Runtime ensures candidate schema before enabling MySQL persistence', async () => {
|
|
const calls = [];
|
|
const mysqlPool = {
|
|
async query(sql, params = []) {
|
|
calls.push({ sql, params });
|
|
if (sql.startsWith('CREATE TABLE')) return [[], []];
|
|
if (sql.startsWith('INSERT')) return [{ affectedRows: 1 }, []];
|
|
throw new Error(`Unexpected SQL: ${sql}`);
|
|
},
|
|
};
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService(),
|
|
mysqlPool,
|
|
env: {
|
|
MEMORY_CANDIDATE_ENABLED: '1',
|
|
MEMORY_CANDIDATE_MODE: 'shadow',
|
|
MEMORY_CANDIDATE_PERSISTENCE_ENABLED: '1',
|
|
},
|
|
});
|
|
|
|
assert.equal(memory.getStatus().personalMemory.persistence, 'mysql');
|
|
assert.match(calls[0].sql, /CREATE TABLE IF NOT EXISTS `h5_memory_v2_candidates`/);
|
|
const observed = await memory.observePersonalMemory({
|
|
userId: 'u-1',
|
|
sessionId: 's-1',
|
|
messages: [{ role: 'user', content: '请记住我偏好使用简洁的中文回答' }],
|
|
});
|
|
assert.equal(observed.accepted, 1);
|
|
assert.equal(calls[1].sql.startsWith('INSERT'), true);
|
|
await memory.close();
|
|
});
|
|
|
|
test('createMemoryV2Runtime fails open when candidate schema initialization fails', async () => {
|
|
const logger = silentLogger();
|
|
const memory = await createMemoryV2Runtime({
|
|
logger,
|
|
legacyMemoryService: legacyService(),
|
|
mysqlPool: { async query() { throw new Error('ddl denied'); } },
|
|
env: {
|
|
MEMORY_CANDIDATE_ENABLED: '1',
|
|
MEMORY_CANDIDATE_MODE: 'shadow',
|
|
MEMORY_CANDIDATE_PERSISTENCE_ENABLED: '1',
|
|
},
|
|
});
|
|
|
|
assert.equal(memory.getStatus().personalMemory.persistence, 'bounded-memory');
|
|
assert.match(logger.warnings[0], /candidate persistence unavailable: ddl denied/);
|
|
await memory.close();
|
|
});
|
|
|
|
test('createMemoryV2Runtime syncs a successful user memory write into pgvector before returning', async () => {
|
|
const mysqlCalls = [];
|
|
const pgCalls = [];
|
|
class FakePool {
|
|
async query(sql, params) {
|
|
pgCalls.push({ sql, params });
|
|
return { rows: [] };
|
|
}
|
|
async end() {}
|
|
}
|
|
const mysqlPool = {
|
|
async query(sql, params) {
|
|
mysqlCalls.push({ sql, params });
|
|
if (sql.includes('FROM h5_user_memory_items')) {
|
|
return [[{
|
|
id: 'mem-new',
|
|
user_id: 'u-1',
|
|
label: 'fact',
|
|
memory_text: '灰度代号 MEM-NEW',
|
|
evidence_message_id: 'msg-1',
|
|
source_session_id: 's-new',
|
|
confidence: 0.9,
|
|
status: 'active',
|
|
created_at: 100,
|
|
updated_at: 200,
|
|
}]];
|
|
}
|
|
throw new Error(`Unexpected MySQL query: ${sql}`);
|
|
},
|
|
};
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
mysqlPool,
|
|
legacyMemoryService: legacyService(),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_EVENT_LOG_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
|
|
MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs',
|
|
},
|
|
async importPg() { return { Pool: FakePool }; },
|
|
async importModule() { return { embedQuery: async () => [0.1, 0.2] }; },
|
|
});
|
|
|
|
const result = await memory.write({ userId: 'u-1', sessionId: 's-new', messages: [] });
|
|
assert.equal(result.memories, 1);
|
|
assert.match(mysqlCalls[0].sql, /user_id = \?/);
|
|
assert.match(mysqlCalls[0].sql, /source_session_id = \?/);
|
|
assert.deepEqual(mysqlCalls[0].params, ['u-1', 's-new', 50]);
|
|
const insert = pgCalls.find((call) => call.sql.includes('INSERT INTO "memory_embeddings"'));
|
|
assert.ok(insert);
|
|
assert.equal(insert.params[1], '灰度代号 MEM-NEW');
|
|
await memory.close();
|
|
});
|
|
|
|
test('createMemoryV2Runtime retries scoped pgvector sync after a deduplicated memory write', async () => {
|
|
const pgCalls = [];
|
|
class FakePool {
|
|
async query(sql, params) {
|
|
pgCalls.push({ sql, params });
|
|
return { rows: [] };
|
|
}
|
|
async end() {}
|
|
}
|
|
const mysqlPool = {
|
|
async query(sql) {
|
|
if (sql.includes('FROM h5_user_memory_items')) {
|
|
return [[{
|
|
id: 'mem-existing',
|
|
user_id: 'u-1',
|
|
label: 'fact',
|
|
memory_text: '需要重试同步的既有记忆',
|
|
source_session_id: 's-existing',
|
|
confidence: 0.9,
|
|
created_at: 100,
|
|
updated_at: 200,
|
|
}]];
|
|
}
|
|
throw new Error(`Unexpected MySQL query: ${sql}`);
|
|
},
|
|
};
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
mysqlPool,
|
|
legacyMemoryService: {
|
|
...legacyService(),
|
|
async saveAndAnalyze() {
|
|
return { saved: 0, analyzed: 0, memories: 0 };
|
|
},
|
|
},
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
|
|
MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs',
|
|
},
|
|
async importPg() { return { Pool: FakePool }; },
|
|
async importModule() { return { embedQuery: async () => [0.1, 0.2] }; },
|
|
});
|
|
|
|
const result = await memory.write({ userId: 'u-1', sessionId: 's-existing', messages: [] });
|
|
assert.equal(result.memories, 0);
|
|
const insert = pgCalls.find((call) => call.sql.includes('INSERT INTO "memory_embeddings"'));
|
|
assert.ok(insert);
|
|
assert.equal(insert.params[1], '需要重试同步的既有记忆');
|
|
await memory.close();
|
|
});
|
|
|
|
test('createMemoryV2Runtime syncs promoted canary candidates into pgvector', async () => {
|
|
const pgCalls = [];
|
|
class FakePool {
|
|
async query(sql, params) {
|
|
pgCalls.push({ sql, params });
|
|
return { rows: [] };
|
|
}
|
|
async end() {}
|
|
}
|
|
const mysqlPool = {
|
|
async query(sql) {
|
|
if (sql.startsWith('SELECT * FROM h5_memory_v2_candidates')) {
|
|
return [[{
|
|
user_id: 'u-1', memory_type: 'episodic', content: '候选灰度代号',
|
|
session_id: 's-1', confidence: 0.9, evidence_json: '{}', created_at: 100,
|
|
}]];
|
|
}
|
|
if (sql.startsWith('INSERT IGNORE INTO h5_user_memory_items')) return [{ affectedRows: 1 }];
|
|
if (sql.includes('FROM h5_user_memory_items')) {
|
|
return [[{
|
|
id: 'mem-promoted', user_id: 'u-1', label: 'episodic', memory_text: '候选灰度代号',
|
|
source_session_id: 's-1', confidence: 0.9, status: 'active', created_at: 100, updated_at: 200,
|
|
}]];
|
|
}
|
|
throw new Error(`Unexpected MySQL query: ${sql}`);
|
|
},
|
|
};
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
mysqlPool,
|
|
legacyMemoryService: legacyService(),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
|
|
MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs',
|
|
MEMORY_LIFECYCLE_ENABLED: '1',
|
|
MEMORY_PROMOTION_ENABLED: '1',
|
|
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'canary',
|
|
MEMORY_LIFECYCLE_ROLLOUT_USER_IDS: 'u-1',
|
|
},
|
|
async importPg() { return { Pool: FakePool }; },
|
|
async importModule() { return { embedQuery: async () => [0.3, 0.4] }; },
|
|
});
|
|
|
|
const result = await memory.lifecycle.promote({ userId: 'u-1' });
|
|
assert.equal(result.promoted, 1);
|
|
assert.deepEqual(result.promotedUserIds, ['u-1']);
|
|
const insert = pgCalls.find((call) => call.sql.includes('INSERT INTO "memory_embeddings"'));
|
|
assert.ok(insert);
|
|
assert.equal(insert.params[1], '候选灰度代号');
|
|
await memory.close();
|
|
});
|
|
|
|
test('createMemoryV2Runtime selects pgvector only when pool and embedding are configured', async () => {
|
|
const queries = [];
|
|
let poolEnded = false;
|
|
class FakePool {
|
|
constructor(options) {
|
|
this.options = options;
|
|
}
|
|
|
|
async query(sql, params) {
|
|
queries.push({ sql, params, options: this.options });
|
|
return {
|
|
rows: [
|
|
{
|
|
id: 1,
|
|
type: 'fact',
|
|
content: 'semantic memory',
|
|
score: 0.91,
|
|
created_at: '2026-07-02T00:00:00.000Z',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
async end() {
|
|
poolEnded = true;
|
|
}
|
|
}
|
|
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy memory' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
|
|
MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs',
|
|
MEMORY_PGVECTOR_POOL_MAX: '2',
|
|
},
|
|
async importPg(specifier) {
|
|
assert.equal(specifier, 'pg');
|
|
return { Pool: FakePool };
|
|
},
|
|
async importModule(specifier) {
|
|
assert.match(specifier, /fake-embed\.mjs$/);
|
|
return {
|
|
async embedQuery(query) {
|
|
assert.equal(query, 'memory-chain');
|
|
return [0.1, 0.2, 0.3];
|
|
},
|
|
};
|
|
},
|
|
async fetchImpl(url, options) {
|
|
assert.equal(url, 'http://127.0.0.1:6333/collections/memind_memory/points/search');
|
|
assert.equal(options.method, 'POST');
|
|
assert.deepEqual(JSON.parse(options.body), {
|
|
vector: [0.1, 0.2, 0.3],
|
|
limit: 4,
|
|
with_payload: true,
|
|
filter: {
|
|
must: [{
|
|
key: 'user_id',
|
|
match: { value: 'u1' },
|
|
}],
|
|
},
|
|
});
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
result: [{
|
|
id: 'q1',
|
|
score: 0.88,
|
|
payload: {
|
|
content: 'qdrant memory',
|
|
type: 'fact',
|
|
},
|
|
}],
|
|
};
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
|
|
assert.equal(memory.getStatus().selectedBackend, 'pgvector');
|
|
assert.equal(result.source, 'pgvector');
|
|
assert.deepEqual(result.semanticMemories, ['semantic memory']);
|
|
assert.equal(queries.length, 1);
|
|
assert.equal(queries[0].options.connectionString, 'postgresql://local/memory');
|
|
assert.equal(queries[0].options.max, 2);
|
|
assert.match(queries[0].sql, /recent_candidates/);
|
|
assert.deepEqual(queries[0].params, ['u1', '[0.1,0.2,0.3]', 50]);
|
|
|
|
await memory.close();
|
|
assert.equal(poolEnded, true);
|
|
});
|
|
|
|
test('createMemoryV2Runtime falls back to legacy when pgvector init fails', async () => {
|
|
const logger = silentLogger();
|
|
const memory = await createMemoryV2Runtime({
|
|
logger,
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy fallback' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
|
|
MEMORY_PGVECTOR_EMBEDDING_MODULE: './bad-embed.mjs',
|
|
},
|
|
async importModule() {
|
|
return {};
|
|
},
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1', query: 'hello' });
|
|
const pgvector = memory.getStatus().backends.find((backend) => backend.name === 'pgvector');
|
|
|
|
assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(pgvector.available, false);
|
|
assert.equal(pgvector.reason, 'init_failed');
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy fallback' }]);
|
|
assert.match(logger.warnings[0], /pgvector backend unavailable/);
|
|
});
|
|
|
|
test('createMemoryV2Runtime replaces qdrant placeholder without network wiring', async () => {
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy qdrant fallback' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'qdrant',
|
|
MEMORY_QDRANT_ENABLED: '1',
|
|
MEMORY_QDRANT_URL: 'http://127.0.0.1:6333',
|
|
MEMORY_QDRANT_COLLECTION: 'memind_memory',
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const qdrant = status.backends.find((backend) => backend.name === 'qdrant');
|
|
const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
|
|
assert.equal(status.selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(qdrant.available, false);
|
|
assert.equal(qdrant.reason, 'client_not_configured');
|
|
assert.equal(qdrant.category, 'semantic');
|
|
assert.equal(qdrant.flag, 'MEMORY_QDRANT_ENABLED');
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy qdrant fallback' }]);
|
|
});
|
|
|
|
test('createMemoryV2Runtime can select qdrant read-only client when embedding module is configured', async () => {
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy qdrant fallback' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'qdrant',
|
|
MEMORY_QDRANT_ENABLED: '1',
|
|
MEMORY_QDRANT_URL: 'http://127.0.0.1:6333',
|
|
MEMORY_QDRANT_COLLECTION: 'memind_memory',
|
|
MEMORY_QDRANT_EMBEDDING_MODULE: './qdrant-embed.mjs',
|
|
MEMORY_QDRANT_LIMIT: '4',
|
|
},
|
|
async importModule(specifier) {
|
|
assert.match(specifier, /qdrant-embed\.mjs$/);
|
|
return {
|
|
async embedQuery(query) {
|
|
assert.equal(query, 'memory-chain');
|
|
return [0.1, 0.2, 0.3];
|
|
},
|
|
};
|
|
},
|
|
async fetchImpl(url, options) {
|
|
assert.equal(url, 'http://127.0.0.1:6333/collections/memind_memory/points/search');
|
|
assert.equal(options.method, 'POST');
|
|
assert.deepEqual(JSON.parse(options.body), {
|
|
vector: [0.1, 0.2, 0.3],
|
|
limit: 4,
|
|
with_payload: true,
|
|
filter: {
|
|
must: [{
|
|
key: 'user_id',
|
|
match: { value: 'u1' },
|
|
}],
|
|
},
|
|
});
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
result: [{
|
|
id: 'q1',
|
|
score: 0.88,
|
|
payload: {
|
|
content: 'qdrant memory',
|
|
type: 'fact',
|
|
},
|
|
}],
|
|
};
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const qdrant = memory.getStatus().backends.find((backend) => backend.name === 'qdrant');
|
|
const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
|
|
assert.equal(memory.getStatus().selectedBackend, 'qdrant');
|
|
assert.equal(qdrant.available, true);
|
|
assert.equal(result.source, 'qdrant');
|
|
assert.deepEqual(result.memories, [{
|
|
id: 'q1',
|
|
label: 'fact',
|
|
text: 'qdrant memory',
|
|
score: 0.88,
|
|
createdAt: null,
|
|
}]);
|
|
});
|
|
|
|
test('createManagedMemoryV2Runtime refreshes backend selection from admin config state', async () => {
|
|
let state = {
|
|
fingerprint: 'v1',
|
|
overrides: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'legacy',
|
|
},
|
|
};
|
|
const memory = await createManagedMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy managed' }]),
|
|
configService: {
|
|
async getRuntimeState() {
|
|
return state;
|
|
},
|
|
},
|
|
async importModule(specifier) {
|
|
assert.match(specifier, /managed-embed\.mjs$/);
|
|
return {
|
|
async embedQuery() {
|
|
return [0.1, 0.2, 0.3];
|
|
},
|
|
};
|
|
},
|
|
async importPg() {
|
|
return {
|
|
Pool: class FakePool {
|
|
async query() {
|
|
return {
|
|
rows: [{
|
|
id: 1,
|
|
type: 'fact',
|
|
content: 'pgvector managed',
|
|
score: 0.9,
|
|
created_at: '2026-07-02T00:00:00.000Z',
|
|
}],
|
|
};
|
|
}
|
|
|
|
async end() {}
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const legacyResult = await memory.resolve({ userId: 'u1', query: 'hello' });
|
|
assert.equal(legacyResult.source, 'legacy-conversation-memory');
|
|
|
|
state = {
|
|
fingerprint: 'v2',
|
|
overrides: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_PGVECTOR_ENABLED: '1',
|
|
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
|
|
MEMORY_PGVECTOR_EMBEDDING_MODULE: './managed-embed.mjs',
|
|
},
|
|
};
|
|
|
|
const pgvectorResult = await memory.resolve({ userId: 'u1', query: 'hello' });
|
|
const status = await memory.getStatus();
|
|
|
|
assert.equal(pgvectorResult.source, 'pgvector');
|
|
assert.equal(status.selectedBackend, 'pgvector');
|
|
assert.equal(status.configSource, 'admin-db');
|
|
});
|
|
|
|
test('createMemoryV2Runtime reports qdrant init failure without blocking memory', async () => {
|
|
const logger = silentLogger();
|
|
const memory = await createMemoryV2Runtime({
|
|
logger,
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy after qdrant error' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'qdrant',
|
|
MEMORY_QDRANT_ENABLED: '1',
|
|
MEMORY_QDRANT_URL: 'ftp://bad-qdrant',
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const qdrant = status.backends.find((backend) => backend.name === 'qdrant');
|
|
const result = await memory.resolve({ userId: 'u1' });
|
|
|
|
assert.equal(status.selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(qdrant.available, false);
|
|
assert.equal(qdrant.reason, 'init_failed');
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy after qdrant error' }]);
|
|
assert.match(logger.warnings[0], /qdrant backend unavailable/);
|
|
});
|
|
|
|
test('createMemoryV2Runtime keeps mem0 as fallback when api key is incomplete', async () => {
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy resolve with mem0' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'mem0',
|
|
MEMORY_MEM0_ENABLED: '1',
|
|
MEMORY_MEM0_PROJECT_ID: 'memind_project',
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const mem0 = status.backends.find((backend) => backend.name === 'mem0');
|
|
const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] });
|
|
const compacted = await memory.compact({ userId: 'u1' });
|
|
|
|
assert.equal(status.selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(mem0.available, false);
|
|
assert.equal(mem0.reason, 'api_key_not_configured');
|
|
assert.equal(mem0.category, 'extraction');
|
|
assert.equal(mem0.flag, 'MEMORY_MEM0_ENABLED');
|
|
assert.deepEqual(resolved.memories, [{ label: 'fact', text: 'legacy resolve with mem0' }]);
|
|
assert.equal(written.source, 'legacy-conversation-memory');
|
|
assert.equal(compacted.source, 'legacy-conversation-memory');
|
|
});
|
|
|
|
test('createMemoryV2Runtime can write and compact through Mem0 HTTP client', async () => {
|
|
const requests = [];
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy resolve with mem0' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'mem0',
|
|
MEMORY_MEM0_ENABLED: '1',
|
|
MEMORY_MEM0_API_KEY: 'secret',
|
|
MEMORY_MEM0_PROJECT_ID: 'memind_project',
|
|
MEMORY_MEM0_BASE_URL: 'https://mem0.local',
|
|
},
|
|
async fetchImpl(url, options) {
|
|
requests.push({ url, body: JSON.parse(options.body) });
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { saved: 1, analyzed: 1, memories: 1 };
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [{ text: 'hi' }] });
|
|
const compacted = await memory.compact({ userId: 'u1' });
|
|
|
|
assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory');
|
|
assert.deepEqual(resolved.memories, [{ label: 'fact', text: 'legacy resolve with mem0' }]);
|
|
assert.equal(written.source, 'mem0');
|
|
assert.equal(compacted.source, 'mem0');
|
|
assert.deepEqual(requests.map((request) => request.url), [
|
|
'https://mem0.local/v1/memories',
|
|
'https://mem0.local/v1/memories/compact',
|
|
]);
|
|
});
|
|
|
|
test('createMemoryV2Runtime reports mem0 init failure without blocking memory', async () => {
|
|
const logger = silentLogger();
|
|
const memory = await createMemoryV2Runtime({
|
|
logger,
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy after mem0 error' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'mem0',
|
|
MEMORY_MEM0_ENABLED: '1',
|
|
MEMORY_MEM0_API_KEY: 'secret',
|
|
MEMORY_MEM0_PROJECT_ID: 'bad project id',
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const mem0 = status.backends.find((backend) => backend.name === 'mem0');
|
|
const result = await memory.resolve({ userId: 'u1' });
|
|
|
|
assert.equal(status.selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(mem0.available, false);
|
|
assert.equal(mem0.reason, 'init_failed');
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy after mem0 error' }]);
|
|
assert.match(logger.warnings[0], /mem0 backend unavailable/);
|
|
});
|
|
|
|
test('createMemoryV2Runtime keeps letta as fallback when agent id is incomplete', async () => {
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy resolve with letta' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'letta',
|
|
MEMORY_LETTA_ENABLED: '1',
|
|
MEMORY_LETTA_API_KEY: 'secret',
|
|
MEMORY_LETTA_PROJECT_ID: 'memind_project',
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const letta = status.backends.find((backend) => backend.name === 'letta');
|
|
const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] });
|
|
const compacted = await memory.compact({ userId: 'u1' });
|
|
|
|
assert.equal(status.selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(letta.available, false);
|
|
assert.equal(letta.reason, 'client_not_configured');
|
|
assert.equal(letta.category, 'lifecycle');
|
|
assert.equal(letta.flag, 'MEMORY_LETTA_ENABLED');
|
|
assert.deepEqual(resolved.memories, [{ label: 'fact', text: 'legacy resolve with letta' }]);
|
|
assert.equal(written.source, 'legacy-conversation-memory');
|
|
assert.equal(compacted.source, 'legacy-conversation-memory');
|
|
});
|
|
|
|
test('createMemoryV2Runtime can select letta with default HTTP client', async () => {
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy letta fallback' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'letta',
|
|
MEMORY_LETTA_ENABLED: '1',
|
|
MEMORY_LETTA_API_KEY: 'secret',
|
|
MEMORY_LETTA_PROJECT_ID: 'memind_project',
|
|
MEMORY_LETTA_AGENT_ID: 'agent_1',
|
|
MEMORY_LETTA_BASE_URL: 'https://letta.local',
|
|
},
|
|
async fetchImpl(url, options) {
|
|
assert.match(url, /^https:\/\/letta\.local\/v1\/agents\/agent_1\//);
|
|
assert.equal(options.headers.authorization, 'Bearer secret');
|
|
if (url.endsWith('/memory')) {
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { memories: ['letta lifecycle memory'], activeGoals: ['memory-v2'] };
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { saved: 1, analyzed: 1, memories: 1 };
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const letta = status.backends.find((backend) => backend.name === 'letta');
|
|
const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] });
|
|
const compacted = await memory.compact({ userId: 'u1' });
|
|
|
|
assert.equal(status.selectedBackend, 'letta');
|
|
assert.equal(letta.available, true);
|
|
assert.equal(resolved.source, 'letta');
|
|
assert.deepEqual(resolved.memories, [{ label: 'lifecycle', text: 'letta lifecycle memory' }]);
|
|
assert.deepEqual(resolved.activeGoals, ['memory-v2']);
|
|
assert.equal(written.source, 'letta');
|
|
assert.equal(compacted.source, 'letta');
|
|
});
|
|
|
|
test('createMemoryV2Runtime reports letta init failure without blocking memory', async () => {
|
|
const logger = silentLogger();
|
|
const memory = await createMemoryV2Runtime({
|
|
logger,
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy after letta error' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'letta',
|
|
MEMORY_LETTA_ENABLED: '1',
|
|
MEMORY_LETTA_API_KEY: 'secret',
|
|
MEMORY_LETTA_AGENT_ID: 'bad agent id',
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const letta = status.backends.find((backend) => backend.name === 'letta');
|
|
const result = await memory.resolve({ userId: 'u1' });
|
|
|
|
assert.equal(status.selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(letta.available, false);
|
|
assert.equal(letta.reason, 'init_failed');
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy after letta error' }]);
|
|
assert.match(logger.warnings[0], /letta backend unavailable/);
|
|
});
|
|
|
|
test('createMemoryV2Runtime wires remaining external adapters as safe fallbacks', async () => {
|
|
const memory = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy external fallback' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'weaviate',
|
|
MEMORY_WEAVIATE_ENABLED: '1',
|
|
MEMORY_WEAVIATE_URL: 'https://weaviate.local',
|
|
MEMORY_NEO4J_ENABLED: '1',
|
|
MEMORY_NEO4J_URI: 'neo4j://localhost:7687',
|
|
MEMORY_NEO4J_USER: 'neo4j',
|
|
MEMORY_NEO4J_PASSWORD: 'secret',
|
|
MEMORY_REDIS_STREAMS_ENABLED: '1',
|
|
MEMORY_LANGGRAPH_ENABLED: '1',
|
|
MEMORY_LANGGRAPH_POLICY_ID: 'memory_policy',
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const byName = new Map(status.backends.map((backend) => [backend.name, backend]));
|
|
const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] });
|
|
|
|
assert.equal(status.selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(byName.get('weaviate').reason, 'client_not_configured');
|
|
assert.equal(byName.get('neo4j').reason, 'client_not_configured');
|
|
assert.equal(byName.get('redis-streams').reason, 'url_not_configured');
|
|
assert.equal(byName.get('langgraph').reason, 'client_not_configured');
|
|
assert.deepEqual(resolved.memories, [{ label: 'fact', text: 'legacy external fallback' }]);
|
|
assert.equal(written.source, 'legacy-conversation-memory');
|
|
});
|
|
|
|
test('createMemoryV2Runtime can select external adapters with built-in HTTP clients', async () => {
|
|
const weaviate = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy weaviate fallback' }]),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'weaviate',
|
|
MEMORY_WEAVIATE_ENABLED: '1',
|
|
MEMORY_WEAVIATE_URL: 'https://weaviate.local',
|
|
MEMORY_WEAVIATE_EMBEDDING_MODULE: './weaviate-embed.mjs',
|
|
},
|
|
async importModule() {
|
|
return { async embedQuery() { return [0.1, 0.2]; } };
|
|
},
|
|
async fetchImpl(url, options) {
|
|
assert.equal(url, 'https://weaviate.local/v1/graphql');
|
|
assert.equal(options.method, 'POST');
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
data: {
|
|
Get: {
|
|
MemindMemory: [{
|
|
content: 'weaviate selected memory',
|
|
_additional: { id: 'w1', certainty: 0.9 },
|
|
}],
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
},
|
|
});
|
|
assert.equal(weaviate.getStatus().selectedBackend, 'weaviate');
|
|
assert.equal((await weaviate.resolve({ userId: 'u1', query: 'memory-chain' })).source, 'weaviate');
|
|
|
|
const langGraph = await createMemoryV2Runtime({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: legacyService(),
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'langgraph',
|
|
MEMORY_LANGGRAPH_ENABLED: '1',
|
|
MEMORY_LANGGRAPH_POLICY_ID: 'memory_policy',
|
|
MEMORY_LANGGRAPH_URL: 'https://langgraph.local',
|
|
},
|
|
async fetchImpl(url, options) {
|
|
assert.equal(url, 'https://langgraph.local/memory/resolve');
|
|
assert.equal(options.method, 'POST');
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { activeGoals: ['route-memory'] };
|
|
},
|
|
};
|
|
},
|
|
});
|
|
assert.equal(langGraph.getStatus().selectedBackend, 'langgraph');
|
|
assert.deepEqual((await langGraph.resolve({ userId: 'u1' })).activeGoals, ['route-memory']);
|
|
});
|