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, }; }