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>
447 lines
14 KiB
JavaScript
447 lines
14 KiB
JavaScript
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,
|
||
};
|
||
}
|