import assert from 'node:assert/strict'; import test from 'node:test'; import { createConversationMemoryService, extractConversationMessageText } from './conversation-memory.mjs'; import { encryptSecret } from './llm-providers.mjs'; function createPool({ provider = null } = {}) { const state = { messages: [], memories: [], analyzed: new Set(), }; return { state, async query(sql, params = []) { if (sql.includes('INSERT INTO h5_conversation_messages')) { for (const row of params[0]) { const [ id, userId, sessionId, messageKey, sequenceNo, role, text, rawJson, createdAt, updatedAt, ] = row; const existing = state.messages.find( (item) => item.agent_session_id === sessionId && item.message_key === messageKey, ); const next = { id, user_id: userId, agent_session_id: sessionId, message_key: messageKey, sequence_no: sequenceNo, role, text, raw_json: rawJson, created_at: createdAt, updated_at: updatedAt, analyzed_at: existing?.analyzed_at ?? null, }; if (existing) Object.assign(existing, next); else state.messages.push(next); } return [{ affectedRows: params[0].length }]; } if (sql.includes('FROM h5_conversation_messages')) { const [userId, limit] = params; return [ state.messages .filter((item) => item.user_id === userId && item.role === 'user' && item.analyzed_at == null) .slice(0, limit), ]; } if (sql.includes('INSERT INTO h5_user_memory_items')) { for (const row of params[0]) { const [ id, userId, label, memoryHash, memoryText, evidenceMessageId, sourceSessionId, confidence, rawJson, createdAt, updatedAt, ] = row; state.memories.push({ id, user_id: userId, label, memory_hash: memoryHash, memory_text: memoryText, evidence_message_id: evidenceMessageId, source_session_id: sourceSessionId, confidence, raw_json: rawJson, created_at: createdAt, updated_at: updatedAt, }); } return [{ affectedRows: params[0].length }]; } if (sql.includes('UPDATE h5_conversation_messages SET analyzed_at')) { const [analyzedAt, ids] = params; for (const item of state.messages) { if (ids.includes(item.id)) item.analyzed_at = analyzedAt; } return [{ affectedRows: ids.length }]; } if (sql.includes('FROM h5_user_memory_items')) { const [userId, limit] = params; return [ state.memories .filter((item) => item.user_id === userId) .slice(0, limit) .map((item) => ({ ...item, status: 'active' })), ]; } if (sql.includes('FROM h5_llm_provider_keys')) return [[provider].filter(Boolean)]; throw new Error(`Unexpected SQL: ${sql}`); }, }; } test('extractConversationMessageText reads text content and display text', () => { assert.equal( extractConversationMessageText({ content: [{ type: 'text', text: ' hello ' }] }), 'hello', ); assert.equal( extractConversationMessageText({ content: [], metadata: { displayText: 'fallback' } }), 'fallback', ); }); test('saveAndAnalyze stores messages and fallback memories', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0'; const pool = createPool(); const service = createConversationMemoryService(pool, { now: () => 1000 }); const result = await service.saveAndAnalyze('session-1', 'user-1', [ { id: 'm1', role: 'user', content: [{ type: 'text', text: '我喜欢简洁直接的回答,也关注 AI 产品设计。' }], metadata: { userVisible: true }, }, { id: 'm2', role: 'assistant', content: [{ type: 'text', text: '好的。' }], metadata: { userVisible: true }, }, ]); assert.equal(result.saved, 2); assert.equal(result.analyzed, 1); assert.equal(result.memories, 1); assert.equal(pool.state.messages.length, 2); assert.equal(pool.state.memories[0].label, 'preference'); assert.match(pool.state.memories[0].memory_text, /简洁直接/); assert.equal(pool.state.messages.find((item) => item.message_key === 'm1')?.analyzed_at, 1000); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); test('saveAndAnalyze re-analyzes saved session messages even when already marked analyzed', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0'; const pool = createPool(); const service = createConversationMemoryService(pool, { now: () => 1500 }); const messages = [{ id: 'm-remember', role: 'user', content: [{ type: 'text', text: '请记住:我的测试别名是蓝鲸42。只回复已记住。' }], metadata: { userVisible: true }, }]; const first = await service.saveAndAnalyze('session-remember', 'user-remember', messages); assert.equal(first.analyzed, 1); assert.equal(first.memories, 1); assert.match(pool.state.memories.at(-1).memory_text, /蓝鲸42/); pool.state.messages[0].analyzed_at = 1500; const second = await service.saveAndAnalyze('session-remember', 'user-remember', messages); assert.equal(second.analyzed, 1); assert.equal(second.memories, 1); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); test('saveAndAnalyze marks messages analyzed when llm extraction fails and no memory is stored', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; const pool = createPool(); const service = createConversationMemoryService(pool, { now: () => 2000, llmProviderService: { async createChatCompletion() { throw new Error('upstream unavailable'); }, }, }); const result = await service.saveAndAnalyze('session-2', 'user-2', [ { id: 'm3', role: 'user', content: [{ type: 'text', text: '今天下雨了。' }], metadata: { userVisible: true }, }, ]); assert.equal(result.saved, 1); assert.equal(result.memories, 0); assert.equal(pool.state.messages.find((item) => item.message_key === 'm3')?.analyzed_at, 2000); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); test('saveAndAnalyze still uses fallback when llm extraction is unavailable', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; const pool = createPool(); const service = createConversationMemoryService(pool, { now: () => 2250, llmProviderService: { async createChatCompletion() { throw new Error('upstream unavailable'); }, }, }); const result = await service.saveAndAnalyze('session-fallback', 'user-fallback', [ { id: 'm-fallback', role: 'user', content: [{ type: 'text', text: '我喜欢简洁的回答。' }], metadata: { userVisible: true }, }, ]); assert.equal(result.memories, 1); assert.match(pool.state.memories[0].memory_text, /简洁/); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); test('saveAndAnalyze uses admin effective env for memory extraction model', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; const pool = createPool(); const llmCalls = []; const service = createConversationMemoryService(pool, { now: () => 2600, getEffectiveEnv: async () => ({ MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID: 'router-key', MEMIND_CHAT_ROUTER_MODEL: 'deepseek-chat', }), llmProviderService: { async createChatCompletion({ providerKeyId, model, messages }) { llmCalls.push({ providerKeyId, model, messages }); return { ok: true, reply: JSON.stringify({ memories: [{ label: 'fact', text: '用户叫 John', confidence: 0.9 }], }), }; }, }, }); await service.saveAndAnalyze('session-admin', 'user-admin', [ { id: 'm-admin', role: 'user', content: [{ type: 'text', text: '我是 John。' }], }, ]); assert.equal(llmCalls.length, 1); assert.equal(llmCalls[0].providerKeyId, 'router-key'); assert.equal(llmCalls[0].model, 'deepseek-chat'); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); test('saveAndAnalyze extracts memories through llmProviderService', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; const pool = createPool(); const service = createConversationMemoryService(pool, { now: () => 2500, llmProviderService: { async createChatCompletion({ messages }) { assert.match(String(messages?.[0]?.content ?? ''), /长期记忆/); return { ok: true, reply: JSON.stringify({ memories: [{ label: 'goal', text: '用户计划去日本旅游', confidence: 0.82 }], }), }; }, }, }); const result = await service.saveAndAnalyze('session-llm', 'user-llm', [ { id: 'm-japan', role: 'user', content: [{ type: 'text', text: '我打算带家人去日本玩 5 天。' }], metadata: { userVisible: true }, }, ]); assert.equal(result.memories, 1); assert.equal(pool.state.memories[0].label, 'goal'); assert.match(pool.state.memories[0].memory_text, /日本/); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); test('saveAndAnalyze respects an empty llm result instead of storing a question through fallback', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; const pool = createPool(); const service = createConversationMemoryService(pool, { now: () => 2750, llmProviderService: { async createChatCompletion({ messages }) { assert.match(String(messages?.[0]?.content ?? ''), /问句没有提供答案时不要记录/); return { ok: true, reply: JSON.stringify({ memories: [] }), }; }, }, }); const result = await service.saveAndAnalyze('session-question', 'user-question', [ { id: 'm-question', role: 'user', content: [{ type: 'text', text: '用户记住的记忆召回灰度测试代号是什么?' }], metadata: { userVisible: true }, }, ]); assert.equal(result.analyzed, 1); assert.equal(result.memories, 0); assert.equal(pool.state.memories.length, 0); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); test('saveAndAnalyze throttles repeated llm extraction warnings', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; const warnings = []; const originalWarn = console.warn; console.warn = (...args) => warnings.push(args); let currentNow = 4000; try { const secret = encryptSecret('test-key', 'memory-test-key'); const pool = createPool({ provider: { api_url: 'https://llm.example.com/v1', default_model: 'memory-model', api_key_ciphertext: secret.ciphertext, api_key_iv: secret.iv, api_key_tag: secret.tag, }, }); const service = createConversationMemoryService(pool, { now: () => currentNow, encryptionKey: 'memory-test-key', llmWarningIntervalMs: 60000, fetch: async () => { throw new Error('upstream unavailable'); }, }); await service.saveAndAnalyze('session-warn-1', 'user-warn', [ { id: 'warn-1', role: 'user', content: [{ type: 'text', text: '今天下雨了。' }], metadata: { userVisible: true }, }, ]); currentNow += 1000; await service.saveAndAnalyze('session-warn-2', 'user-warn', [ { id: 'warn-2', role: 'user', content: [{ type: 'text', text: '明天可能也下雨。' }], metadata: { userVisible: true }, }, ]); } finally { console.warn = originalWarn; if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; } assert.equal(warnings.length, 1); assert.match(String(warnings[0][0]), /conversation-memory/); }); test('saveAndAnalyze still marks messages analyzed when llm extraction is disabled and fallback stores nothing', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0'; const pool = createPool(); const service = createConversationMemoryService(pool, { now: () => 3000 }); const result = await service.saveAndAnalyze('session-3', 'user-3', [ { id: 'm4', role: 'user', content: [{ type: 'text', text: '今天天气不错。' }], metadata: { userVisible: true }, }, ]); assert.equal(result.saved, 1); assert.equal(result.memories, 0); assert.equal(pool.state.messages.find((item) => item.message_key === 'm4')?.analyzed_at, 3000); if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; });