diff --git a/.env.example b/.env.example index 2af5fc3..3763fd7 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,15 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # MEMIND_CHAT_ROUTER_MEMORY_ENABLED=1 # MEMIND_CHAT_ROUTER_FALLBACK_ROUTE=direct_chat +# ----- Chat task intent layer(docs/architecture/chat-task-intent-layer-review-20260828.md)----- +# 阶段 B:direct→agent 升级时把 snapshot 历史注入 Goose 输入(默认关,建议先 canary) +# MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED=0 +# MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS= +# 阶段 C:0.72 兜底在歧义文本上 defer 给 LLM router(默认关;建议先 SHADOW=1 观测) +# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED=0 +# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS= +# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH=12 + # ----- Token 压缩(本地开发可选,降低 DeepSeek 上下文)----- # Agent 会话历史压缩:超过阈值后清空 Goose 会话并保留摘要(默认 180 条 / 12 万字符,偏松) # MEMIND_AGENT_SESSION_COMPACT_MESSAGE_COUNT=40 diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index fd819cb..80679f4 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -45,6 +45,10 @@ import { import { applyCursorFirstAgentExecution } from './cursor-page-routing.mjs'; import { cursorDeepseekFallbackEnabled } from './cursor-agent-launch.mjs'; import { buildCursorBillingTokenState } from './cursor-agent-usage.mjs'; +import { + isDirectEscalationContextEnabledForUser, + resolveDirectEscalationContextPolicy, +} from './chat-task-intent-config.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; const TERMINAL_STATUSES = new Set(['succeeded', 'failed']); @@ -260,6 +264,26 @@ function appendFreshSessionContext( return { ...message, content }; } +function maybeInjectDirectEscalationContext({ + userMessage, + conversation, + userId, + policy, +} = {}) { + if (!isDirectEscalationContextEnabledForUser(userId, policy)) { + return { userMessage, injected: false, messageCount: 0 }; + } + const context = buildFreshSessionContext(conversation); + if (!context) { + return { userMessage, injected: false, messageCount: 0 }; + } + return { + userMessage: appendFreshSessionContext(userMessage, conversation), + injected: true, + messageCount: Array.isArray(conversation) ? conversation.length : 0, + }; +} + const FRESH_SESSION_RECOVERY_CODES = new Set([ 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED', 'SESSION_REASONING_CONTENT_POISONED', @@ -871,9 +895,12 @@ export function createAgentRunGateway({ ), goalRunService = null, workerIdentity = null, + directEscalationContextPolicy = null, }) { const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {}); const sessionStore = resolveSessionAccess({ userAuth, sessionAccess }); + const escalationContextPolicy = directEscalationContextPolicy + ?? resolveDirectEscalationContextPolicy(); const inFlight = new Set(); const queuedDispatches = []; const queuedDispatchSet = new Set(); @@ -2044,6 +2071,7 @@ export function createAgentRunGateway({ forceDeepReasoning: runOptions.forceDeepReasoning, }); let escalatedDirectSessionId = null; + let escalationContextMessages = null; if (isDirectChatSessionId(sessionId)) { escalatedDirectSessionId = sessionId; await appendEvent(runId, 'direct_session_escalated_to_deep_reasoning', { @@ -2069,7 +2097,7 @@ export function createAgentRunGateway({ }); await appendRunSnapshot(runId); if (escalatedDirectSessionId) { - const priorMessages = await loadSnapshotMessages( + escalationContextMessages = await loadSnapshotMessages( sessionSnapshotService, escalatedDirectSessionId, ); @@ -2077,7 +2105,7 @@ export function createAgentRunGateway({ conversationMemoryService, sessionId, userId: row.user_id, - messages: priorMessages, + messages: escalationContextMessages, }); if (persisted.saved > 0) { await appendEvent(runId, 'direct_session_transcript_persisted', { @@ -2102,6 +2130,30 @@ export function createAgentRunGateway({ sessionId, saved: transcriptPersisted.saved, }); + if (!escalationContextMessages?.length) { + escalationContextMessages = await loadSnapshotMessages( + sessionSnapshotService, + escalatedDirectSessionId ?? sessionId, + ); + } + } + if (escalationContextMessages?.length) { + const escalationContext = maybeInjectDirectEscalationContext({ + userMessage, + conversation: escalationContextMessages, + userId: row.user_id, + policy: escalationContextPolicy, + }); + if (escalationContext.injected) { + userMessage = escalationContext.userMessage; + await appendEvent(runId, 'direct_escalation_context_injected', { + sessionId, + previousSessionId: escalatedDirectSessionId ?? null, + messageCount: escalationContext.messageCount, + retainedContextMessages: 16, + retainedContextChars: 12_000, + }); + } } if (typeof tkmindProxy.compactSessionConversationForUser === 'function') { try { diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index f96faaf..3ef4953 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -2737,6 +2737,67 @@ test('agent run persists direct session transcript before escalating to goosed', assert.equal(removed[0], 'deep-session-1'); }); +test('agent run injects direct escalation context when feature flag is enabled', async () => { + const pool = createFakePool({ + sessionDeliverables: { + 'user-1:deep-session-1': [{ + page_id: 'page-deep-context', + title: '深度页面', + publication_id: 'pub-deep-context', + publication_status: 'online', + public_url: 'http://127.0.0.1:5173/MindSpace/user-1/public/a.html', + }], + }, + }); + const submitted = []; + const gateway = createAgentRunGateway({ + pool, + tkmindProxy: { + async startSessionForUser(userId) { + assert.equal(userId, 'user-1'); + return { id: 'deep-session-1' }; + }, + async submitSessionReplyForUser(userId, sessionId, requestId, userMessage, options = {}) { + submitted.push({ userId, sessionId, requestId, userMessage, options }); + }, + }, + sessionSnapshotService: { + async get(sessionId) { + if (sessionId !== 'h5direct_existing') return null; + return { + messages: [ + { role: 'user', content: [{ type: 'text', text: '中考政策' }] }, + { role: 'assistant', content: [{ type: 'text', text: '政策摘要' }] }, + ], + }; + }, + async remove() {}, + }, + conversationMemoryService: { + async saveConversationMessages(_sessionId, _userId, messages) { + return messages; + }, + }, + directEscalationContextPolicy: { + enabled: true, + canaryUserIds: new Set(), + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: 'h5direct_existing', + requestId: 'req-force-deep-context', + userMessage: { role: 'user', content: [{ type: 'text', text: '帮我生成页面 public/a.html' }] }, + forceDeepReasoning: true, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.ok(pool.events.some((event) => event.eventType === 'direct_escalation_context_injected')); + assert.match(submitted[0].userMessage.content[0].text, /会话恢复上下文/); + assert.match(submitted[0].userMessage.content[0].text, /中考政策/); +}); + test('agent run rejects reused goosed session when broker ownership check fails', async () => { const pool = createFakePool(); const gateway = createAgentRunGateway({ diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 4b1dda3..eaf0a1c 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -22,7 +22,17 @@ import { } from './conversation-display.mjs'; import { isGoalRunIntent } from './goal-run-intent.mjs'; import { pgvectorMemoryBackendInternals } from './memory-v2-pgvector.mjs'; +import { + isChatSessionDeferEnabledForUser, + resolveChatSessionDeferPolicy, + shouldDeferChatSessionRoutingToLlm, +} from './chat-task-intent-config.mjs'; +export { shouldDeferChatSessionRoutingToLlm } from './chat-task-intent-config.mjs'; +export { + resolveChatSessionDeferPolicy, + isChatSessionDeferEnabledForUser, +} from './chat-task-intent-config.mjs'; export { matchDirectChatFaqRule, DIRECT_CHAT_FAQ_RULES, FAQ_EXCLUSION_PATTERNS, isExplicitTextOnlyRequest } from './chat-intent-router-rules.mjs'; export const CHAT_INTENT_ROUTE = { @@ -1208,6 +1218,8 @@ export function classifyWithRules({ includeIntentPatterns = true, activeTaskContext = null, grantedSkills = [], + chatSessionDeferEnabled = false, + chatSessionDeferMinTextLength = 12, } = {}) { const decisionContext = { text, @@ -1363,6 +1375,14 @@ export function classifyWithRules({ ) { return null; } + if (shouldDeferChatSessionRoutingToLlm({ + enabled: chatSessionDeferEnabled, + text: normalized, + minTextLength: chatSessionDeferMinTextLength, + isExplicitDirectChatOnlyText, + })) { + return null; + } return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.DIRECT_CHAT, confidence: 0.72, @@ -1398,6 +1418,8 @@ export function createChatIntentRouter(options = {}) { 'fallbackRoute', ]), }); + const chatSessionDeferPolicy = options.chatSessionDeferPolicy + ?? resolveChatSessionDeferPolicy(env); function isLlmRouterEligibleForUser(userId) { return isChatLlmRouterEligible({ @@ -1723,6 +1745,7 @@ export function createChatIntentRouter(options = {}) { }, decisionContext); }; const llmRouterEligible = isLlmRouterEligibleForUser(userId); + const chatSessionDeferEnabled = isChatSessionDeferEnabledForUser(userId, chatSessionDeferPolicy); const ruleResult = classifyWithRules({ text, forceDeepReasoning, @@ -1733,6 +1756,8 @@ export function createChatIntentRouter(options = {}) { includeIntentPatterns: true, activeTaskContext, grantedSkills, + chatSessionDeferEnabled, + chatSessionDeferMinTextLength: chatSessionDeferPolicy.minTextLength, }); if (ruleResult) return finalizeWithCoercion(ruleResult); diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index 6cff922..a10108d 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -1054,6 +1054,27 @@ test('classifyWithRules routes memory recall to direct chat on fresh session', ( assert.equal(lastConversation.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); }); +test('classifyWithRules defers ambiguous chat text to llm when chat session defer is enabled', () => { + const result = classifyWithRules({ + text: '帮我比较一下英得尔和美的车载冰箱', + sessionId: 'h5direct_abc', + sessionMessageCount: 3, + chatSessionDeferEnabled: true, + chatSessionDeferMinTextLength: 12, + }); + assert.equal(result, null); +}); + +test('classifyWithRules keeps memory recall on direct chat when defer is enabled', () => { + const result = classifyWithRules({ + text: '你记得我说想去哪儿吗', + sessionId: 'h5direct_abc', + sessionMessageCount: 3, + chatSessionDeferEnabled: true, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); +}); + test('createChatIntentRouter fast-paths news lookup to agent without LLM', async () => { let llmCalls = 0; const router = createChatIntentRouter({ diff --git a/chat-task-intent-config.mjs b/chat-task-intent-config.mjs new file mode 100644 index 0000000..bfb76c6 --- /dev/null +++ b/chat-task-intent-config.mjs @@ -0,0 +1,63 @@ +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +function parseUserIdSet(raw) { + return new Set( + String(raw ?? '') + .split(/[,;\s]+/) + .map((item) => item.trim()) + .filter(Boolean), + ); +} + +function boundedNumber(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +export function resolveDirectEscalationContextPolicy(env = process.env) { + return { + enabled: envFlag(env?.MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED, false), + canaryUserIds: parseUserIdSet(env?.MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS), + }; +} + +export function isDirectEscalationContextEnabledForUser(userId, policy) { + if (!policy?.enabled) return false; + if (!policy.canaryUserIds?.size) return true; + return policy.canaryUserIds.has(String(userId ?? '').trim()); +} + +export function resolveChatSessionDeferPolicy(env = process.env) { + return { + enabled: envFlag(env?.MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED, false), + canaryUserIds: parseUserIdSet(env?.MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS), + minTextLength: Math.round(boundedNumber( + env?.MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH, + 12, + { min: 4, max: 200 }, + )), + }; +} + +export function isChatSessionDeferEnabledForUser(userId, policy) { + if (!policy?.enabled) return false; + if (!policy.canaryUserIds?.size) return true; + return policy.canaryUserIds.has(String(userId ?? '').trim()); +} + +export function shouldDeferChatSessionRoutingToLlm({ + enabled = false, + text = '', + minTextLength = 12, + isExplicitDirectChatOnlyText = () => false, +} = {}) { + if (!enabled) return false; + const normalized = String(text ?? '').trim(); + if (!normalized || isExplicitDirectChatOnlyText(normalized)) return false; + return normalized.length >= Math.max(1, Number(minTextLength) || 12); +} diff --git a/chat-task-intent-config.test.mjs b/chat-task-intent-config.test.mjs new file mode 100644 index 0000000..2724bd7 --- /dev/null +++ b/chat-task-intent-config.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + isChatSessionDeferEnabledForUser, + isDirectEscalationContextEnabledForUser, + resolveChatSessionDeferPolicy, + resolveDirectEscalationContextPolicy, + shouldDeferChatSessionRoutingToLlm, +} from './chat-task-intent-config.mjs'; + +test('resolveDirectEscalationContextPolicy defaults to disabled', () => { + const policy = resolveDirectEscalationContextPolicy({}); + assert.equal(policy.enabled, false); + assert.equal(policy.canaryUserIds.size, 0); +}); + +test('isDirectEscalationContextEnabledForUser respects canary list', () => { + const policy = { + enabled: true, + canaryUserIds: new Set(['user-a']), + }; + assert.equal(isDirectEscalationContextEnabledForUser('user-a', policy), true); + assert.equal(isDirectEscalationContextEnabledForUser('user-b', policy), false); +}); + +test('shouldDeferChatSessionRoutingToLlm skips explicit direct chat text', () => { + assert.equal(shouldDeferChatSessionRoutingToLlm({ + enabled: true, + text: '你好', + isExplicitDirectChatOnlyText: (value) => value === '你好', + }), false); + assert.equal(shouldDeferChatSessionRoutingToLlm({ + enabled: true, + text: '帮我研究一下车载冰箱的参数和选购建议', + minTextLength: 12, + isExplicitDirectChatOnlyText: () => false, + }), true); + assert.equal(shouldDeferChatSessionRoutingToLlm({ + enabled: false, + text: '帮我研究一下车载冰箱的参数和选购建议', + isExplicitDirectChatOnlyText: () => false, + }), false); +}); + +test('resolveChatSessionDeferPolicy parses min text length', () => { + const policy = resolveChatSessionDeferPolicy({ + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED: '1', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH: '20', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS: 'john', + }); + assert.equal(policy.enabled, true); + assert.equal(policy.minTextLength, 20); + assert.deepEqual([...policy.canaryUserIds], ['john']); + assert.equal(isChatSessionDeferEnabledForUser('john', policy), true); + assert.equal(isChatSessionDeferEnabledForUser('other', policy), false); +}); diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs index 8c8555e..85d868e 100644 --- a/direct-chat-service.mjs +++ b/direct-chat-service.mjs @@ -138,7 +138,7 @@ function buildMemorySystemBlock({ memories, routingMemoryContent } = {}) { function buildModelMessages({ previousMessages, userMessage, memories, routingMemoryContent }) { const system = [ '你是 TKMind H5 聊天助手。', - '优先直接回答用户问题;不要调用工具;涉及需要执行代码、改文件、生成页面或操作外部系统的任务时,简要说明已交由后台任务处理或请用户确认具体任务。', + '优先直接回答用户问题;不要调用工具。涉及需要执行代码、改文件、生成页面或操作外部系统的任务时,说明当前只能文字讨论,并提示用户开启「深度推理」或用更明确的任务描述。', buildMemorySystemBlock({ memories, routingMemoryContent }), ].filter(Boolean).join('\n\n'); diff --git a/scripts/verify-chat-task-intent-layer.mjs b/scripts/verify-chat-task-intent-layer.mjs new file mode 100644 index 0000000..b528f9e --- /dev/null +++ b/scripts/verify-chat-task-intent-layer.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node +/** + * Local acceptance for chat-task-intent-layer (docs/architecture/chat-task-intent-layer-review-20260828.md). + * + * Phase B: direct→agent escalation injects snapshot context (direct_escalation_context_injected). + * Phase C: optional shadow observation when chat session defer + LLM router shadow are enabled. + * + * Usage: + * MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED=1 node scripts/verify-chat-task-intent-layer.mjs + * + * Requires Portal on 8081, agent-run-worker, goosed, and DB from .env. + */ +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, +} from '../chat-intent-router.mjs'; +import { + isDirectEscalationContextEnabledForUser, + resolveDirectEscalationContextPolicy, + resolveChatSessionDeferPolicy, + shouldDeferChatSessionRoutingToLlm, +} 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 ?? process.env.VERIFY_LLM_ROUTER_USER ?? 'john'; +const PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888'; +const DIRECT_TURN_MESSAGE = '我想了解车载冰箱,MPV 用,宽度不超过 60cm,先聊聊选购要点'; +const AGENT_TURN_MESSAGE = '帮我生成对比页面 public/fridge-compare-test.html'; +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)); +} + +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, forceDeepReasoning = false } = {}) { + 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, + session_id: sessionId, + force_deep_reasoning: forceDeepReasoning, + 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: resolveRunSessionId(run, sessionId), + status: run.status, + }; +} + +function resolveRunSessionId(run, fallbackSessionId = null) { + return run?.sessionId ?? run?.agent_session_id ?? fallbackSessionId ?? 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(() => ({})); + if (!response.ok) { + throw new Error(`GET run ${response.status}: ${JSON.stringify(payload)}`); + } + return payload.run ?? 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 waitForRunTerminal(token, runId) { + const started = Date.now(); + let last = null; + while (Date.now() - started < MAX_RUN_WAIT_MS) { + last = await getRun(token, runId); + if (['succeeded', 'failed'].includes(last.status)) return last; + await sleep(2000); + } + throw new Error(`run ${runId} 超时,最后状态 ${last?.status ?? 'unknown'}`); +} + +async function waitForDirectReply(token, sessionId) { + const started = Date.now(); + while (Date.now() - started < MAX_REPLY_WAIT_MS) { + const response = await fetch(`${PORTAL}/api/sessions/${encodeURIComponent(sessionId)}`, { + headers: { Cookie: `${USER_COOKIE}=${token}` }, + }); + const payload = await response.json().catch(() => ({})); + if (response.ok) { + const messages = payload?.messages ?? payload?.conversation ?? []; + const assistant = [...messages].reverse().find((item) => + item?.role === 'assistant' && item?.metadata?.source === 'portal-direct-chat'); + if (assistant) { + const text = Array.isArray(assistant.content) + ? assistant.content.map((part) => part?.text ?? '').join('') + : String(assistant.content ?? ''); + if (text.trim().length > 10) { + return text.trim(); + } + } + } + await sleep(1500); + } + throw new Error(`direct chat 回复超时 session=${sessionId}`); +} + +function verifyStaticGates(userId) { + const escalationPolicy = resolveDirectEscalationContextPolicy(process.env); + if (!escalationPolicy.enabled) { + fail('阶段 B flag', 'MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED 未开启,E2E 无法验收注入'); + } else if (!isDirectEscalationContextEnabledForUser(userId, escalationPolicy)) { + fail('阶段 B canary', `用户 ${userId} 不在 MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS`); + } else { + pass('阶段 B flag', 'direct escalation context enabled'); + } + + const deferPolicy = resolveChatSessionDeferPolicy(process.env); + const deferSample = classifyWithRules({ + text: '帮我比较一下英得尔和美的车载冰箱参数', + sessionId: 'h5direct_verify', + sessionMessageCount: 2, + chatSessionDeferEnabled: deferPolicy.enabled, + chatSessionDeferMinTextLength: deferPolicy.minTextLength, + }); + if (deferPolicy.enabled) { + if (deferSample !== null) { + fail('阶段 C defer 规则', '歧义文本应返回 null 交给 LLM router'); + } else { + pass('阶段 C defer 规则', '歧义文本已 defer'); + } + } else { + pass('阶段 C defer 规则', 'flag 未开,跳过(仅静态检查 defer 函数)'); + if (!shouldDeferChatSessionRoutingToLlm({ + enabled: true, + text: '帮我比较一下英得尔和美的车载冰箱参数', + minTextLength: 12, + isExplicitDirectChatOnlyText: () => false, + })) { + fail('阶段 C defer 函数', 'shouldDeferChatSessionRoutingToLlm 预期 true'); + } + } + + const recall = classifyWithRules({ + text: '你记得我说想去哪儿吗', + sessionId: 'h5direct_verify', + chatSessionDeferEnabled: true, + }); + if (recall?.route !== CHAT_INTENT_ROUTE.DIRECT_CHAT) { + fail('记忆召回硬规则', `期望 direct_chat,实际 ${recall?.route ?? 'null'}`); + } else { + pass('记忆召回硬规则', recall.reason); + } +} + +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-id'})`); + + verifyStaticGates(userId); + + const runTag = crypto.randomUUID().slice(0, 8); + const directMessage = `${DIRECT_TURN_MESSAGE} [verify-${runTag}]`; + const agentMessage = `${AGENT_TURN_MESSAGE.replace('.html', `-${runTag}.html`)}`; + + const directRun = await createRun(token, { message: directMessage }); + pass('direct 轮次提交', `run=${directRun.runId} session=${directRun.sessionId ?? 'pending'}`); + + const directTerminal = await waitForRunTerminal(token, directRun.runId); + const directEvents = await readRunEvents(directRun.runId); + const directRouted = directEvents.find((row) => row.event_type === 'intent_routed'); + const routedData = parseEventData(directRouted?.data_json); + if (routedData?.route !== CHAT_INTENT_ROUTE.DIRECT_CHAT) { + fail('direct 路由', `期望 direct_chat,实际 ${routedData?.route ?? 'missing'}`); + } else { + pass('direct 路由', routedData.reason ?? 'direct_chat'); + } + if (directTerminal.status !== 'succeeded') { + fail('direct run 终态', directTerminal.status); + } else { + pass('direct run 终态', 'succeeded'); + } + + let sessionId = resolveRunSessionId(directTerminal, directRun.sessionId); + if (!sessionId) { + const directEventsEarly = await readRunEvents(directRun.runId); + const completed = directEventsEarly.find((row) => row.event_type === 'direct_chat_completed'); + sessionId = parseEventData(completed?.data_json)?.sessionId ?? null; + } + if (!sessionId) { + fail('direct session', '未获得 sessionId'); + } else { + pass('direct session', sessionId); + } + + let directReply = ''; + try { + directReply = await waitForDirectReply(token, sessionId); + pass('direct 回复', `${directReply.length} chars`); + if (/已交由后台任务处理/.test(directReply)) { + fail('direct 文案', '仍包含「已交由后台任务处理」'); + } else { + pass('direct 文案', '未谎报后台执行'); + } + } catch (err) { + fail('direct 回复', err instanceof Error ? err.message : String(err)); + } + + const agentRun = await createRun(token, { + message: agentMessage, + sessionId, + forceDeepReasoning: true, + }); + pass('agent 升级提交', `run=${agentRun.runId}`); + + const agentTerminal = await waitForRunTerminal(token, agentRun.runId); + const agentEvents = await readRunEvents(agentRun.runId); + if (agentTerminal.status !== 'succeeded') { + fail('agent run 终态', `${agentTerminal.status} ${agentTerminal.error_message ?? ''}`); + } else { + pass('agent run 终态', 'succeeded'); + } + + const escalated = agentEvents.some((row) => row.event_type === 'direct_session_escalated_to_deep_reasoning'); + if (escalated) { + pass('升级事件', 'direct_session_escalated_to_deep_reasoning'); + } else { + pass('升级事件', '同 session agent(非 h5direct 升级路径)'); + } + + const contextInjected = agentEvents.find((row) => row.event_type === 'direct_escalation_context_injected'); + if (resolveDirectEscalationContextPolicy(process.env).enabled) { + if (!contextInjected) { + fail('阶段 B 注入', '缺少 direct_escalation_context_injected 事件'); + } else { + const data = parseEventData(contextInjected.data_json); + pass('阶段 B 注入', `messages=${data?.messageCount ?? '?'}`); + } + } + + const transcriptPersisted = agentEvents.some((row) => + row.event_type === 'direct_session_transcript_persisted' + || row.event_type === 'portal_direct_transcript_persisted'); + if (transcriptPersisted) { + pass('transcript 持久化', 'ok'); + } else { + fail('transcript 持久化', '未观察到 transcript 事件'); + } + + const shadowEvent = agentEvents.find((row) => row.event_type === 'intent_routed'); + const shadowData = parseEventData(shadowEvent?.data_json); + if (shadowData?.llmShadow?.wouldChangeRoute != null) { + pass('阶段 C shadow', `wouldChangeRoute=${shadowData.llmShadow.wouldChangeRoute}`); + } + + console.log('\n=== 汇总 ==='); + console.log(`通过: ${checks.filter((item) => item.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('chat-task-intent-layer 本地验收通过'); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.stack ?? err.message : err); + process.exit(1); +});