e45c9300bf
Wire chat intent routing with direct_chat on regular sessions, skill-selected short-circuit to Agent, memory light/heavy intervention tiers, and fix direct chat UI stuck streaming after completion. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
11 KiB
JavaScript
337 lines
11 KiB
JavaScript
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 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 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 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;
|
|
});
|