267 lines
10 KiB
JavaScript
267 lines
10 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
buildEpisodicMemorySchemaSql,
|
|
buildEpisodicSnapshotDocument,
|
|
createEpisodicMemoryService,
|
|
extractEpisodicRecallTopic,
|
|
isEpisodicRecallQuery,
|
|
} from './episodic-memory.mjs';
|
|
import { createSessionSnapshotService } from './session-snapshot.mjs';
|
|
|
|
function visibleMessage(role, text, metadata = {}) {
|
|
return {
|
|
role,
|
|
content: [{ type: 'text', text }],
|
|
metadata: { userVisible: true, ...metadata },
|
|
};
|
|
}
|
|
|
|
test('episodic recall recognizes historical conversation questions and extracts the topic', () => {
|
|
assert.equal(isEpisodicRecallQuery('你还记得我们聊过德川家康吗?'), true);
|
|
assert.equal(extractEpisodicRecallTopic('你还记得我们聊过德川家康吗?'), '德川家康');
|
|
assert.equal(extractEpisodicRecallTopic('我们之前关于德川家康聊过吗?'), '德川家康');
|
|
assert.equal(extractEpisodicRecallTopic('我们上次聊过什么?'), '');
|
|
assert.equal(isEpisodicRecallQuery('上次我们聊了什么?'), true);
|
|
assert.equal(extractEpisodicRecallTopic('上次我们聊了什么?'), '');
|
|
assert.equal(isEpisodicRecallQuery('记住我喜欢简洁回答'), false);
|
|
});
|
|
|
|
test('episodic snapshot document keeps only bounded user-visible conversation text', () => {
|
|
const document = buildEpisodicSnapshotDocument({
|
|
sessionId: 'session-history',
|
|
userId: 'user-1',
|
|
session: {
|
|
display_title: '日本战国史',
|
|
updated_at: '2026-07-20T10:00:00.000Z',
|
|
},
|
|
messages: [
|
|
visibleMessage(
|
|
'user',
|
|
'【Memind 任务编排】这是内部路由\n用户任务:我们聊聊德川家康的生平',
|
|
),
|
|
visibleMessage('assistant', '德川家康建立了江户幕府。'),
|
|
visibleMessage('assistant', '我将调用 load_skill 处理内部流程。'),
|
|
visibleMessage('user', '这条不可见', { userVisible: false }),
|
|
{ role: 'tool', content: [{ type: 'text', text: 'secret tool output' }] },
|
|
],
|
|
});
|
|
|
|
assert.equal(document.sessionId, 'session-history');
|
|
assert.match(document.searchText, /我们聊聊德川家康的生平/);
|
|
assert.match(document.searchText, /建立了江户幕府/);
|
|
assert.doesNotMatch(document.searchText, /内部路由|load_skill|不可见|secret tool output/);
|
|
assert.equal(document.messageCount, 2);
|
|
});
|
|
|
|
test('episodic schema is additive and user scoped', () => {
|
|
const sql = buildEpisodicMemorySchemaSql();
|
|
assert.match(sql, /CREATE TABLE IF NOT EXISTS `h5_episodic_memory_items`/);
|
|
assert.match(sql, /user_id CHAR\(36\) NOT NULL/);
|
|
assert.match(sql, /idx_h5_episodic_user_updated/);
|
|
assert.match(sql, /ON DELETE CASCADE/);
|
|
});
|
|
|
|
test('episodic resolve searches the same user, excludes current session, and returns evidence', async () => {
|
|
const calls = [];
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
calls.push({ sql, params });
|
|
if (sql.includes('FROM `h5_episodic_memory_items`')) {
|
|
return [[{
|
|
agent_session_id: 'session-history',
|
|
user_id: 'user-1',
|
|
display_title: '日本战国史',
|
|
summary_text: '用户:德川家康有什么影响? 助手:他建立了江户幕府。',
|
|
search_text: '标题:日本战国史\n用户:德川家康有什么影响?\n助手:他建立了江户幕府。',
|
|
message_count: 2,
|
|
source_updated_at: '2026-07-20T10:00:00.000Z',
|
|
session_updated_at: Date.parse('2026-07-20T10:00:00.000Z'),
|
|
}]];
|
|
}
|
|
throw new Error(`unexpected query: ${sql}`);
|
|
},
|
|
};
|
|
const service = createEpisodicMemoryService(pool, {
|
|
env: {
|
|
MEMORY_RETRIEVER_EPISODIC_ENABLED: '1',
|
|
MEMORY_RETRIEVER_ENABLED: '1',
|
|
MEMORY_RETRIEVER_EPISODIC_MODE: 'active',
|
|
MEMORY_RETRIEVER_LIMIT: '3',
|
|
MEMORY_RETRIEVER_TIMEOUT_MS: '500',
|
|
},
|
|
});
|
|
|
|
const result = await service.resolve({
|
|
userId: 'user-1',
|
|
sessionId: 'session-current',
|
|
query: '你还记得我们聊过德川家康吗?',
|
|
});
|
|
|
|
assert.equal(result.skipped, false);
|
|
assert.equal(result.memories.length, 1);
|
|
assert.equal(result.memories[0].label, '历史会话');
|
|
assert.equal(result.memories[0].sessionId, 'session-history');
|
|
assert.deepEqual(result.memories[0].evidence.matchedTerms, ['德川家康']);
|
|
assert.match(result.memories[0].text, /2026-07-20/);
|
|
assert.match(result.memories[0].text, /江户幕府/);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].params[0], 'user-1');
|
|
assert.equal(calls[0].params[1], 'session-current');
|
|
assert.match(calls[0].sql, /user_id = \?/);
|
|
assert.match(calls[0].sql, /agent_session_id <> \?/);
|
|
});
|
|
|
|
test('episodic resolve falls back to old session snapshots when the index is unavailable', async () => {
|
|
const calls = [];
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
calls.push({ sql, params });
|
|
if (sql.includes('FROM `h5_episodic_memory_items`')) {
|
|
throw new Error('table unavailable');
|
|
}
|
|
if (sql.includes('FROM h5_session_snapshots')) {
|
|
return [[{
|
|
agent_session_id: 'old-session',
|
|
user_id: 'user-1',
|
|
name: 'New Chat',
|
|
display_title: '历史人物',
|
|
updated_at_str: '2026-06-01T08:00:00.000Z',
|
|
source_updated_at: '2026-06-01T08:00:00.000Z',
|
|
synced_at: Date.parse('2026-06-01T08:00:00.000Z'),
|
|
messages_json: JSON.stringify([
|
|
visibleMessage('user', '我们之前讨论过德川家康。'),
|
|
visibleMessage('assistant', '重点是关原之战和江户幕府。'),
|
|
]),
|
|
}]];
|
|
}
|
|
if (sql.startsWith('INSERT INTO `h5_episodic_memory_items`')) return [{ affectedRows: 1 }];
|
|
throw new Error(`unexpected query: ${sql}`);
|
|
},
|
|
};
|
|
const warnings = [];
|
|
const service = createEpisodicMemoryService(pool, {
|
|
env: {
|
|
MEMORY_RETRIEVER_EPISODIC_ENABLED: '1',
|
|
MEMORY_RETRIEVER_ENABLED: '1',
|
|
MEMORY_RETRIEVER_EPISODIC_MODE: 'active',
|
|
MEMORY_RETRIEVER_TIMEOUT_MS: '500',
|
|
},
|
|
logger: { warn(message) { warnings.push(message); } },
|
|
});
|
|
|
|
const result = await service.resolve({
|
|
userId: 'user-1',
|
|
sessionId: 'current-session',
|
|
query: '我们之前聊过德川家康,你还记得吗?',
|
|
});
|
|
|
|
assert.equal(result.memories.length, 1);
|
|
assert.equal(result.memories[0].sessionId, 'old-session');
|
|
assert.equal(result.memories[0].source, 'session-snapshot-fallback');
|
|
assert.equal(result.degraded, true);
|
|
assert.ok(warnings.some((message) => message.includes('index query degraded')));
|
|
assert.ok(calls.some(({ sql }) => sql.includes('FROM h5_session_snapshots')));
|
|
});
|
|
|
|
test('episodic retrieval stays off for ordinary chat and when the feature flag is disabled', async () => {
|
|
let queries = 0;
|
|
const pool = { async query() { queries += 1; return [[]]; } };
|
|
const enabledService = createEpisodicMemoryService(pool, {
|
|
env: {
|
|
MEMORY_RETRIEVER_EPISODIC_ENABLED: '1',
|
|
MEMORY_RETRIEVER_ENABLED: '1',
|
|
MEMORY_RETRIEVER_EPISODIC_MODE: 'active',
|
|
},
|
|
});
|
|
const disabledService = createEpisodicMemoryService(pool, {
|
|
env: { MEMORY_RETRIEVER_EPISODIC_ENABLED: '0' },
|
|
});
|
|
|
|
assert.equal((await enabledService.resolve({ userId: 'u1', query: '今天过得怎么样?' })).reason, 'not_historical_recall');
|
|
assert.equal((await disabledService.resolve({ userId: 'u1', query: '我们之前聊过什么?' })).reason, 'disabled');
|
|
assert.equal(queries, 0);
|
|
});
|
|
|
|
test('episodic canary mode indexes and resolves only configured users', async () => {
|
|
const inserts = [];
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
if (sql.startsWith('INSERT INTO `h5_episodic_memory_items`')) {
|
|
inserts.push(params);
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
const service = createEpisodicMemoryService(pool, {
|
|
env: {
|
|
MEMORY_RETRIEVER_EPISODIC_ENABLED: '1',
|
|
MEMORY_RETRIEVER_ENABLED: '1',
|
|
MEMORY_RETRIEVER_EPISODIC_MODE: 'canary',
|
|
MEMORY_RETRIEVER_EPISODIC_CANARY_USER_IDS: 'user-canary,user-other',
|
|
},
|
|
});
|
|
const snapshot = {
|
|
sessionId: 'session-1',
|
|
session: { updated_at: '2026-07-22T10:00:00.000Z' },
|
|
messages: [visibleMessage('user', '我们聊过德川家康')],
|
|
};
|
|
|
|
const controlWrite = await service.upsertSnapshot({ ...snapshot, userId: 'user-control' });
|
|
const canaryWrite = await service.upsertSnapshot({ ...snapshot, userId: 'user-canary' });
|
|
const controlRead = await service.resolve({
|
|
userId: 'user-control',
|
|
query: '我们之前聊过德川家康吗?',
|
|
});
|
|
const status = await service.getStatus();
|
|
|
|
assert.equal(controlWrite.reason, 'outside_canary');
|
|
assert.equal(canaryWrite.ok, true);
|
|
assert.equal(inserts.length, 1);
|
|
assert.equal(inserts[0][1], 'user-canary');
|
|
assert.equal(controlRead.reason, 'outside_canary');
|
|
assert.equal(controlRead.mode, 'canary');
|
|
assert.equal(status.configuredEnabled, true);
|
|
assert.equal(status.mode, 'canary');
|
|
assert.equal(status.canaryUserCount, 2);
|
|
assert.equal(status.metrics.indexed, 1);
|
|
assert.equal(status.metrics.indexSkipped, 1);
|
|
});
|
|
|
|
test('session snapshot save and delete keep the episodic index synchronized', async () => {
|
|
const indexed = [];
|
|
const removed = [];
|
|
const pool = {
|
|
async query(sql) {
|
|
if (sql.includes('INSERT INTO h5_session_snapshots')) return [{ affectedRows: 1 }];
|
|
if (sql.includes('DELETE FROM h5_session_snapshots')) return [{ affectedRows: 1 }];
|
|
throw new Error(`unexpected query: ${sql}`);
|
|
},
|
|
};
|
|
const service = createSessionSnapshotService(pool, {
|
|
episodicMemoryService: {
|
|
async upsertSnapshot(input) { indexed.push(input); },
|
|
async remove(sessionId) { removed.push(sessionId); },
|
|
},
|
|
});
|
|
const messages = [visibleMessage('user', '聊聊德川家康')];
|
|
|
|
await service.save(
|
|
'session-1',
|
|
'user-1',
|
|
{
|
|
name: 'New Chat',
|
|
updated_at: '2026-07-22T10:00:00.000Z',
|
|
message_count: 1,
|
|
},
|
|
messages,
|
|
);
|
|
await service.remove('session-1');
|
|
|
|
assert.equal(indexed.length, 1);
|
|
assert.equal(indexed[0].sessionId, 'session-1');
|
|
assert.equal(indexed[0].session.display_title, '聊聊德川家康');
|
|
assert.deepEqual(removed, ['session-1']);
|
|
});
|