feat: LLM intent router, direct chat execution, and Memory V2 light intervention

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>
This commit is contained in:
john
2026-07-04 22:32:57 +08:00
parent bfb6356f7d
commit e45c9300bf
33 changed files with 3412 additions and 105 deletions
+446
View File
@@ -0,0 +1,446 @@
import crypto from 'node:crypto';
import { buildChatSkillPrompt, CHAT_SKILL_DEFINITIONS } from './chat-skills.mjs';
const DEFAULT_SKILL = 'service-integration-smoke';
const RUN_TIMEOUT_MS = 25000;
const POLL_INTERVAL_MS = 500;
const USER_COOKIE = 'tkmind_user_session';
function normalizeJson(value) {
if (!value) return null;
if (typeof value === 'object') return value;
if (typeof value !== 'string') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
function trimText(value, max = 600) {
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return '';
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function extractMessageText(message) {
const content = message?.content;
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return String(message?.text ?? '').trim();
return content
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text ?? '';
return '';
})
.join('\n')
.trim();
}
function stepResult(step) {
return {
key: step.key,
label: step.label,
status: step.status,
message: step.message,
details: step.details ?? null,
};
}
function makeIssue(stepKey, severity, title, detail) {
return {
stepKey,
severity,
title,
detail: trimText(detail, 1000),
};
}
function cookieHeader(token) {
return `${USER_COOKIE}=${encodeURIComponent(token)}`;
}
function skillPrompt(skillName) {
const definition = CHAT_SKILL_DEFINITIONS.find((item) => item.skillName === skillName);
if (definition?.promptKey) {
return buildChatSkillPrompt(definition.promptKey, skillName);
}
return `请使用 ${skillName} 技能:`;
}
async function portalJson(fetchImpl, url, { token, method = 'GET', body } = {}) {
const response = await fetchImpl(url, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
Cookie: cookieHeader(token),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text().catch(() => '');
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
return { ok: response.ok, status: response.status, payload };
}
export function createAdminSystemTestService({
pool,
userAuth,
portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
fetchImpl = globalThis.fetch,
} = {}) {
if (!pool || !userAuth || !fetchImpl) {
throw new Error('admin system test service requires pool, userAuth, and fetch');
}
async function getRunById(runId) {
const [rows] = await pool.query(
`SELECT id, user_id, request_id, status, attempts, agent_session_id, error_message, completed_at
FROM h5_agent_runs
WHERE id = ?
LIMIT 1`,
[runId],
);
return rows[0] ?? null;
}
async function getRunEvents(runId) {
const [rows] = await pool.query(
`SELECT event_type, data_json, created_at
FROM h5_agent_run_events
WHERE run_id = ?
ORDER BY created_at ASC`,
[runId],
);
return rows.map((row) => ({
eventType: row.event_type,
data: normalizeJson(row.data_json) ?? row.data_json ?? null,
createdAt: Number(row.created_at ?? 0) || null,
}));
}
async function getSessionPreview(sessionId) {
if (!sessionId) return null;
const [rows] = await pool.query(
`SELECT messages_json
FROM h5_session_snapshots
WHERE agent_session_id = ?
LIMIT 1`,
[sessionId],
);
const parsed = normalizeJson(rows[0]?.messages_json);
if (!Array.isArray(parsed)) return null;
const assistant = parsed.filter((message) => message?.role === 'assistant');
const latestAssistant = assistant.length ? assistant.at(-1) : null;
return {
messageCount: parsed.length,
assistantPreview: trimText(extractMessageText(latestAssistant), 800),
};
}
async function waitForTerminalRun(runId, timeoutMs = RUN_TIMEOUT_MS) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const run = await getRunById(runId);
if (!run) {
return { run: null, events: [], preview: null };
}
if (['succeeded', 'failed'].includes(run.status)) {
const [events, preview] = await Promise.all([
getRunEvents(runId),
getSessionPreview(run.agent_session_id),
]);
return { run, events, preview };
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
const run = await getRunById(runId);
const [events, preview] = await Promise.all([
getRunEvents(runId),
getSessionPreview(run?.agent_session_id ?? null),
]);
return { run, events, preview, timedOut: true };
}
async function cleanupSession(token, sessionId) {
if (!sessionId) return;
try {
await fetchImpl(`${portalBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
headers: { Cookie: cookieHeader(token) },
});
} catch {
// best effort
}
}
async function runChatProbe(token, text, {
stepKey,
label,
expectDirectChat = false,
expectMemory = false,
} = {}) {
const requestId = crypto.randomUUID();
const submit = await portalJson(fetchImpl, `${portalBaseUrl}/api/agent/runs`, {
token,
method: 'POST',
body: {
request_id: requestId,
user_message: {
id: `admin-system-test-${stepKey}`,
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text }],
metadata: { userVisible: true, agentVisible: true },
},
},
});
if (!submit.ok || !submit.payload?.run?.id) {
return {
step: stepResult({
key: stepKey,
label,
status: 'failed',
message: `提交测试消息失败(HTTP ${submit.status}`,
details: submit.payload,
}),
issues: [makeIssue(stepKey, 'error', '测试消息提交失败', JSON.stringify(submit.payload ?? {}))],
};
}
const runId = submit.payload.run.id;
const result = await waitForTerminalRun(runId);
const issues = [];
const routeEvent = result.events.find((event) => event.eventType === 'intent_routed');
const directEvent = result.events.find((event) => event.eventType === 'direct_chat_completed');
const directFailed = result.events.find((event) => event.eventType === 'direct_chat_failed');
const route = routeEvent?.data?.route ?? null;
const memory = routeEvent?.data?.memory ?? null;
const missingDirectChat = expectDirectChat && (!directEvent || route !== 'direct_chat');
const missingMemory = expectMemory && (!memory || Number(memory.itemsUsed ?? 0) <= 0);
const status = result.timedOut || result.run?.status !== 'succeeded' || missingDirectChat
? 'failed'
: missingMemory
? 'warning'
: 'passed';
if (expectDirectChat && route !== 'direct_chat') {
issues.push(makeIssue(stepKey, 'error', '未走直连聊天', `实际 route=${route ?? 'unknown'}`));
}
if (expectDirectChat && !directEvent) {
issues.push(makeIssue(stepKey, 'error', '直连聊天未完成', JSON.stringify(directFailed?.data ?? result.run ?? {})));
}
if (expectMemory && (!memory || Number(memory.itemsUsed ?? 0) <= 0)) {
issues.push(makeIssue(
stepKey,
'warning',
'Memory V2 未命中可用记忆',
JSON.stringify(memory ?? routeEvent?.data ?? {}),
));
}
if (result.run?.status !== 'succeeded') {
issues.push(makeIssue(stepKey, 'error', '聊天任务未成功结束', JSON.stringify(result.run ?? {})));
}
await cleanupSession(token, result.run?.agent_session_id ?? null);
return {
step: stepResult({
key: stepKey,
label,
status,
message: status === 'passed'
? '验证通过'
: status === 'warning'
? '验证完成,但存在待处理问题'
: '验证失败',
details: {
runId,
route,
runStatus: result.run?.status ?? null,
sessionId: result.run?.agent_session_id ?? null,
memory,
assistantPreview: result.preview?.assistantPreview ?? '',
directChatFailed: directFailed?.data ?? null,
timedOut: Boolean(result.timedOut),
},
}),
issues,
};
}
async function runSkillProbe(token, skillName) {
const prompt = `${skillPrompt(skillName)}请基于当前账号做一次轻量联调,只返回“通过项 / 失败项 / 待确认项”,不要生成页面。`;
const result = await runChatProbe(token, prompt, {
stepKey: 'skill_probe',
label: 'Skill 验证',
expectDirectChat: false,
expectMemory: false,
});
const preview = String(result.step.details?.assistantPreview ?? '');
if (!preview || !/(通过项|失败项|待确认项)/.test(preview)) {
result.step.status = result.step.status === 'failed' ? 'failed' : 'warning';
result.step.message = 'Skill 已执行,但输出不符合预期格式';
result.issues.push(makeIssue(
'skill_probe',
'warning',
'Skill 输出不完整',
preview || JSON.stringify(result.step.details ?? {}),
));
}
return result;
}
async function runSkillValidation({ username, password, skillName = DEFAULT_SKILL } = {}) {
const issues = [];
const steps = [];
const startedAt = Date.now();
const normalizedSkill = String(skillName || DEFAULT_SKILL).trim() || DEFAULT_SKILL;
const loginResult = await userAuth.login({
username: String(username ?? '').trim(),
password: String(password ?? ''),
ip: 'admin-system-test',
now: startedAt,
});
if (!loginResult?.ok) {
return {
ok: false,
startedAt,
finishedAt: Date.now(),
portalBaseUrl,
selectedSkill: normalizedSkill,
account: { username: String(username ?? '').trim() },
steps: [
stepResult({
key: 'login',
label: '账号登录',
status: 'failed',
message: loginResult?.message ?? '登录失败',
}),
],
issues: [makeIssue('login', 'error', '账号密码校验失败', loginResult?.message ?? '登录失败')],
summary: { passed: 0, warnings: 0, failed: 1 },
};
}
const token = loginResult.token;
const user = loginResult.user;
steps.push(stepResult({
key: 'login',
label: '账号登录',
status: 'passed',
message: '登录成功',
details: { userId: user.id, username: user.username, displayName: user.displayName ?? null },
}));
try {
const authStatus = await portalJson(fetchImpl, `${portalBaseUrl}/auth/status`, { token });
if (!authStatus.ok || !authStatus.payload?.authenticated) {
steps.push(stepResult({
key: 'auth_status',
label: '会话状态',
status: 'failed',
message: `auth/status 异常(HTTP ${authStatus.status}`,
details: authStatus.payload,
}));
issues.push(makeIssue('auth_status', 'error', '用户会话未生效', JSON.stringify(authStatus.payload ?? {})));
} else {
const grantedSkills = Array.isArray(authStatus.payload.grantedSkills)
? authStatus.payload.grantedSkills
: [];
const hasSelectedSkill = grantedSkills.includes(normalizedSkill);
steps.push(stepResult({
key: 'auth_status',
label: '会话状态',
status: 'passed',
message: 'auth/status 返回正常',
details: {
userId: authStatus.payload.user?.id ?? null,
grantedSkills,
capabilities: authStatus.payload.capabilities ?? null,
},
}));
steps.push(stepResult({
key: 'skill_grant',
label: 'Skill 授权',
status: hasSelectedSkill ? 'passed' : 'warning',
message: hasSelectedSkill
? `账号已授予 ${normalizedSkill}`
: `账号未授予 ${normalizedSkill}`,
details: { selectedSkill: normalizedSkill, grantedSkills },
}));
if (!hasSelectedSkill) {
issues.push(makeIssue(
'skill_grant',
'warning',
'账号未授予目标 skill',
`selected=${normalizedSkill}; granted=${grantedSkills.join(', ')}`,
));
}
}
const greetingProbe = await runChatProbe(token, 'hi', {
stepKey: 'direct_chat',
label: '直连聊天验证',
expectDirectChat: true,
});
steps.push(greetingProbe.step);
issues.push(...greetingProbe.issues);
const memoryProbe = await runChatProbe(token, '你记得我什么?', {
stepKey: 'memory_read',
label: 'Memory V2 读取验证',
expectDirectChat: true,
expectMemory: true,
});
steps.push(memoryProbe.step);
issues.push(...memoryProbe.issues);
const skillProbe = await runSkillProbe(token, normalizedSkill);
steps.push(skillProbe.step);
issues.push(...skillProbe.issues);
} finally {
try {
await fetchImpl(`${portalBaseUrl}/auth/logout`, {
method: 'POST',
headers: { Cookie: cookieHeader(token) },
});
} catch {
// best effort
}
}
const summary = steps.reduce((acc, step) => {
if (step.status === 'passed') acc.passed += 1;
else if (step.status === 'warning') acc.warnings += 1;
else acc.failed += 1;
return acc;
}, { passed: 0, warnings: 0, failed: 0 });
return {
ok: summary.failed === 0,
startedAt,
finishedAt: Date.now(),
portalBaseUrl,
selectedSkill: normalizedSkill,
account: {
userId: user.id,
username: user.username,
displayName: user.displayName ?? null,
},
summary,
steps,
issues,
};
}
return {
runSkillValidation,
};
}