Files
memind/admin-system-tests.mjs
T
john e45c9300bf 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>
2026-07-04 22:32:57 +08:00

447 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}