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']); });