#!/usr/bin/env node /** * Local Stage D check: casual chat then a short "go do it" follow-up. * Does not require Goose to finish; waits for intent_routed only. * * H5_PORT=8086 node scripts/verify-chat-intent-followup.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 } from '../chat-intent-router.mjs'; import { isDirectFollowupEscalateEnabledForUser, resolveDirectFollowupEscalatePolicy } from '../chat-task-intent-config.mjs'; loadH5Environment(import.meta.dirname); const PORTAL = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`; const USERNAME = process.env.MEMIND_E2E_USERNAME ?? 'john'; const PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888'; const DIRECT_MESSAGE = '我想了解车载冰箱,MPV 用,宽度不超过 60cm,先聊聊选购要点'; const FOLLOWUP_MESSAGE = '不对,你去执行'; const EVENT_WAIT_MS = 90_000; const issues = []; function pass(label, detail = '') { console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`); } function fail(label, detail = '') { issues.push({ label, detail }); console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`); } function parseEventData(raw) { if (!raw) return null; if (typeof raw === 'object') return raw; try { return JSON.parse(String(raw)); } catch { return null; } } 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) { 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 { token: decodeURIComponent(match[1]), userId: body.user?.id ?? null }; } } 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 || !result.token) { throw new Error(`登录失败: ${result.message ?? 'unknown'}`); } return { token: result.token, userId: result.user?.id ?? null }; } async function createRun(token, { message, sessionId = null }) { const response = await fetch(`${PORTAL}/api/agent/runs`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `${USER_COOKIE}=${token}`, }, body: JSON.stringify({ request_id: crypto.randomUUID(), session_id: sessionId, user_message: { id: crypto.randomUUID(), role: 'user', content: [{ type: 'text', text: message }], metadata: { displayText: message, userVisible: true }, }, }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(`POST /api/agent/runs ${response.status}: ${JSON.stringify(payload)}`); } const run = payload.run ?? payload; return { runId: run.id, sessionId: run.sessionId ?? run.agent_session_id ?? sessionId ?? null }; } 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(() => ({})); return payload.run ?? payload; } async function readRunEvents(runId) { const pool = await createDbPool(); const [rows] = await pool.query( `SELECT event_type, data_json FROM h5_agent_run_events WHERE run_id = ? ORDER BY created_at ASC`, [runId], ); await pool.end(); return rows; } async function waitForRunEvents(token, runId, { requireCompleted = false } = {}) { const started = Date.now(); while (Date.now() - started < EVENT_WAIT_MS) { const run = await getRun(token, runId); const events = await readRunEvents(runId); const routed = events.find((row) => row.event_type === 'intent_routed'); const completed = events.find((row) => row.event_type === 'direct_chat_completed'); const sessionId = parseEventData(completed?.data_json)?.sessionId ?? run?.sessionId ?? run?.agent_session_id ?? null; if (routed && sessionId && (!requireCompleted || completed || ['succeeded', 'failed'].includes(run?.status))) { return { run, events, routed: parseEventData(routed.data_json), sessionId, }; } await new Promise((resolve) => setTimeout(resolve, 1000)); } throw new Error(`run ${runId} 未在 ${EVENT_WAIT_MS}ms 内拿到路由/session`); } async function main() { const health = await fetch(`${PORTAL}/auth/status`).catch(() => null); if (!health?.ok) throw new Error(`Portal 不可用: ${PORTAL}/auth/status`); pass('Portal', PORTAL); const { token, userId } = await loginViaApi(); pass('登录', `${USERNAME} (${userId ?? 'unknown'})`); const policy = resolveDirectFollowupEscalatePolicy(process.env); if (!isDirectFollowupEscalateEnabledForUser(userId, policy)) { fail('阶段 D flag', `用户 ${userId} 未命中 DIRECT_FOLLOWUP_ESCALATE canary`); } else { pass('阶段 D flag', 'enabled for user'); } const direct = await createRun(token, { message: DIRECT_MESSAGE }); pass('direct 提交', direct.runId); const directResult = await waitForRunEvents(token, direct.runId, { requireCompleted: true }); if (directResult.routed?.route !== CHAT_INTENT_ROUTE.DIRECT_CHAT) { fail('direct 路由', String(directResult.routed?.route ?? 'missing')); } else { pass('direct 路由', directResult.routed.reason ?? 'direct_chat'); } const sessionId = directResult.sessionId; if (!sessionId) fail('direct session', 'missing'); else pass('direct session', sessionId); const followup = await createRun(token, { message: FOLLOWUP_MESSAGE, sessionId }); pass('followup 提交', followup.runId); const followupResult = await waitForRunEvents(token, followup.runId); const routed = followupResult.routed; if (routed?.route !== CHAT_INTENT_ROUTE.AGENT) { fail('阶段 D 路由', `期望 agent,实际 ${routed?.route ?? 'missing'} / ${routed?.reason ?? ''}`); } else if (!String(routed.reason ?? '').includes('短句纠偏')) { fail('阶段 D 原因', routed.reason ?? 'missing'); } else { pass('阶段 D 路由', routed.reason); } const escalated = followupResult.events.some((row) => row.event_type === 'direct_session_escalated_to_deep_reasoning'); const isH5Direct = String(sessionId ?? '').startsWith('h5direct_'); if (escalated) pass('升级事件', 'direct_session_escalated_to_deep_reasoning'); else if (!isH5Direct) pass('升级事件', '日期 session 同会话升级,无需换 Goose session'); else fail('升级事件', '未看到 direct session 升级'); console.log('\n=== 汇总 ==='); if (issues.length) { for (const item of issues) console.log(` - ${item.label}: ${item.detail}`); process.exit(1); } console.log('chat intent followup 本地验收通过'); } main().catch((err) => { console.error(err instanceof Error ? err.message : err); process.exit(1); });