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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user