Files
memind/scripts/verify-world-cup-flow-e2e.mjs
john 14a00774d9 feat(h5): web 联网能力、实时查询路由与 session Finish 对齐
- 新增 web 能力并挂载 platform/web(web_search/fetch_url)
- 实时查询强制 web skill,router fallback 与 await session Finish
- Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 16:06:26 +08:00

359 lines
12 KiB
JavaScript
Raw Permalink 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.
#!/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);
});