#!/usr/bin/env node /** * Full local E2E: john login → agent run → routing → session reply quality. * Usage: JOHN_PASSWORD=888888 node scripts/verify-world-cup-flow-e2e.mjs */ import crypto from 'node:crypto'; import { loadH5Environment } from './load-env.mjs'; import { createDbPool } from '../db.mjs'; import { createUserAuth, USER_COOKIE } from '../user-auth.mjs'; import { CHAT_INTENT_ROUTE, classifyWithRules, resolveChatIntentRouterPolicy, } from '../chat-intent-router.mjs'; loadH5Environment(import.meta.dirname); const PORTAL = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`; const USERNAME = 'john'; const PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888'; const QUERY = '世界杯现在赛况如何'; const MAX_RUN_WAIT_MS = Number(process.env.E2E_RUN_WAIT_MS ?? 300_000); const MAX_REPLY_WAIT_MS = 120_000; const issues = []; const checks = []; function pass(label, detail = '') { checks.push({ ok: true, label, detail }); console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`); } function fail(label, detail = '') { issues.push({ label, detail }); checks.push({ ok: false, label, detail }); console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function loginViaApi() { const response = await fetch(`${PORTAL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: USERNAME, password: PASSWORD }), }); const body = await response.json().catch(() => ({})); if (!response.ok || !body?.authenticated) { throw new Error(`HTTP 登录失败 ${response.status}: ${JSON.stringify(body)}`); } const setCookie = response.headers.getSetCookie?.() ?? []; const cookieLine = setCookie.find((line) => line.startsWith(`${USER_COOKIE}=`)) ?? response.headers.get('set-cookie'); const match = String(cookieLine ?? '').match(new RegExp(`${USER_COOKIE}=([^;]+)`)); if (match?.[1]) return decodeURIComponent(match[1]); const pool = await createDbPool(); const auth = createUserAuth(pool); const result = await auth.login({ username: USERNAME, password: PASSWORD, ip: '127.0.0.1' }); await pool.end(); if (!result.ok) { throw new Error(`登录 cookie 解析失败,且 DB login 失败: ${result.message ?? 'unknown'}`); } return result.token; } async function createRun(token) { const requestId = crypto.randomUUID(); const response = await fetch(`${PORTAL}/api/agent/runs`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `${USER_COOKIE}=${token}`, }, body: JSON.stringify({ request_id: requestId, user_message: { id: crypto.randomUUID(), role: 'user', content: [{ type: 'text', text: QUERY }], metadata: { userVisible: true, displayText: QUERY }, }, }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(`POST /agent/runs ${response.status}: ${JSON.stringify(payload)}`); } const run = payload.run ?? payload; return { runId: run.id, requestId, status: run.status }; } async function getRun(token, runId) { const response = await fetch(`${PORTAL}/api/agent/runs/${runId}`, { headers: { Cookie: `${USER_COOKIE}=${token}` }, }); const payload = await response.json().catch(() => ({})); const run = payload.run ?? payload; if (!response.ok) { throw new Error(`GET run ${response.status}: ${JSON.stringify(payload)}`); } return run; } async function getSession(token, sessionId) { const response = await fetch(`${PORTAL}/api/sessions/${encodeURIComponent(sessionId)}`, { headers: { Cookie: `${USER_COOKIE}=${token}` }, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { return { ok: false, status: response.status, payload }; } return { ok: true, session: payload }; } async function readRunEvents(runId) { const pool = await createDbPool(); 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], ); await pool.end(); return rows; } async function readSessionStreamCount(sessionId) { const pool = await createDbPool(); const [[row]] = await pool.query( `SELECT COUNT(*) AS cnt FROM h5_session_stream_events WHERE agent_session_id = ?`, [sessionId], ); await pool.end(); return Number(row?.cnt ?? 0); } function extractAssistantTexts(sessionPayload) { const conversation = sessionPayload?.conversation ?? sessionPayload?.session?.conversation ?? sessionPayload?.messages ?? []; const texts = []; for (const message of conversation) { if (message?.role !== 'assistant') continue; const content = message?.content; if (typeof content === 'string') texts.push(content); else if (Array.isArray(content)) { texts.push( content .map((item) => (item?.type === 'text' ? item.text ?? '' : '')) .join(''), ); } } return texts.filter(Boolean); } function verifyStaticRouter() { const rule = classifyWithRules({ text: QUERY, llmRouterEnabled: true, userMessage: { role: 'user', content: [{ type: 'text', text: QUERY }], metadata: { displayText: QUERY }, }, }); if (rule?.route !== CHAT_INTENT_ROUTE.AGENT) { fail('规则快路径', `期望 agent_orchestration,实际 ${rule?.route ?? 'null'}`); return; } pass('规则快路径', `${rule.source} / ${rule.reason}`); const policy = resolveChatIntentRouterPolicy({ env: { MEMIND_CHAT_ROUTER_FALLBACK_ROUTE: 'direct_chat' }, }); if (policy.fallbackRoute !== CHAT_INTENT_ROUTE.AGENT) { fail('fallback 策略', `direct_chat 未被纠正,实际 ${policy.fallbackRoute}`); return; } pass('fallback 策略', 'timeout 强制 agent_orchestration'); } async function verifyAdminConfig() { const pool = await createDbPool(); const [[row]] = await pool.query( `SELECT config_json FROM h5_memory_v2_admin_config WHERE config_scope = 'global' LIMIT 1`, ); await pool.end(); const router = row?.config_json?.chatIntentRouter ?? {}; if (router.fallbackRoute !== 'agent_orchestration') { fail('Admin DB fallbackRoute', String(router.fallbackRoute ?? 'missing')); } else { pass('Admin DB fallbackRoute', router.fallbackRoute); } if (Number(router.timeoutMs) < 2000) { fail('Admin DB timeoutMs', String(router.timeoutMs ?? 'missing')); } else { pass('Admin DB timeoutMs', `${router.timeoutMs}ms`); } } async function waitForAssistantReply(token, sessionId) { const started = Date.now(); while (Date.now() - started < MAX_REPLY_WAIT_MS) { const result = await getSession(token, sessionId); if (result.ok) { const texts = extractAssistantTexts(result.session); const combined = texts.join('\n').trim(); if (combined.length > 20) { return { texts, combined, elapsedMs: Date.now() - started }; } } await sleep(2000); } return null; } async function main() { console.log('==> 世界杯链路 E2E 验证'); console.log(` Portal: ${PORTAL}`); console.log(` 用户: ${USERNAME}\n`); console.log('--- 静态检查 ---'); verifyStaticRouter(); await verifyAdminConfig(); console.log('\n--- HTTP 链路 ---'); const token = await loginViaApi(); pass('HTTP 登录', USERNAME); const { runId } = await createRun(token); pass('POST /agent/runs', runId); let run = null; const runStarted = Date.now(); const terminalRunWaitMs = Number(process.env.E2E_RUN_WAIT_MS ?? 300_000); while (Date.now() - runStarted < terminalRunWaitMs) { run = await getRun(token, runId); if (['succeeded', 'failed'].includes(run.status)) break; await sleep(2000); } if (!run || !['succeeded', 'failed'].includes(run.status)) { fail('run 终态', `超时未完成,最后状态 ${run?.status ?? 'unknown'}`); } else if (run.status === 'failed') { fail('run 终态', run.error ?? 'failed'); } else { pass('run 终态', `succeeded (${Date.now() - runStarted}ms)`); } const events = await readRunEvents(runId); let routed = events.find((row) => row.event_type === 'intent_routed'); let route = routed?.data_json?.route ?? null; let source = routed?.data_json?.source ?? null; let reason = routed?.data_json?.reason ?? null; if (route === CHAT_INTENT_ROUTE.AGENT) { pass('intent_routed', `${source} / ${reason}`); } else { fail('intent_routed', `route=${route}, source=${source}, reason=${reason}`); } const suggestedSkill = routed?.data_json?.suggestedSkill ?? null; if (suggestedSkill === 'web') { pass('强制 web skill', suggestedSkill); } else { fail('强制 web skill', `期望 web,实际 ${suggestedSkill ?? 'missing'}`); } if (events.some((row) => row.event_type === 'direct_chat_completed')) { fail('通道', '出现 direct_chat_completed,不应走 direct chat'); } else { pass('通道', '无 direct_chat_completed'); } const sessionId = run?.sessionId ?? run?.agent_session_id; if (!sessionId) { fail('session', 'run 未回填 sessionId'); } else { pass('session 创建', sessionId); if (events.some((row) => row.event_type === 'session_started')) { pass('agent 派发', 'session_started 已记录'); } else { fail('agent 派发', '缺少 session_started'); } const streamCount = await readSessionStreamCount(sessionId); if (streamCount > 0) { pass('session SSE 落库', `${streamCount} 条 stream events`); } else { fail('session SSE 落库', 'h5_session_stream_events 为空(4c replay 可能未生效或 agent 未流式输出)'); } console.log('\n--- 等待 assistant 回复(最长 120s)---'); const reply = await waitForAssistantReply(token, sessionId); if (!reply) { fail('assistant 回复', '超时未从 GET /sessions 读到 assistant 文本'); } else { pass('assistant 回复', `${reply.combined.length} 字 / ${reply.elapsedMs}ms`); console.log('\n--- 回复摘要 ---'); console.log(reply.combined.slice(0, 800)); if (/无法实时获取|无法获取.*最新赛况|建议你打开体育新闻/i.test(reply.combined)) { fail('回复质量', '仍是 direct chat 式拒答,未实际搜索赛况'); } else if (/(世界杯|赛程|比分|淘汰赛|小组赛|FIFA|2026)/i.test(reply.combined)) { pass('回复质量', '包含赛况/世界杯相关内容'); } else { fail('回复质量', '未检测到赛况关键词,请人工查看完整回复'); } } } if (run && !['succeeded', 'failed'].includes(run.status)) { const extraWaitStarted = Date.now(); while (Date.now() - extraWaitStarted < MAX_REPLY_WAIT_MS) { run = await getRun(token, runId); if (['succeeded', 'failed'].includes(run.status)) break; await sleep(2000); } if (['succeeded', 'failed'].includes(run.status)) { pass('run 终态(延后)', `${run.status}`); } } const finalEvents = await readRunEvents(runId); routed = finalEvents.find((row) => row.event_type === 'intent_routed') ?? routed; route = routed?.data_json?.route ?? route; source = routed?.data_json?.source ?? source; reason = routed?.data_json?.reason ?? reason; const sessionFinishedIdx = finalEvents.findIndex((row) => row.event_type === 'session_finished'); const succeededIdx = finalEvents.findIndex((row) => row.event_type === 'succeeded'); if (sessionFinishedIdx >= 0 && succeededIdx > sessionFinishedIdx) { pass('run 与 Finish 对齐', 'session_finished 在 succeeded 之前'); } else if (run?.status === 'running') { fail('run 与 Finish 对齐', 'run 仍在 running,可能 await Finish 尚未完成'); } else if (run?.status === 'succeeded') { fail('run 与 Finish 对齐', '缺少 session_finished 或顺序不对'); } console.log('\n=== 汇总 ==='); console.log(`通过: ${checks.filter((c) => c.ok).length}/${checks.length}`); if (issues.length) { console.log('待修复:'); for (const item of issues) { console.log(` - ${item.label}: ${item.detail}`); } process.exit(1); } console.log('全部检查通过'); } main().catch((err) => { console.error(err instanceof Error ? err.stack ?? err.message : err); process.exit(1); });