490 lines
14 KiB
JavaScript
490 lines
14 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
createLegacyMemoryBackend,
|
|
createMemoryV2,
|
|
resolveMemoryV2Policy,
|
|
} from './memory-v2.mjs';
|
|
|
|
function silentLogger() {
|
|
return { warn() {} };
|
|
}
|
|
|
|
test('resolveMemoryV2Policy uses legacy memory flag for backward compatibility', () => {
|
|
assert.equal(resolveMemoryV2Policy({ env: {} }).enabled, true);
|
|
assert.equal(
|
|
resolveMemoryV2Policy({
|
|
env: { USER_CONVERSATION_MEMORY_ENABLED: '0' },
|
|
}).enabled,
|
|
false,
|
|
);
|
|
assert.equal(
|
|
resolveMemoryV2Policy({
|
|
env: {
|
|
USER_CONVERSATION_MEMORY_ENABLED: '0',
|
|
MEMORY_ENABLED: 'true',
|
|
},
|
|
}).enabled,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
resolveMemoryV2Policy({
|
|
env: { MEMORY_VECTOR_ENABLED: '1' },
|
|
}).vectorEnabled,
|
|
true,
|
|
);
|
|
const runtimePolicy = resolveMemoryV2Policy({ env: {} });
|
|
assert.equal(runtimePolicy.agentResolveEnabled, false);
|
|
assert.equal(runtimePolicy.agentInjectionMode, 'off');
|
|
assert.deepEqual(runtimePolicy.agentCanaryUserIds, []);
|
|
assert.equal(runtimePolicy.agentResolveLimit, 3);
|
|
assert.equal(runtimePolicy.promotionEnabled, false);
|
|
});
|
|
|
|
test('legacy backend adapts existing conversation memory service', async () => {
|
|
const calls = [];
|
|
const backend = createLegacyMemoryBackend({
|
|
async listMemories(userId, options) {
|
|
calls.push(['list', userId, options]);
|
|
return [{ id: 'm1', label: 'preference', text: '用户喜欢简洁回答' }];
|
|
},
|
|
async saveAndAnalyze(sessionId, userId, messages) {
|
|
calls.push(['write', sessionId, userId, messages.length]);
|
|
return { saved: messages.length, analyzed: 1, memories: 1 };
|
|
},
|
|
async analyzeUser(userId) {
|
|
calls.push(['compact', userId]);
|
|
return { analyzed: 2, memories: 1 };
|
|
},
|
|
});
|
|
|
|
const resolved = await backend.resolve({ userId: 'u1', limit: 3 });
|
|
const written = await backend.write({
|
|
userId: 'u1',
|
|
sessionId: 's1',
|
|
messages: [{ role: 'user', content: [] }],
|
|
});
|
|
const compacted = await backend.compact({ userId: 'u1' });
|
|
|
|
assert.deepEqual(resolved.memories, [
|
|
{ id: 'm1', label: 'preference', text: '用户喜欢简洁回答' },
|
|
]);
|
|
assert.deepEqual(written, { saved: 1, analyzed: 1, memories: 1 });
|
|
assert.deepEqual(compacted, { analyzed: 2, memories: 1 });
|
|
assert.deepEqual(calls, [
|
|
['list', 'u1', { limit: 3 }],
|
|
['write', 's1', 'u1', 1],
|
|
['compact', 'u1'],
|
|
]);
|
|
});
|
|
|
|
test('Memory V2 resolve returns unified payload from legacy backend', async () => {
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: {
|
|
async listMemories() {
|
|
return [{ id: 'm1', label: 'fact', text: '用户常做基础设施排障' }];
|
|
},
|
|
},
|
|
env: { MEMORY_ENABLED: '1' },
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1', query: '帮我看 103' });
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.enabled, true);
|
|
assert.equal(result.source, 'legacy-conversation-memory');
|
|
assert.deepEqual(result.memories, [
|
|
{ id: 'm1', label: 'fact', text: '用户常做基础设施排障' },
|
|
]);
|
|
assert.deepEqual(result.semanticMemories, result.memories);
|
|
});
|
|
|
|
test('Memory V2 feature flags can disable reads and writes without touching legacy service', async () => {
|
|
let touched = false;
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: {
|
|
async listMemories() {
|
|
touched = true;
|
|
return [];
|
|
},
|
|
async saveAndAnalyze() {
|
|
touched = true;
|
|
return { saved: 1, analyzed: 1, memories: 1 };
|
|
},
|
|
},
|
|
env: { MEMORY_ENABLED: '0' },
|
|
});
|
|
|
|
assert.deepEqual(await memory.resolve({ userId: 'u1' }), {
|
|
ok: true,
|
|
enabled: false,
|
|
skipped: true,
|
|
reason: 'disabled',
|
|
degraded: false,
|
|
source: null,
|
|
profile: null,
|
|
semanticMemories: [],
|
|
behaviorSummary: null,
|
|
activeGoals: [],
|
|
contextGoals: [],
|
|
memories: [],
|
|
});
|
|
assert.deepEqual(await memory.write({ userId: 'u1', sessionId: 's1' }), {
|
|
ok: true,
|
|
enabled: false,
|
|
skipped: true,
|
|
reason: 'disabled',
|
|
source: null,
|
|
saved: 0,
|
|
analyzed: 0,
|
|
memories: 0,
|
|
});
|
|
assert.equal(touched, false);
|
|
});
|
|
|
|
test('Memory V2 fails open when backend resolve fails', async () => {
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
backends: [
|
|
{
|
|
name: 'broken-backend',
|
|
async resolve() {
|
|
throw new Error('backend unavailable');
|
|
},
|
|
},
|
|
],
|
|
env: { MEMORY_ENABLED: '1' },
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1' });
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.enabled, true);
|
|
assert.equal(result.degraded, true);
|
|
assert.equal(result.reason, 'backend_failure');
|
|
assert.deepEqual(result.memories, []);
|
|
});
|
|
|
|
test('Memory V2 write honors event log flag', async () => {
|
|
let touched = false;
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
legacyMemoryService: {
|
|
async saveAndAnalyze() {
|
|
touched = true;
|
|
return { saved: 1, analyzed: 1, memories: 1 };
|
|
},
|
|
},
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_EVENT_LOG_ENABLED: '0',
|
|
},
|
|
});
|
|
|
|
const result = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] });
|
|
|
|
assert.equal(result.skipped, true);
|
|
assert.equal(result.reason, 'event_log_disabled');
|
|
assert.equal(touched, false);
|
|
});
|
|
|
|
test('Memory V2 selects configured pluggable backend when available', async () => {
|
|
const calls = [];
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
backends: [
|
|
{
|
|
name: 'legacy-conversation-memory',
|
|
async resolve() {
|
|
calls.push('legacy');
|
|
return { memories: [{ text: 'legacy' }] };
|
|
},
|
|
},
|
|
{
|
|
name: 'pgvector',
|
|
async resolve() {
|
|
calls.push('pgvector');
|
|
return {
|
|
profile: { responseStyle: 'concise' },
|
|
semanticMemories: ['用户关注 memory facade'],
|
|
memories: [{ label: 'fact', text: 'vector result' }],
|
|
};
|
|
},
|
|
},
|
|
],
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_PROFILE_ENABLED: '1',
|
|
},
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
|
|
assert.deepEqual(calls, ['pgvector']);
|
|
assert.equal(result.source, 'pgvector');
|
|
assert.deepEqual(result.profile, { responseStyle: 'concise' });
|
|
assert.deepEqual(result.semanticMemories, ['用户关注 memory facade']);
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'vector result' }]);
|
|
});
|
|
|
|
test('Memory V2 normalizes external resolve payloads into tool-safe context fields', async () => {
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
backends: [
|
|
{
|
|
name: 'langgraph',
|
|
async resolve() {
|
|
return {
|
|
tool: 'browser',
|
|
action: 'download',
|
|
url: 'https://example.invalid',
|
|
memories: [
|
|
{
|
|
id: 'm1',
|
|
label: 'fact',
|
|
text: 'user prefers cautious memory rollouts',
|
|
tool: 'download',
|
|
functionCall: { name: 'bad' },
|
|
url: 'https://example.invalid',
|
|
filePath: '/tmp/bad',
|
|
arguments: { value: 1 },
|
|
score: '0.7',
|
|
created_at: '2026-07-03T00:00:00Z',
|
|
},
|
|
],
|
|
semanticMemories: [
|
|
{ text: 'semantic safe', toolName: 'bad_tool' },
|
|
{ action: 'call_tool', arguments: {} },
|
|
'plain semantic',
|
|
],
|
|
behaviorSummary: 'Use memory as context only.',
|
|
activeGoals: [
|
|
{ text: 'preserve execution boundary', tool: 'bad' },
|
|
{ command: 'rm -rf /', arguments: [] },
|
|
'keep memory in context domain',
|
|
],
|
|
};
|
|
},
|
|
},
|
|
],
|
|
env: { MEMORY_ENABLED: '1', MEMORY_BACKEND: 'langgraph' },
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' });
|
|
|
|
assert.equal(result.source, 'langgraph');
|
|
assert.deepEqual(result.memories, [
|
|
{
|
|
id: 'm1',
|
|
label: 'fact',
|
|
text: 'user prefers cautious memory rollouts',
|
|
createdAt: '2026-07-03T00:00:00Z',
|
|
score: 0.7,
|
|
},
|
|
]);
|
|
assert.deepEqual(result.semanticMemories, ['semantic safe', 'plain semantic']);
|
|
assert.equal(result.behaviorSummary, 'Use memory as context only.');
|
|
assert.deepEqual(result.contextGoals, [
|
|
'preserve execution boundary',
|
|
'keep memory in context domain',
|
|
]);
|
|
assert.deepEqual(result.activeGoals, result.contextGoals);
|
|
assert.equal(result.tool, undefined);
|
|
assert.equal(result.action, undefined);
|
|
assert.equal(result.url, undefined);
|
|
assert.equal(result.memories[0].tool, undefined);
|
|
assert.equal(result.memories[0].functionCall, undefined);
|
|
assert.equal(result.memories[0].url, undefined);
|
|
assert.equal(result.memories[0].filePath, undefined);
|
|
assert.equal(result.memories[0].arguments, undefined);
|
|
});
|
|
|
|
test('Memory V2 falls back to first available backend when preferred backend is unavailable', async () => {
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
backends: [
|
|
{
|
|
name: 'pgvector',
|
|
isAvailable() {
|
|
return false;
|
|
},
|
|
async resolve() {
|
|
throw new Error('should not use unavailable backend');
|
|
},
|
|
},
|
|
{
|
|
name: 'legacy-conversation-memory',
|
|
async resolve() {
|
|
return { memories: [{ label: 'fact', text: 'legacy fallback' }] };
|
|
},
|
|
},
|
|
],
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
},
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1' });
|
|
|
|
assert.equal(result.source, 'legacy-conversation-memory');
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy fallback' }]);
|
|
});
|
|
|
|
test('Memory V2 selects backends per operation so semantic read does not block legacy writes', async () => {
|
|
const calls = [];
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
backends: [
|
|
{
|
|
name: 'pgvector',
|
|
isAvailable() {
|
|
return true;
|
|
},
|
|
async resolve() {
|
|
calls.push('pgvector.resolve');
|
|
return { memories: [{ label: 'semantic', text: 'vector read' }] };
|
|
},
|
|
},
|
|
{
|
|
name: 'legacy-conversation-memory',
|
|
async resolve() {
|
|
calls.push('legacy.resolve');
|
|
return { memories: [{ label: 'fact', text: 'legacy read' }] };
|
|
},
|
|
async write() {
|
|
calls.push('legacy.write');
|
|
return { saved: 1, analyzed: 1, memories: 1 };
|
|
},
|
|
async compact() {
|
|
calls.push('legacy.compact');
|
|
return { analyzed: 2, memories: 1 };
|
|
},
|
|
},
|
|
],
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_EVENT_LOG_ENABLED: '1',
|
|
},
|
|
});
|
|
|
|
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(resolved.source, 'pgvector');
|
|
assert.equal(written.source, 'legacy-conversation-memory');
|
|
assert.equal(compacted.source, 'legacy-conversation-memory');
|
|
assert.deepEqual(calls, ['pgvector.resolve', 'legacy.write', 'legacy.compact']);
|
|
});
|
|
|
|
test('Memory V2 can suppress profile payloads through policy', async () => {
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
backends: [
|
|
{
|
|
name: 'profile-backend',
|
|
async resolve() {
|
|
return {
|
|
profile: { responseStyle: 'detailed' },
|
|
memories: [{ label: 'preference', text: '用户喜欢完整回答' }],
|
|
};
|
|
},
|
|
},
|
|
],
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_PROFILE_ENABLED: '0',
|
|
},
|
|
});
|
|
|
|
const result = await memory.resolve({ userId: 'u1' });
|
|
|
|
assert.equal(result.profile, null);
|
|
assert.deepEqual(result.memories, [{ label: 'preference', text: '用户喜欢完整回答' }]);
|
|
});
|
|
|
|
test('Memory V2 getStatus exposes policy and backend contract details', () => {
|
|
const memory = createMemoryV2({
|
|
logger: silentLogger(),
|
|
backends: [
|
|
{
|
|
name: 'legacy-conversation-memory',
|
|
async resolve() {
|
|
return { memories: [] };
|
|
},
|
|
async write() {
|
|
return { saved: 0, analyzed: 0, memories: 0 };
|
|
},
|
|
async compact() {
|
|
return { analyzed: 0, memories: 0 };
|
|
},
|
|
},
|
|
{
|
|
name: 'pgvector',
|
|
isAvailable() {
|
|
return false;
|
|
},
|
|
async resolve() {
|
|
return { memories: [] };
|
|
},
|
|
},
|
|
],
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_PROFILE_ENABLED: '0',
|
|
MEMORY_EVENT_LOG_ENABLED: '1',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
MEMORY_FAIL_OPEN: '1',
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(memory.getStatus(), {
|
|
enabled: true,
|
|
backend: 'pgvector',
|
|
selectedBackend: 'legacy-conversation-memory',
|
|
profileEnabled: false,
|
|
eventLogEnabled: true,
|
|
vectorEnabled: true,
|
|
failOpen: true,
|
|
runtimeControl: {
|
|
agentResolveEnabled: false,
|
|
agentInjectionMode: 'off',
|
|
agentCanaryUserIds: [],
|
|
agentResolveLimit: 3,
|
|
agentResolveTimeoutMs: 1200,
|
|
promotionEnabled: false,
|
|
compactionV2Enabled: false,
|
|
reflectionEnabled: false,
|
|
lifecycleWorkerEnabled: false,
|
|
lifecycleRolloutMode: 'off',
|
|
lifecycleRolloutUserIds: [],
|
|
},
|
|
backends: [
|
|
{
|
|
name: 'legacy-conversation-memory',
|
|
available: true,
|
|
supports: {
|
|
resolve: true,
|
|
write: true,
|
|
compact: true,
|
|
},
|
|
},
|
|
{
|
|
name: 'pgvector',
|
|
available: false,
|
|
supports: {
|
|
resolve: true,
|
|
write: false,
|
|
compact: false,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|