diff --git a/.env.example b/.env.example index 6434de7..5933c2d 100644 --- a/.env.example +++ b/.env.example @@ -47,14 +47,34 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # 本地开发:Patch 2 接入后可设 1 验证 broker 路径;默认 0 保持 userAuth 直调。 # 103 生产:仅在灰度窗口于服务器 /Users/john/Project/Memind/.env 设置,勿把生产值写入 Git。 # MEMIND_SESSION_BROKER_ENABLED=0 + +# --- 本地全量联调示例(复制到 .env,勿提交 Git)--- +# MEMIND_SESSION_BROKER_ENABLED=1 +# MEMIND_ROUTER_NORMALIZED_DECISION=1 +# MEMIND_SSE_EVENT_TAXONOMY=1 +# MEMIND_RUN_STREAM_REPLAY=1 +# MEMIND_SESSION_STREAM_REPLAY=1 +# MEMIND_SESSION_BROKER_METRICS=1 +# MEMIND_H5_HTML_FINISH_GUARD=1 +# 启动后 Portal 日志应出现:[Portal] H5 session flags: ... # 103 生产 goosed 多实例示例(仅服务器 .env,见 docs/103-runtime-topology.md): # TKMIND_API_TARGET=https://127.0.0.1:18006 # TKMIND_API_TARGET_1=https://127.0.0.1:18007 # H5 Router normalized decision(docs/h5-session-architecture-20260706.md Patch 3) -# 默认 0:gateway 仍读 legacy route;设 1 时 gateway 优先读 classification.decision.route。 +# 默认 0/off:gateway 仍读 legacy route +# shadow:只打 [router-shadow] 日志,行为不变 +# 1/on:gateway 优先读 classification.decision.route / session_hint # MEMIND_ROUTER_NORMALIZED_DECISION=0 +# H5 Session stream replay(docs/h5-session-architecture-20260706.md Patch 4c) +# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。 +# MEMIND_SESSION_STREAM_REPLAY=0 + +# H5 Session Broker metrics(docs/h5-session-architecture-20260706.md §13.4) +# 默认 0:不打 structured log;设 1 时输出 [session_broker.*] 行 +# MEMIND_SESSION_BROKER_METRICS=0 + # H5 SSE event taxonomy(docs/h5-session-architecture-20260706.md Patch 4) # 默认 0:不改变 SSE wire format;设 1 时在 session/run SSE payload 附加 taxonomy 字段(shadow)。 # MEMIND_SSE_EVENT_TAXONOMY=0 diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 903aaec..2d0d246 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { isRunStreamReplayEnabled } from './agent-run-stream.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; -import { CHAT_INTENT_ROUTE, resolveLegacyRouteFromClassification } from './chat-intent-router.mjs'; +import { CHAT_INTENT_ROUTE, resolveGatewayAgentSessionId, resolveLegacyRouteFromClassification, logRouterDecisionShadow } from './chat-intent-router.mjs'; import { resolveSessionAccess } from './session-broker.mjs'; import { loadSnapshotMessages, @@ -21,6 +21,12 @@ const DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1000; const DEFAULT_RUN_HEARTBEAT_MS = 30 * 1000; const TOOL_GATEWAY_SUMMARY_LIMIT = 4096; +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + function nowMs() { return Date.now(); } @@ -33,6 +39,12 @@ function safeJsonParse(value, fallback = null) { } } +function parseDbJsonColumn(value, fallback = null) { + if (value == null || value === '') return fallback; + if (typeof value === 'object') return value; + return safeJsonParse(value, fallback); +} + function serializeMessage(message) { return JSON.stringify(message ?? {}); } @@ -49,12 +61,6 @@ function timeoutError(ms) { return err; } -function envFlag(value, fallback = false) { - const raw = String(value ?? '').trim().toLowerCase(); - if (!raw) return fallback; - return ['1', 'true', 'yes', 'on'].includes(raw); -} - export function normalizeAgentRunToolMode(value) { const normalized = String(value ?? 'chat').trim().toLowerCase(); if (!normalized || normalized === 'chat') return 'chat'; @@ -471,6 +477,10 @@ export function createAgentRunGateway({ const routing = await resolveRunRouting(row, userMessage, runOptions); const routingDecision = resolveLegacyRouteFromClassification(routing) ?? routing?.route ?? null; if (routing) { + logRouterDecisionShadow(routing, { + requestId: row.request_id ?? null, + sessionId: row.agent_session_id ?? null, + }); await appendEvent(runId, 'intent_routed', routing); if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.applyAgentOrchestration) { const grantedSkills = await resolveGrantedSkills(row.user_id); @@ -578,7 +588,11 @@ export function createAgentRunGateway({ return { sessionId: row.agent_session_id ?? null }; } - let sessionId = row.agent_session_id ?? null; + let sessionId = resolveGatewayAgentSessionId({ + agentSessionId: row.agent_session_id ?? null, + classification: routing, + forceDeepReasoning: runOptions.forceDeepReasoning, + }); let escalatedDirectSessionId = null; if (isDirectChatSessionId(sessionId)) { escalatedDirectSessionId = sessionId; @@ -640,16 +654,37 @@ export function createAgentRunGateway({ }); } await invalidatePortalDirectChatSnapshot(sessionId); - await tkmindProxy.submitSessionReplyForUser( - row.user_id, - sessionId, - row.request_id, - ensureGooseUserMessageMetadata(userMessage), - { - toolMode: runOptions.toolMode, - forceDeepReasoning: runOptions.forceDeepReasoning, - }, - ); + const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true) + && runOptions.toolMode === 'chat' + && typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function'; + if (awaitSessionFinish) { + const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser( + row.user_id, + sessionId, + row.request_id, + ensureGooseUserMessageMetadata(userMessage), + { + toolMode: runOptions.toolMode, + forceDeepReasoning: runOptions.forceDeepReasoning, + timeoutMs: runTimeoutMs, + }, + ); + await appendEvent(runId, 'session_finished', { + sessionId, + tokenState: finish.tokenState ?? null, + }); + } else { + await tkmindProxy.submitSessionReplyForUser( + row.user_id, + sessionId, + row.request_id, + ensureGooseUserMessageMetadata(userMessage), + { + toolMode: runOptions.toolMode, + forceDeepReasoning: runOptions.forceDeepReasoning, + }, + ); + } return { sessionId }; } @@ -966,7 +1001,7 @@ export function createAgentRunGateway({ events: rows.map((row) => ({ id: row.id, eventType: row.event_type, - data: row.data_json ? JSON.parse(row.data_json) : null, + data: parseDbJsonColumn(row.data_json, null), createdAt: Number(row.created_at), })), cursorMiss, diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 75aaf6d..a99df86 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -284,6 +284,36 @@ test('agent run starts a session and marks submitted reply as succeeded', async assert.equal(pool.runs.get(run.id).attempts, 1); }); +test('agent run awaits session Finish before succeeding when proxy supports it', async () => { + const pool = createFakePool(); + const awaited = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-finish-1' }; + }, + async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage) { + awaited.push({ userId, sessionId, requestId, userMessage }); + return { ok: true, finishEvent: { type: 'Finish' }, tokenState: { totalTokens: 9 } }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-finish-1', + userMessage: { role: 'user', content: [{ type: 'text', text: '世界杯现在赛况如何' }] }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(awaited.length, 1); + assert.equal(awaited[0].sessionId, 'session-finish-1'); + const finishEvents = pool.events.filter((item) => item.eventType === 'session_finished'); + assert.equal(finishEvents.length, 1); +}); + test('agent run uses direct chat service for eligible chat messages', async () => { const pool = createFakePool(); const directRuns = []; @@ -1458,6 +1488,35 @@ test('listRunEventsForUser replays events after Last-Event-ID cursor', async () assert.equal(replay.events[0].data.run.agentSessionId, 'sess-1'); }); +test('listRunEventsForUser accepts mysql2-parsed JSON objects in data_json', async () => { + const pool = createFakePool(); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: {}, + autoDispatch: false, + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-replay-json-object', + userMessage: { role: 'user', content: [] }, + }); + + const queuedAt = Number(pool.events.find((event) => event.runId === run.id)?.createdAt ?? 1000); + pool.events.push({ + id: 'evt-snapshot-object', + runId: run.id, + eventType: 'run_snapshot', + dataJson: { run: { ...run, status: 'running', agentSessionId: 'sess-mysql2' } }, + createdAt: queuedAt + 1000, + }); + + const batch = await gateway.listRunEventsForUser('user-1', run.id); + const snapshotEvent = batch.events.find((event) => event.id === 'evt-snapshot-object'); + assert.ok(snapshotEvent); + assert.equal(snapshotEvent.data.run.agentSessionId, 'sess-mysql2'); +}); + test('listRunEventsForUser reports cursorMiss for unknown Last-Event-ID', async () => { const pool = createFakePool(); const gateway = createAgentRunGateway({ diff --git a/capabilities.mjs b/capabilities.mjs index 8c73781..1531598 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -123,6 +123,13 @@ export const CAPABILITY_CATALOG = [ risk: 'low', category: 'productivity', }, + { + key: 'web', + label: '网页搜索', + description: '联网搜索与抓取(platform/web:web_search、fetch_url)', + risk: 'medium', + category: 'knowledge', + }, { key: 'computer', label: '电脑控制', @@ -182,6 +189,7 @@ export const DEFAULT_USER_CAPABILITIES = Object.fromEntries( extension_admin: false, apps: false, todo: false, + web: true, computer: false, charts: false, aider: false, @@ -401,6 +409,9 @@ export function buildAgentExtensionPolicy( if (capabilities.todo) { extensions.push(makeExtension('platform', 'todo', [])); } + if (capabilities.web) { + extensions.push(makeExtension('platform', 'web', ['web_search', 'fetch_url'])); + } if (capabilities.computer) { extensions.push(makeExtension('builtin', 'computercontroller', [])); } diff --git a/capabilities.test.mjs b/capabilities.test.mjs index a5a11b9..b714fe5 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -33,6 +33,32 @@ test('resolveSandboxMcpNodeExecPath honors container-path override without host assert.equal(resolveSandboxMcpNodeExecPath(''), process.execPath); }); +test('web capability mounts platform web extension with search tools', () => { + const caps = applyPoliciesToCapabilities( + { ...DEFAULT_USER_CAPABILITIES, web: true }, + { network_egress: 'allow' }, + ); + const policy = buildAgentExtensionPolicy(caps, { + policies: { network_egress: 'allow', goose_mode: 'auto' }, + }); + const web = policy.extensionOverrides.find((ext) => ext.name === 'web'); + assert.ok(web); + assert.equal(web.type, 'platform'); + assert.deepEqual(web.available_tools, ['web_search', 'fetch_url']); +}); + +test('web capability is stripped when network egress is denied', () => { + const caps = applyPoliciesToCapabilities( + { ...DEFAULT_USER_CAPABILITIES, web: true }, + { network_egress: 'deny' }, + ); + assert.equal(caps.web, false); + const policy = buildAgentExtensionPolicy(caps, { + policies: { network_egress: 'deny', goose_mode: 'auto' }, + }); + assert.equal(policy.extensionOverrides.some((ext) => ext.name === 'web'), false); +}); + test('default user policy blocks dangerous capabilities', () => { assert.equal(DEFAULT_USER_CAPABILITIES.shell, false); assert.equal(DEFAULT_USER_CAPABILITIES.filesystem, false); @@ -43,6 +69,7 @@ test('default user policy blocks dangerous capabilities', () => { assert.equal(DEFAULT_USER_CAPABILITIES.memory_store, true); assert.equal(DEFAULT_USER_CAPABILITIES.skills, true); assert.equal(DEFAULT_USER_CAPABILITIES.chat_recall, true); + assert.equal(DEFAULT_USER_CAPABILITIES.web, true); assert.equal(DEFAULT_USER_CAPABILITIES.aider, false); assert.equal(DEFAULT_USER_CAPABILITIES.openhands, false); }); diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index eca2a83..3ce2c5f 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -1,5 +1,6 @@ import { buildChatSkillPrompt, + buildWebNewsSkillPrompt, extractSelectedChatSkillName, hasExplicitChatSkillPrompt, } from './chat-skills.mjs'; @@ -63,6 +64,22 @@ const OBVIOUS_DIRECT_PATTERNS = [ /^[??]+$/u, ]; +/** Pure text chat/creative prompts — fast-path even when LLM router is enabled. */ +const OBVIOUS_DIRECT_CHAT_PATTERNS = [ + /(?:讲|说|来|编).{0,10}(?:个|一段|一首|一个)?(?:睡前故事|故事|笑话|段子)/u, + /(?:写|作|创|生成).{0,10}(?:首|一段|一篇|一个)?(?:诗|散文|笑话|段子|故事)/u, + /(?:继续|再).{0,8}(?:写|讲|说|编).{0,10}(?:诗|故事|笑话|散文)/u, +]; + +/** Realtime / web-search prompts — fast-path to agent even when LLM router is enabled. */ +const REALTIME_INFO_AGENT_PATTERNS = [ + /(?:世界杯|欧冠|NBA|英超|西甲|意甲|德甲|法甲|欧洲杯|亚洲杯|奥运会).{0,16}(?:赛况|赛程|比分|战况|结果|积分|排名|进展|情况)/u, + /(?:赛况|赛程|比分|战况|积分榜|最新比赛|比赛结果).{0,16}(?:如何|怎样|怎么样|多少|是什么)/u, + /(?:看看|查(?:一下|查)?|搜索|打听|了解).{0,16}(?:今天|今日|现在|最新|当前).{0,16}(?:新闻|赛况|比分|天气|行情|股价|汇率|热点)/u, + /(?:今天|今日|现在|最新|当前).{0,12}(?:的)?(?:新闻|赛况|比分|天气|行情|股价|汇率|热点)/u, + /(?:实时|最新).{0,8}(?:信息|数据|情况|动态|资讯)/u, +]; + const MEMORY_RECALL_PATTERNS = [ /你(?:还)?记得(?:我)?/u, /(?:有没有|是否).{0,6}记住/u, @@ -78,9 +95,48 @@ export function isMemoryRecallQuestion(text) { return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized)); } -const DEFAULT_ROUTER_TIMEOUT_MS = 1500; +export function isRealtimeInfoQuestion(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return REALTIME_INFO_AGENT_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +function buildRealtimeInfoClassification() { + return normalizeClassification({ + route: CHAT_INTENT_ROUTE.AGENT, + confidence: 0.98, + reason: '需要搜索实时资料', + suggested_skill: 'web', + agent_brief: REALTIME_WEB_AGENT_BRIEF, + }, { source: 'rule' }); +} + +export function coerceRealtimeWebSkill(classification, text, { grantedSkills = [] } = {}) { + if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification; + if (!isRealtimeInfoQuestion(text)) return classification; + if (grantedSkills.length > 0 && !grantedSkills.includes('web')) return classification; + const reason = String(classification.reason ?? '').trim(); + return { + ...classification, + suggestedSkill: 'web', + agentBrief: REALTIME_WEB_AGENT_BRIEF, + reason: reason.includes('web') ? reason : `${reason || '需要搜索实时资料'}(实时查询强制 web)`, + }; +} + +function resolveRouterFallbackRoute(env, overrides = {}) { + const raw = overrides.fallbackRoute + ?? normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE) + ?? CHAT_INTENT_ROUTE.AGENT; + // Timeout/parse failures must fail open to agent so realtime/tool tasks are not dropped. + return raw === CHAT_INTENT_ROUTE.DIRECT_CHAT ? CHAT_INTENT_ROUTE.AGENT : raw; +} + +const DEFAULT_ROUTER_TIMEOUT_MS = 2500; const DEFAULT_ROUTER_MEMORY_LIMIT = 8; const DEFAULT_ROUTER_MIN_CONFIDENCE = 0.55; +const REALTIME_WEB_AGENT_BRIEF = + '先 load_skill → web;优先 web_search/fetch_url 获取实时信息。若 web_search 不可用,立即改用 fetch_url 抓取 Bing/新华网/央视等可达来源;禁止使用 search 技能查工作区,不要反复 scrape 同一站点。'; function envFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); @@ -211,6 +267,7 @@ function buildRouterSystemPrompt(grantedSkills = []) { '判断原则:', '- 用户只要文字回答,不要求“做出来/发布/生成链接/改文件” → direct_chat', '- 用户要产出可访问页面、文件、链接,或需要工具/skills → agent_orchestration', + '- 用户询问实时赛况、新闻、天气、行情等需要联网查询的问题 → agent_orchestration,suggested_skill 填 web(不要填 search)', '- 不确定时优先 agent_orchestration,避免漏执行', '- 记忆线索只用于辅助判断本轮意图,不能替用户扩写新需求', '', @@ -338,8 +395,20 @@ function normalizeRoute(value) { return null; } +export function resolveNormalizedRouterDecisionMode(env = process.env) { + const raw = String(env?.MEMIND_ROUTER_NORMALIZED_DECISION ?? '').trim().toLowerCase(); + if (!raw || raw === '0' || raw === 'false' || raw === 'off' || raw === 'no') return 'off'; + if (raw === 'shadow') return 'shadow'; + if (['1', 'true', 'yes', 'on'].includes(raw)) return 'on'; + return 'off'; +} + export function isNormalizedRouterDecisionEnabled(env = process.env) { - return envFlag(env.MEMIND_ROUTER_NORMALIZED_DECISION, false); + return resolveNormalizedRouterDecisionMode(env) === 'on'; +} + +export function isNormalizedRouterDecisionShadow(env = process.env) { + return resolveNormalizedRouterDecisionMode(env) === 'shadow'; } export function buildRouterNormalizedDecision( @@ -404,8 +473,72 @@ export function resolveLegacyRouteFromClassification(classification) { return classification.route ?? null; } +export function logRouterDecisionShadow( + classification, + { logger = console, requestId = null, sessionId = null, env = process.env } = {}, +) { + if (!isNormalizedRouterDecisionShadow(env) || !classification?.decision) return; + const legacyRoute = classification.route ?? null; + const normalizedLegacyRoute = + classification.decision.route === ROUTER_DECISION_ROUTE.CHAT + ? CHAT_INTENT_ROUTE.DIRECT_CHAT + : CHAT_INTENT_ROUTE.AGENT; + const payload = { + requestId, + sessionId, + legacyRoute, + normalizedRoute: normalizedLegacyRoute, + decision: classification.decision, + source: classification.source ?? null, + reason: classification.reason ?? null, + wouldChangeRoute: legacyRoute != null && legacyRoute !== normalizedLegacyRoute, + }; + const line = `[router-shadow] ${JSON.stringify(payload)}`; + if (typeof logger.info === 'function') logger.info(line); + else logger.log(line); +} + +/** + * Apply router session_hint for goosed agent path without overriding hard rules. + * Returns null when gateway should start a fresh goosed session. + */ +export function resolveGatewayAgentSessionId({ + agentSessionId = null, + classification = null, + forceDeepReasoning = false, + enabled = isNormalizedRouterDecisionEnabled(), +} = {}) { + const sessionId = agentSessionId || null; + + if (forceDeepReasoning) { + return sessionId; + } + + if (!enabled || !classification?.decision) { + return sessionId; + } + + const decision = classification.decision; + if (Array.isArray(decision.flags) && decision.flags.includes('force_deep_reasoning')) { + return sessionId; + } + + if (!sessionId) { + return null; + } + + if (isDirectChatSessionId(sessionId)) { + return sessionId; + } + + if (decision.session_hint === ROUTER_DECISION_SESSION_HINT.NEW) { + return null; + } + + return sessionId; +} + export function resolveChatIntentRouterPolicy({ env = process.env, overrides = {} } = {}) { - const fallbackRoute = normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE) ?? CHAT_INTENT_ROUTE.AGENT; return { enabled: envFlag(env?.MEMIND_CHAT_LLM_ROUTER_ENABLED, false), modelProviderKeyId: String(env?.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID ?? '').trim() || null, @@ -428,8 +561,8 @@ export function resolveChatIntentRouterPolicy({ env = process.env, overrides = { DEFAULT_ROUTER_TIMEOUT_MS, { min: 0, max: 30_000 }, )), - fallbackRoute, ...overrides, + fallbackRoute: resolveRouterFallbackRoute(env, overrides), }; } @@ -455,10 +588,13 @@ function normalizeClassification(raw, { source, fallbackRoute = CHAT_INTENT_ROUT }; } -function resolveSkillPrompt(suggestedSkill, grantedSkills = []) { +function resolveSkillPrompt(suggestedSkill, grantedSkills = [], displayText = '') { const skillName = String(suggestedSkill ?? '').trim(); if (!skillName) return ''; if (grantedSkills.length > 0 && !grantedSkills.includes(skillName)) return ''; + if (skillName === 'web' && isRealtimeInfoQuestion(displayText)) { + return buildWebNewsSkillPrompt(skillName); + } const promptKey = SKILL_PROMPT_KEYS[skillName]; if (!promptKey) return ''; return buildChatSkillPrompt(promptKey, skillName); @@ -485,7 +621,7 @@ export function buildAgentOrchestrationAgentText({ export function applyAgentOrchestrationToUserMessage(userMessage, classification, { grantedSkills = [] } = {}) { const displayText = messageDisplayText(userMessage); - const skillPrompt = resolveSkillPrompt(classification?.suggestedSkill, grantedSkills); + const skillPrompt = resolveSkillPrompt(classification?.suggestedSkill, grantedSkills, displayText); const agentText = buildAgentOrchestrationAgentText({ displayText, classification, @@ -587,6 +723,9 @@ export function classifyWithRules({ agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接。', }, { source: 'rule' }), decisionContext); } + if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) { + return finalizeRouterClassification(buildRealtimeInfoClassification(), decisionContext); + } if ( includeIntentPatterns && normalized && @@ -598,6 +737,17 @@ export function classifyWithRules({ reason: '简单寒暄或连通性测试', }, { source: 'rule' }), decisionContext); } + if ( + includeIntentPatterns && + normalized && + OBVIOUS_DIRECT_CHAT_PATTERNS.some((pattern) => pattern.test(normalized)) + ) { + return finalizeRouterClassification(normalizeClassification({ + route: CHAT_INTENT_ROUTE.DIRECT_CHAT, + confidence: 0.95, + reason: '纯文字创作或闲聊,无需执行任务', + }, { source: 'rule' }), decisionContext); + } return null; } @@ -636,6 +786,7 @@ export function createChatIntentRouter(options = {}) { timeoutMs: policy.timeoutMs, fallbackRoute: policy.fallbackRoute, normalizedDecisionEnabled: isNormalizedRouterDecisionEnabled(env), + normalizedDecisionMode: resolveNormalizedRouterDecisionMode(env), }; } @@ -697,6 +848,15 @@ export function createChatIntentRouter(options = {}) { sessionId, sessionMessageCount, }; + const finalizeWithRealtimeCoercion = (classification) => { + if (!classification) return classification; + const base = { ...classification }; + delete base.decision; + return finalizeRouterClassification( + coerceRealtimeWebSkill(base, text, { grantedSkills }), + decisionContext, + ); + }; const ruleResult = classifyWithRules({ text, forceDeepReasoning, @@ -704,16 +864,16 @@ export function createChatIntentRouter(options = {}) { sessionId, sessionMessageCount, userMessage, - includeIntentPatterns: false, + includeIntentPatterns: true, llmRouterEnabled: Boolean(policy.enabled), }); - if (ruleResult) return ruleResult; + if (ruleResult) return finalizeWithRealtimeCoercion(ruleResult); if (!isEnabled()) { - return finalizeRouterClassification(normalizeClassification({ + return finalizeWithRealtimeCoercion(normalizeClassification({ route: policy.fallbackRoute, confidence: 0.5, reason: '意图路由未启用,走默认通道', - }, { source: 'fallback' }), decisionContext); + }, { source: 'fallback' })); } const routerContext = await resolveRouterContext({ @@ -745,47 +905,47 @@ export function createChatIntentRouter(options = {}) { 'Chat intent router', ); } catch (err) { - return finalizeRouterClassification(normalizeClassification({ + return finalizeWithRealtimeCoercion(normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: err instanceof Error ? err.message : '意图路由失败,走默认通道', memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext); + }, { source: 'fallback', fallbackRoute: policy.fallbackRoute })); } if (!completion?.ok) { - return finalizeRouterClassification(normalizeClassification({ + return finalizeWithRealtimeCoercion(normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: completion?.message ?? '意图路由失败,走默认通道', memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext); + }, { source: 'fallback', fallbackRoute: policy.fallbackRoute })); } const parsed = parseRouterJson(completion.reply); if (!parsed) { - return finalizeRouterClassification(normalizeClassification({ + return finalizeWithRealtimeCoercion(normalizeClassification({ route: policy.fallbackRoute, confidence: 0, reason: '意图路由响应无法解析,走默认通道', memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext); + }, { source: 'fallback', fallbackRoute: policy.fallbackRoute })); } - const classification = finalizeRouterClassification({ + const classification = finalizeWithRealtimeCoercion({ ...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }), providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null, model: completion.model ?? policy.model ?? null, memory: routerContext, - }, decisionContext); + }); if ( classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT && classification.confidence < policy.minConfidence ) { - return finalizeRouterClassification(normalizeClassification({ + return finalizeWithRealtimeCoercion(normalizeClassification({ ...classification, route: policy.fallbackRoute, reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`, - }, { source: 'threshold', fallbackRoute: policy.fallbackRoute }), decisionContext); + }, { source: 'threshold', fallbackRoute: policy.fallbackRoute })); } return classification; } diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index f497b39..63c7844 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -10,10 +10,17 @@ import { createChatIntentRouter, createManagedChatIntentRouter, isNormalizedRouterDecisionEnabled, + isNormalizedRouterDecisionShadow, + resolveGatewayAgentSessionId, resolveLegacyRouteFromClassification, + resolveNormalizedRouterDecisionMode, + logRouterDecisionShadow, ROUTER_DECISION_ROUTE, ROUTER_DECISION_MODE, ROUTER_DECISION_SESSION_HINT, + isRealtimeInfoQuestion, + coerceRealtimeWebSkill, + resolveChatIntentRouterPolicy, } from './chat-intent-router.mjs'; test('classifyWithRules routes greetings to direct chat', () => { @@ -434,7 +441,11 @@ test('createManagedChatIntentRouter hot-loads admin config and stays closed by d states.shift(); assert.equal(await router.isEnabled(), true); const result = await router.classify({ - userMessage: { role: 'user', content: [{ type: 'text', text: '你好' }] }, + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我概括一下这段材料的要点' }], + metadata: { displayText: '帮我概括一下这段材料的要点' }, + }, }); assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); assert.equal(llmCalls[0].providerKeyId, 'key-router'); @@ -485,8 +496,8 @@ test('createChatIntentRouter falls back to agent when LLM fails', async () => { const result = await router.classify({ userMessage: { role: 'user', - content: [{ type: 'text', text: '帮我查一下今天的 AI 新闻' }], - metadata: { displayText: '帮我查一下今天的 AI 新闻' }, + content: [{ type: 'text', text: '帮我概括一下这段材料的要点' }], + metadata: { displayText: '帮我概括一下这段材料的要点' }, }, grantedSkills: ['web'], }); @@ -495,6 +506,32 @@ test('createChatIntentRouter falls back to agent when LLM fails', async () => { assert.equal(result.source, 'fallback'); }); +test('createChatIntentRouter fast-paths news lookup to agent without LLM', async () => { + let llmCalls = 0; + const router = createChatIntentRouter({ + enabled: true, + llmProviderService: { + async createChatCompletion() { + llmCalls += 1; + return { ok: false, message: 'router model unavailable' }; + }, + }, + }); + + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我查一下今天的 AI 新闻' }], + metadata: { displayText: '帮我查一下今天的 AI 新闻' }, + }, + grantedSkills: ['web'], + }); + + assert.equal(llmCalls, 0); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'rule'); +}); + test('applyAgentOrchestrationToUserMessage preserves displayText and adds task envelope', () => { const enriched = applyAgentOrchestrationToUserMessage( { @@ -604,6 +641,286 @@ test('resolveLegacyRouteFromClassification prefers decision when flag enabled', } }); +test('resolveGatewayAgentSessionId honors session_hint without breaking hard rules', () => { + const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + process.env.MEMIND_ROUTER_NORMALIZED_DECISION = '1'; + try { + assert.equal( + resolveGatewayAgentSessionId({ + agentSessionId: 'sess-1', + classification: { + decision: { session_hint: ROUTER_DECISION_SESSION_HINT.REUSE }, + }, + }), + 'sess-1', + ); + assert.equal( + resolveGatewayAgentSessionId({ + agentSessionId: 'sess-1', + classification: { + decision: { session_hint: ROUTER_DECISION_SESSION_HINT.NEW }, + }, + }), + null, + ); + assert.equal( + resolveGatewayAgentSessionId({ + agentSessionId: 'h5direct_abc', + classification: { + decision: { session_hint: ROUTER_DECISION_SESSION_HINT.NEW }, + }, + }), + 'h5direct_abc', + ); + assert.equal( + resolveGatewayAgentSessionId({ + agentSessionId: 'sess-1', + forceDeepReasoning: true, + classification: { + decision: { session_hint: ROUTER_DECISION_SESSION_HINT.NEW }, + }, + }), + 'sess-1', + ); + } finally { + if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous; + } +}); + +test('resolveNormalizedRouterDecisionMode supports off, shadow, and on', () => { + const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + try { + delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + assert.equal(resolveNormalizedRouterDecisionMode(), 'off'); + process.env.MEMIND_ROUTER_NORMALIZED_DECISION = 'shadow'; + assert.equal(resolveNormalizedRouterDecisionMode(), 'shadow'); + assert.equal(isNormalizedRouterDecisionShadow(), true); + assert.equal(isNormalizedRouterDecisionEnabled(), false); + process.env.MEMIND_ROUTER_NORMALIZED_DECISION = '1'; + assert.equal(resolveNormalizedRouterDecisionMode(), 'on'); + assert.equal(isNormalizedRouterDecisionEnabled(), true); + } finally { + if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous; + } +}); + +test('shadow mode keeps legacy route for gateway decisions', () => { + const classification = { + route: CHAT_INTENT_ROUTE.DIRECT_CHAT, + decision: { route: ROUTER_DECISION_ROUTE.AGENT }, + }; + const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + process.env.MEMIND_ROUTER_NORMALIZED_DECISION = 'shadow'; + try { + assert.equal(resolveLegacyRouteFromClassification(classification), CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(isNormalizedRouterDecisionEnabled(), false); + } finally { + if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous; + } +}); + +test('classifyWithRules fast-paths bedtime story when llm router is enabled', () => { + const result = classifyWithRules({ + text: '讲一个睡前故事吧', + llmRouterEnabled: true, + sessionId: '20260706_2', + sessionMessageCount: 3, + userMessage: { + role: 'user', + content: [{ type: 'text', text: '讲一个睡前故事吧' }], + metadata: { displayText: '讲一个睡前故事吧' }, + }, + }); + assert.equal(result?.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(result?.source, 'rule'); + assert.equal(result?.decision?.route, ROUTER_DECISION_ROUTE.CHAT); +}); + +test('classifyWithRules fast-paths world cup standings to agent orchestration', () => { + const text = '世界杯现在赛况如何'; + assert.equal(isRealtimeInfoQuestion(text), true); + const result = classifyWithRules({ + text, + llmRouterEnabled: true, + userMessage: { + role: 'user', + content: [{ type: 'text', text }], + metadata: { displayText: text }, + }, + }); + assert.equal(result?.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result?.source, 'rule'); + assert.equal(result?.suggestedSkill, 'web'); + assert.match(result?.reason, /实时/); +}); + +test('createChatIntentRouter fast-paths world cup without calling router LLM', async () => { + let llmCalls = 0; + const router = createChatIntentRouter({ + enabled: true, + llmProviderService: { + async createChatCompletion() { + llmCalls += 1; + throw new Error('router should not be called'); + }, + }, + }); + + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ type: 'text', text: '世界杯现在赛况如何' }], + metadata: { displayText: '世界杯现在赛况如何' }, + }, + grantedSkills: ['web'], + }); + + assert.equal(llmCalls, 0); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'rule'); +}); + +test('resolveChatIntentRouterPolicy coerces direct_chat fallback to agent', () => { + const policy = resolveChatIntentRouterPolicy({ + env: { + MEMIND_CHAT_ROUTER_FALLBACK_ROUTE: 'direct_chat', + }, + }); + assert.equal(policy.fallbackRoute, CHAT_INTENT_ROUTE.AGENT); +}); + +test('coerceRealtimeWebSkill replaces search with web for world cup query', () => { + const coerced = coerceRealtimeWebSkill( + { + route: CHAT_INTENT_ROUTE.AGENT, + confidence: 0.9, + reason: '用户询问实时赛况,需要搜索最新信息', + suggestedSkill: 'search', + source: 'llm', + }, + '世界杯现在赛况如何', + { grantedSkills: ['web', 'search'] }, + ); + assert.equal(coerced.suggestedSkill, 'web'); + assert.match(coerced.agentBrief, /web_search/); +}); + +test('applyAgentOrchestrationToUserMessage uses web news prompt for realtime query', () => { + const enriched = applyAgentOrchestrationToUserMessage( + { + role: 'user', + content: [{ type: 'text', text: '世界杯现在赛况如何' }], + metadata: { displayText: '世界杯现在赛况如何', userVisible: true }, + }, + coerceRealtimeWebSkill( + { + route: CHAT_INTENT_ROUTE.AGENT, + confidence: 0.9, + reason: '需要搜索实时资料', + suggestedSkill: 'web', + source: 'rule', + }, + '世界杯现在赛况如何', + { grantedSkills: ['web'] }, + ), + { grantedSkills: ['web'] }, + ); + assert.match(enriched.content[0].text, /请使用 web 技能:先搜索今天\/最新相关的新闻与热点/); + assert.doesNotMatch(enriched.content[0].text, /search 技能:帮我在工作区中查找代码或文件/); + assert.match(enriched.content[0].text, /web_search/); +}); + +test('createChatIntentRouter coerces LLM search route to web for realtime query', async () => { + const router = createChatIntentRouter({ + enabled: true, + llmProviderService: { + async createChatCompletion() { + return { + ok: true, + reply: JSON.stringify({ + route: 'agent_orchestration', + confidence: 0.92, + reason: '用户询问实时赛况,需要搜索最新信息', + suggested_skill: 'search', + agent_brief: '搜索赛况', + }), + }; + }, + }, + }); + + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ type: 'text', text: '世界杯现在赛况如何' }], + metadata: { displayText: '世界杯现在赛况如何' }, + }, + grantedSkills: ['web', 'search'], + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.suggestedSkill, 'web'); + assert.match(result.agentBrief, /web_search/); +}); + +test('createChatIntentRouter timeout fallback prefers agent even when policy requests direct_chat', async () => { + const router = createChatIntentRouter({ + enabled: true, + timeoutMs: 5, + fallbackRoute: CHAT_INTENT_ROUTE.DIRECT_CHAT, + llmProviderService: { + async createChatCompletion() { + await new Promise((resolve) => setTimeout(resolve, 20)); + return { ok: true, reply: '{"route":"direct_chat","confidence":0.9,"reason":"x","suggested_skill":null,"agent_brief":""}' }; + }, + }, + }); + + const result = await router.classify({ + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我整理一下这段材料' }], + metadata: { displayText: '帮我整理一下这段材料' }, + }, + }); + + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'fallback'); +}); + +test('logRouterDecisionShadow emits payload only in shadow mode', () => { + const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + const lines = []; + const logger = { log: (line) => lines.push(line) }; + const classification = { + route: CHAT_INTENT_ROUTE.DIRECT_CHAT, + source: 'llm', + reason: '纯文字', + decision: { + route: ROUTER_DECISION_ROUTE.AGENT, + mode: ROUTER_DECISION_MODE.SSE, + session_hint: ROUTER_DECISION_SESSION_HINT.REUSE, + flags: [], + }, + }; + try { + process.env.MEMIND_ROUTER_NORMALIZED_DECISION = '0'; + logRouterDecisionShadow(classification, { logger, requestId: 'req-1' }); + assert.equal(lines.length, 0); + process.env.MEMIND_ROUTER_NORMALIZED_DECISION = 'shadow'; + logRouterDecisionShadow(classification, { logger, requestId: 'req-1' }); + assert.equal(lines.length, 1); + assert.match(lines[0], /\[router-shadow\]/); + assert.match(lines[0], /wouldChangeRoute":true/); + } finally { + if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION; + else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous; + } +}); + test('createChatIntentRouter accepts chat and agent route synonyms from llm', async () => { const router = createChatIntentRouter({ enabled: true, diff --git a/chat-skills.mjs b/chat-skills.mjs index cb351e6..7c797b9 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -136,6 +136,8 @@ function buildWebNewsSkillPrompt(skillName) { return `请使用 ${skillName ?? 'web'} 技能:先搜索今天/最新相关的新闻与热点,优先一手来源和权威媒体,整理 3-5 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`; } +export { buildWebNewsSkillPrompt }; + export function filterChatSkills(options, ctx) { return options.filter((skill) => { if (skill.requiresPublish && !ctx.canPublish) return false; diff --git a/db.mjs b/db.mjs index 189e67f..d257cb2 100644 --- a/db.mjs +++ b/db.mjs @@ -718,6 +718,22 @@ export async function migrateSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + // Session stream replay events (Patch 4c). Rollback: DROP TABLE h5_session_stream_events. + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_session_stream_events ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + agent_session_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + payload_json LONGTEXT NOT NULL, + upstream_event_id VARCHAR(255) NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_session_stream_session (agent_session_id, created_at, id), + KEY idx_h5_session_stream_user (user_id, created_at), + CONSTRAINT fk_h5_session_stream_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + // Subscription plan table for tiered monthly billing. // Rollback: DROP TABLE h5_subscriptions — safe, no dependants. await pool.query(` diff --git a/docs/h5-session-architecture-20260706.md b/docs/h5-session-architecture-20260706.md new file mode 100644 index 0000000..0e52f32 --- /dev/null +++ b/docs/h5-session-architecture-20260706.md @@ -0,0 +1,961 @@ +# H5 Session 架构改造与开发设计方案 + +日期: 2026-07-06 + +状态: Phase 1 已完成;Phase 2 Patch 3/4a/4b/4c 已落地(分支 `0706-bug干净`,待全量 soak 后合并)。 + +> 旧路径 [h5-session-gouzao0706.md](./h5-session-gouzao0706.md) 保留重定向至本文件。 + +适用范围: + +- H5 前端聊天主链路。 +- Memind Portal 内的 router、agent-run-gateway、tkmind-proxy、session ownership、SSE stream。 +- goosed 作为 agent runtime 的接入边界。 + +非目标: + +- 不重写 H5 UI。 +- 不重写 goosed。 +- 不引入新的大型框架或新平台。 +- 不一次性重做完整 replay/event sourcing。 +- 不改变当前生产发布规则和回归守卫。 +- 不重复实施 [Memind 2.0 Streaming Agent Runtime 计划](./architecture/memind-2-streaming-agent-runtime-plan.md) 中已落地的 worker/queue/SLO 能力。 + +## 0. 结论 + +本次改造的本质不是重构系统,而是把当前 H5 + Portal + goosed 链路从: + +```text +socket/runtime 牵引的会话执行链路 +``` + +收敛为: + +```text +Portal control plane + goosed execution runtime +``` + +最重要的架构转折点: + +```text +session 不再 socket 化。 +``` + +Session 必须由 Portal 侧的 store/broker 管理,WebSocket/SSE/HTTP 都只能是 transport,不能成为 session state 的事实源。 + +当前系统已经具备较好的基础: + +- H5 已经主要通过 `POST /agent/runs` 提交聊天/agent 请求。 +- 旧的 `POST /sessions/:id/reply` 已经被拦截为 `410 AGENT_RUNS_REQUIRED`。 +- `h5_user_sessions` 已经保存 session ownership、origin、`goosed_node`、`goosed_target`。 +- `h5_agent_runs` 和 `h5_agent_run_events` 已经承担 run lifecycle 和基础事件记录。 +- H5 session event stream 已经走 SSE,并且前端已有 `Last-Event-ID` 传递。 + +因此第一阶段不需要“建一个新系统”,而是抽一层很薄的 Session Broker,把已有能力从 `user-auth.mjs`、`tkmind-proxy.mjs`、`server.mjs`、`agent-run-gateway.mjs` 及若干旁路入口的散落调用中收口。 + +### 0.1 与相关文档的关系 + +| 文档 | 关系 | +|---|---| +| [Memind 2.0 Streaming Agent Runtime 计划](./architecture/memind-2-streaming-agent-runtime-plan.md) | 已落地的 streaming/runtime、worker queue、SLO、tool gateway 等;**本文在其之上做 session 控制面收口,不重复 P6–P8 内容** | +| [Memind / MindSpace / Goose 解耦推进方案](./memind-control-execution-split-plan-20260702.md) | 更广的 Control/Execution + MindSpace 服务化;本文聚焦 H5 session ownership,不涉及 MindSpace 独立部署 | +| [MindSpace 发布与聊天 Finish 回归守卫](./regression-guards/mindspace-publish-and-chat-finish.md) | Patch 4 及 stream finish 相关改动**必须遵守** | +| [103 Runtime Topology](./103-runtime-topology.md) | 生产 goosed target 拓扑参考;broker 的 `goosed_target` pinning 与之对齐 | + +## 1. 当前结构与主要问题 + +### 1.1 当前实际链路 + +```mermaid +flowchart LR + H5["H5 useTKMindChat"] --> API["src/api/client.ts"] + API --> RUN["POST /agent/runs"] + RUN --> GW["agent-run-gateway.mjs"] + GW --> ROUTER["chat-intent-router.mjs"] + GW --> DIRECT["direct-chat-service.mjs"] + GW --> PROXY["tkmind-proxy.mjs"] + PROXY --> GOOSE["goosed runtime"] + H5 --> RUNSSE["GET /agent/runs/:runId/events"] + H5 --> SESSSE["GET /sessions/:sessionId/events"] + RUNSSE --> GW + SESSSE --> PROXY + PROXY --> GOOSE +``` + +这个链路比早期状态更健康: H5 已经不是直接把普通聊天发到 goosed reply path。 + +但仍有三个核心问题: + +1. session lifecycle、ownership、target mapping 仍散落在多个模块里(见 §5.6 调用点清单)。 +2. gateway 同时处理 run lifecycle、router、direct chat、tool gateway、goosed session 创建,边界偏厚。 +3. SSE 已经存在,但事件语义还没有统一成稳定协议,尤其是 run stream 与 session stream 的终态/恢复语义不完全一致(见 §1.3)。 + +### 1.2 当前已有资产 + +当前不应重复建设的能力: + +- `user-auth.mjs` + - `registerAgentSession(userId, agentSessionId, goosedTarget)` + - `getSessionTarget(agentSessionId)` + - `ownsSession(userId, agentSessionId)` + - `unregisterAgentSession(userId, agentSessionId)` +- `db.mjs` + - 自动补齐 `h5_user_sessions.goosed_node` + - 自动补齐 `h5_user_sessions.goosed_target` + - 自动补齐 `h5_user_sessions.origin` + - 注意: `schema.sql` 中 `h5_user_sessions` 基表可能滞后于 runtime migration;**以 `db.mjs` 启动迁移为准**,Patch 1 实施时建议同步更新 `schema.sql` 注释或列定义。 +- `tkmind-proxy.mjs` + - goosed target 选择与 session target 解析。 + - `/sessions/:id/events` SSE proxy。 + - stream started/ended metrics。 + - `reconcileAgentSession` 调用(resume 路径)。 +- `session-snapshot` 相关(`sessionSnapshotService`) + - direct chat / portal snapshot 缓存。 + - `GET /sessions/:id` 在 hint 匹配时的 DB snapshot 快路径。 + - gateway 执行前 transcript persist。 + - **不是** ownership 事实源;与 broker 并列存在(见 §5.6)。 +- `agent-run-gateway.mjs` + - `h5_agent_runs` row。 + - `h5_agent_run_events` 基础事件。 + - queued/running/retryable/succeeded/failed 状态。 + - worker heartbeat 和 stale run recovery。 + +这些能力应该被收口和命名,不应被复制。 + +### 1.3 H5 双 SSE 通道现状 + +H5(`useTKMindChat.ts` + `src/api/client.ts`)在 agent 路径上**同时**订阅两条 SSE/事件通道: + +| 通道 | 端点 | 职责 | 终态信号 | +|---|---|---|---| +| Run stream | `GET /agent/runs/:runId/events` | run 排队/执行状态、`sessionId` 回填、direct chat 触发 snapshot 轮询 | `h5_agent_runs.status` → `succeeded` / `failed` | +| Session stream | `GET /sessions/:sessionId/events` | assistant token、tool call、finish 等 goosed 运行时事件 | goosed `finish` / `error` 等 terminal event | + +典型时序: + +```text +POST /agent/runs + → subscribeAgentRunEvents(runId) # 等待 run 终态、拿 sessionId + → subscribeSessionEvents(sessionId) # 接收流式内容(非 direct chat) + → finish 后 syncSessionMessages # merge,禁止盲覆盖 +``` + +这解释了 §1.1 第三点: **run 终态与 session 终态不是同一事件**,direct chat 甚至主要依赖 run 成功 + snapshot poll,而非 session SSE。 + +Patch 4 必须明确: + +- **run stream**: 保持现有 DB 驱动 polling/SSE,第一阶段不做 replay。 +- **session stream**: 固化 delta/control/terminal 语义、`Last-Event-ID`、finish 后 idle/sync。 +- **禁止** 在 Patch 4 中合并两条通道或改变 H5 订阅顺序。 + +## 2. 目标架构 + +```mermaid +flowchart LR + H5["H5"] --> PORTAL["Portal Control Plane"] + PORTAL --> ROUTER["Router\n decision only"] + PORTAL --> BROKER["Session Broker\n ownership + mapping"] + PORTAL --> GATEWAY["Agent Gateway\n run lifecycle"] + PORTAL --> STREAM["SSE Stream Module\n logical, in tkmind-proxy"] + PORTAL --> SNAP["Session Snapshot\n conversation cache"] + GATEWAY --> PROXY["goosed Proxy Adapter"] + STREAM --> PROXY + PROXY --> GOOSE["goosed\n execution runtime"] + BROKER --> DB["MySQL\n h5_user_sessions"] + GATEWAY --> RUNDB["MySQL\n h5_agent_runs/events"] + SNAP --> SNAPSTORE["Redis / in-memory\n not ownership"] +``` + +说明: + +- **SSE Stream Module** 是逻辑模块,当前与 `tkmind-proxy.mjs`、`server.mjs` 路由同进程部署,**不是**独立服务。 +- **Session Snapshot** 缓存 conversation,与 Broker 分工不同,不可被 broker 吸收。 + +边界原则: + +| 层 | 可以做 | 禁止做 | +|---|---|---| +| Router | 判断 `chat/agent`、`reuse/new`、`sse/ws` | 执行工具、写 memory、创建 session | +| Session Broker | ownership、target mapping、origin/timestamps | message history、tool state、execution state、memory | +| Session Snapshot | conversation 缓存、merge 输入、direct chat snapshot | ownership、goosed target、计费 | +| Agent Gateway | run lifecycle、排队、heartbeat、调用 broker/proxy | 直接持有 session mapping、绕过 proxy 打 goosed | +| Proxy Adapter | goosed HTTP/SSE transport、headers、abort、backpressure、`reconcileAgentSession` | 用户业务决策、router 决策 | +| goosed | runtime session、tool execution、event emission | H5 用户归属、计费、memory policy、业务路由 | + +## 3. 协议分层 + +### 3.1 主链路 + +```text +H5 -> Portal + POST /agent/runs + +H5 <- Portal + GET /agent/runs/:runId/events + GET /sessions/:sessionId/events + +Portal -> goosed + POST /agent/start + POST /sessions/:sessionId/reply + GET /sessions/:sessionId/events + GET /sessions/:sessionId +``` + +说明: + +- H5 不直接调用 goosed。 +- H5 普通聊天不走 WebSocket。 +- WebSocket 只允许用于真正双向交互场景,例如 terminal、远程 shell、实时协作,不作为 H5 chat/session state 的事实源。 +- SSE 是 H5 chat/agent stream 的主通道。 + +### 3.2 SSE 事件分类 + +第一阶段只做协议语义固化,不做完整 event sourcing。 + +统一把事件归为三类: + +| 类别 | 含义 | 例子 | +|---|---|---| +| `delta` | 内容增量 | assistant token、message chunk | +| `control` | 状态变化 | active request、tool call、tool confirmation、balance、heartbeat | +| `terminal` | 结束态 | finish、failed、canceled、error | + +注意: + +- `h5_agent_runs.status` 第一阶段不改枚举,仍保持 `queued/running/retryable/succeeded/failed`。 +- `streaming/tool_call/completed/canceled` 先作为 event 语义,不直接写入 DB status。 +- `Last-Event-ID` 在 session stream 上继续保留;run stream 的 replay 后续再做,不在第一刀完成。 +- **v0 策略**: taxonomy 先在 Portal 侧文档化 + 单测断言映射关系,**不改变** goosed 原始 event name 的 wire format;H5 仍按现有 event type 解析,仅在注释/测试层标注 taxonomy。 + +### 3.3 SSE event type → taxonomy 映射(v0 参考) + +以下为 Patch 4 测试与文档用的**语义映射**,不要求 goosed 改协议: + +| 来源 | 典型 event / 信号 | taxonomy | 备注 | +|---|---|---|---| +| session SSE | message / content chunk | `delta` | assistant token 流 | +| session SSE | tool call / confirmation / balance | `control` | 不改变 active request 终态 | +| session SSE | finish | `terminal` | 必须触发前端 idle + `syncSessionMessages` merge | +| session SSE | error | `terminal` | headers sent 后走 SSE frame,不走 JSON | +| run SSE | status: running | `control` | DB 驱动 | +| run SSE | status: succeeded / failed | `terminal` | 关闭 run 订阅;agent 路径再依赖 session finish | + +## 4. Router 改造设计 + +### 4.1 目标 + +Router 从单纯 classifier 收敛为轻量 decision output,但不升级成复杂 decision engine。 + +### 4.2 输出结构 + +新增统一输出字段: + +```json +{ + "route": "chat", + "mode": "sse", + "session_hint": "reuse", + "flags": [] +} +``` + +字段定义: + +| 字段 | 允许值 | 含义 | +|---|---|---| +| `route` | `chat` / `agent` | 走直接聊天还是 agent 执行 | +| `mode` | `sse` / `ws` | 事件传输协议建议;H5 chat **固定消费 sse** | +| `session_hint` | `reuse` / `new` | 倾向复用当前 session 还是新建 | +| `flags` | string array | 受控扩展位,只放风险/能力标签,不放执行计划 | + +兼容要求: + +- 旧值 `direct_chat` 等价于 `chat`。 +- 旧值 `agent_orchestration` 等价于 `agent`。 +- 旧字段 `suggestedSkill`、`agentBrief` 第一阶段可以继续保留给现有逻辑使用,但不能扩展成多步 plan。 + +Router 可以读取上下文和 memory summary 做判断,但禁止: + +- 创建 session。 +- 写 memory。 +- 执行 tool。 +- 选择具体 goosed target。 +- 输出多步执行计划。 + +### 4.3 字段消费方与语义 + +| 字段 | 消费者 | 第一阶段行为 | +|---|---|---| +| `route` | `agent-run-gateway.mjs` | 与现有 `direct_chat` / `agent_orchestration` 等价映射 | +| `mode` | 暂无 H5 消费 | H5 chat 始终 SSE;`ws` 仅预留给 terminal/shell 等非 chat 场景,router 对 H5 chat **默认输出 `sse`** | +| `session_hint` | `agent-run-gateway.mjs` | **建议性** hint,不 override 硬规则(见下) | +| `flags` | gateway / 日志 | 能力标签,不触发执行 | + +`session_hint` 与 gateway 硬规则(hint 不能 override): + +```text +session_id 为空 → 必须新建 session(无论 hint) +session_id 无效/无 ownership → 403 或新建(沿用现有 gateway 行为) +direct chat 升级 deep reasoning → 清空 sessionId,强制 goosed start(hint reuse 失效) +force_deep_reasoning flag → 同上 +tool gateway / code run → 可能无 goosed session(agent_session_id NULL),broker 不适用 +isDirectChatSessionId → 不走 goosed session stream +``` + +第一阶段 **H5 不读取** router normalized 字段;仅 gateway 内部使用,便于后续扩展。 + +### 4.4 flags 约束 + +允许的 flags 示例: + +```text +memory_recall +selected_skill +force_deep_reasoning +code_task +long_running +``` + +不允许的 flags: + +```text +run_tool_x +write_file_y +use_worker_2 +bill_as_x +inject_memory_y +``` + +## 5. Session Broker 设计 + +### 5.1 目标 + +Session Broker 是一个薄 facade,不是第二套 session store。 + +它只负责: + +- session ownership。 +- session origin。 +- session 与 goosed target 的 mapping。 +- session lifecycle timestamp。 +- session 删除/解绑。 + +它不负责: + +- message history。 +- tool state。 +- execution state。 +- memory。 +- SSE replay。 +- billing。 +- provider selection。 + +### 5.2 与 Session Snapshot、session-reconcile 的边界 + +| 模块 | 职责 | 与 Broker 关系 | +|---|---|---| +| **Session Broker** | `h5_user_sessions` ownership + target | 事实源 | +| **Session Snapshot** | conversation 缓存、direct chat snapshot | broker 不管内容;snapshot **不得**替代 ownership 校验 | +| **session-reconcile.mjs** | goosed session resume 时 working_dir 等字段对齐 | 由 **proxy adapter** 在 transport 层调用;broker 不封装 reconcile 逻辑 | + +回归约束: `GET /sessions/:id` snapshot 缓存仅在 `hint_mc` **且** `hint_ua` 均匹配时命中(见 regression guard)。 + +### 5.3 建议文件 + +```text +session-broker.mjs +session-broker.test.mjs +``` + +### 5.4 API v0 + +```js +export function createSessionBroker({ userAuth, tkmindProxy = null }) { + return { + async validateOwnership(userId, sessionId) {}, + async registerSession({ userId, sessionId, target, origin = 'h5' }) {}, + async unregisterSession({ userId, sessionId }) {}, + async resolveSession(sessionId) {}, + async resolveSessionTarget(sessionId) {}, + }; +} +``` + +第一阶段不建议让 broker 直接承接所有 goosed start 行为。更稳妥的顺序是: + +1. v0 只包现有 `userAuth` session 函数。 +2. `tkmind-proxy.mjs` 内部改为通过 broker 记录/读取 target。 +3. `agent-run-gateway.mjs` 再通过 broker 做 ownership/session resolution。 +4. 旁路入口(§5.6)逐步改走 broker。 + +### 5.5 direct chat 兼容 + +当前 direct chat session 使用 `h5direct_` 前缀,并通过: + +```text +registerAgentSession(userId, activeSessionId, 'h5-direct') +``` + +写入 session 表。 + +Broker 必须把 `h5-direct` 当成合法 target/origin 信息处理,不能强行要求所有 session 都对应 goosed URL。 + +### 5.6 WeChat 兼容 + +`h5_user_sessions.origin` 已有 `h5/wechat` 语义。Broker v0 必须保留 origin,不要把 WeChat dedicated session 当成普通 H5 session 清理或旋转。 + +### 5.7 Session ownership 调用点清单(Patch 2 必须覆盖) + +以下模块当前直接调用 `userAuth.registerAgentSession` / `ownsSession` / `getSessionTarget`,Patch 2 需全部纳入 broker 收口计划: + +| 模块 | 典型调用 | Patch 批次 | +|---|---|---| +| `tkmind-proxy.mjs` | register / getSessionTarget / ownsSession | 2a | +| `agent-run-routes.mjs` | ownsSession(POST /agent/runs) | 2a | +| `agent-run-gateway.mjs` | 间接 via tkmindProxy start | 2b | +| `direct-chat-service.mjs` | register `h5-direct` | 2b | +| `server.mjs` | 多处 ownsSession(sessions CRUD、snapshot、delete 等) | 2c | +| `wechat-mp.mjs` | registerAgentSession | 2c | +| `mindspace-page-edit-session.mjs` | ownsSession / registerAgentSession | 2c | +| `mindspace-agent-runner.mjs` | registerAgentSession | 2c | +| `mindspace-conversation-package-routes.mjs` | ownsSession | 2c | + +**Patch 2 完成定义(量化)**: + +```bash +# 除 session-broker.mjs 外,不应再直接调用 userAuth.registerAgentSession +rg 'userAuth\.registerAgentSession' --glob '*.mjs' | rg -v 'session-broker\.mjs|user-auth\.mjs|\.test\.mjs' + +# ownsSession 同理(测试 mock 除外) +rg 'userAuth\.ownsSession' --glob '*.mjs' | rg -v 'session-broker\.mjs|user-auth\.mjs|\.test\.mjs' +``` + +broker 未覆盖 tool gateway / code run 的 **无 session** 路径: 这类 run 的 `agent_session_id` 可为 NULL,不经过 broker。 + +## 6. Agent Gateway 改造设计 + +### 6.1 当前边界 + +当前 `agent-run-gateway.mjs` 实际承担: + +- run 创建。 +- request idempotency。 +- router 调用。 +- direct chat 分支。 +- tool gateway 分支。 +- goosed session 创建。 +- submit reply。 +- heartbeat。 +- stale recovery。 + +因此第一阶段不能按理想终态一次性“去 orchestration 化”,否则改动会明显变大。 + +### 6.2 第一阶段只抽 session 相关逻辑 + +第一阶段改造目标: + +- 保留 run queue、heartbeat、stale recovery。 +- 保留 direct chat 和 tool gateway 分支。 +- 把 session ownership、session target mapping、session register/unregister 的调用收口到 broker。 + +建议变化: + +```text +agent-run-gateway + before: direct userAuth/tkmindProxy session calls + after: sessionBroker + tkmindProxy adapter +``` + +不要在第一阶段做: + +- 删除 direct chat 分支。 +- 重写 retry policy。 +- 重写 stale recovery。 +- 重写 run status enum。 +- 重写 SSE run event handler。 + +### 6.3 Gateway 最终边界 + +长期目标: + +```text +Gateway = run lifecycle +Broker = session lifecycle +Proxy = runtime transport +Router = decision only +``` + +但实施必须分阶段完成。 + +## 7. Proxy / goosed 边界设计 + +### 7.1 硬边界(H5 chat 主链路) + +H5 chat / agent 主链路上,Portal 到 goosed 的调用必须通过 `tkmind-proxy.mjs` 或其拆分后的 proxy adapter。 + +禁止**新增**: + +```text +server.mjs -> direct fetch goosed # H5 chat 路径 +agent-run-gateway.mjs -> direct fetch goosed +chat-intent-router.mjs -> direct fetch goosed +H5 -> direct goosed +``` + +### 7.2 已知例外(MindSpace 专用路径) + +以下模块当前通过 `apiTarget` 直连 goosed,**不在 H5 chat 主链路内**。Patch 5 静态检查须显式 allowlist,避免误报: + +| 模块 | 用途 | Patch 5 策略 | +|---|---|---| +| `mindspace-agent-runner.mjs` | MindSpace agent 任务 | 保留独立 adapter;长期可迁入 proxy,第一阶段 document + allowlist | +| `mindspace-page-edit-session.mjs` | 页面编辑 session | 同上 | +| `scripts/*` 运维脚本 | 本地调试/fix | 排除在 prod boundary check 外 | + +原则: **新增** goosed 调用必须走 proxy;存量 MindSpace 路径记录在案,不阻塞 Patch 1–4。 + +### 7.3 goosed 保留能力 + +goosed 继续负责: + +- `/agent/start` +- `/sessions/:sessionId/reply` +- `/sessions/:sessionId/events` +- `/sessions/:sessionId` +- tool execution +- runtime event emission + +goosed 不新增负责: + +- H5 user ownership。 +- 余额/计费。 +- router 结果。 +- memory policy。 +- session broker 事实源。 + +### 7.4 target mapping + +当前 `goosed_target` 已经用于 session pinning。Broker 不应替代这个字段,而应封装它。 + +目标行为: + +```text +new session: + proxy pick target + goosed start returns session id + broker records session_id -> target + +existing session: + broker resolves session_id -> target + proxy sends reply/events to same target +``` + +## 8. SSE 设计 + +### 8.1 已有能力 + +已有能力: + +- H5 session events 使用 fetch stream。 +- 前端维护 `lastEventId` 并传 `Last-Event-ID`。 +- Portal proxy 透传 `Last-Event-ID` 到 goosed。 +- Portal proxy 已处理 keepalive、abort、backpressure、billing transform、finish 后 snapshot refresh。 + +这些不能被第一阶段改造破坏。 + +### 8.2 第一阶段目标 + +只做语义固化: + +- 明确事件分类: `delta/control/terminal`(见 §3.3 映射表)。 +- 明确 session stream terminal event 必须关闭当前 active request,并走 `syncSessionMessages` merge(`useTKMindChat.ts`)。 +- 明确 run stream terminal(succeeded/failed)关闭 run 订阅,**不替代** session finish 同步。 +- 错误统一为 SSE `event: error`,不要在 header sent 后再走 JSON error。 +- `GET /agent/runs/:runId/events` 保持现有兼容,不强行改成完整 replay。 + +### 8.3 后续目标 + +第二阶段再考虑: + +- run event id。 +- run event replay。 +- session-level replay cursor。 +- reconnect 后从 DB 补 terminal state。 + +## 9. Run State Model + +### 9.1 现状 + +DB status 当前保持: + +```text +queued +running +retryable +succeeded +failed +``` + +这些状态已经被 gateway、worker、status API、测试和前端使用。 + +### 9.2 目标语义 + +逻辑状态可以扩展为: + +```text +created +queued +running +streaming +tool_call +completed +failed +canceled +``` + +但第一阶段不改 DB enum,只做映射: + +| 逻辑状态 | 第一阶段承载方式 | +|---|---| +| `created` | run row insert 前后 | +| `queued` | `h5_agent_runs.status = queued` | +| `running` | `h5_agent_runs.status = running` | +| `streaming` | session SSE control event | +| `tool_call` | session SSE control event | +| `completed` | `succeeded` + terminal event | +| `failed` | `failed` + terminal/error event | +| `canceled` | 后续新增,不在第一阶段强推 | + +### 9.3 Tool gateway / code run 说明 + +tool gateway 与 code run 路径可能: + +- 不创建 goosed session(`h5_agent_runs.agent_session_id` 为 NULL)。 +- 由 external worker / Aider / OpenHands 执行。 + +这类 run **不经过 Session Broker** 的 target mapping;gateway 仍负责 run lifecycle。避免在 broker 设计中假设“每个 run 必有 agent session”。 + +## 10. 5 个 Patch 的实施版本 + +### Patch 1: Session Broker facade + +目标: + +- 新增 `session-broker.mjs`。 +- 单测覆盖 ownership、target mapping、direct chat target、legacy node fallback。 +- 不改变行为。 + +改动文件: + +```text +session-broker.mjs +session-broker.test.mjs +``` + +验收: + +- 所有现有 session 读写仍通过同一张 `h5_user_sessions`。 +- 不新增 session cache。 +- 不新增 message/tool/memory 字段。 +- `schema.sql` 与 `db.mjs` 列定义对齐(或文档注明以 migration 为准)。 + +### Patch 2: Proxy、Gateway 与旁路入口接入 Broker + +目标: + +- `tkmind-proxy.mjs` 中 session register/resolve 通过 broker facade。 +- `agent-run-routes.mjs` 中 ownership 校验通过 broker facade。 +- `agent-run-gateway.mjs` 只在 session 创建/复用处接 broker,不动 direct/tool/retry。 +- `direct-chat-service.mjs`、`server.mjs`、`wechat-mp.mjs` 等 §5.7 清单模块改走 broker。 + +改动文件: + +```text +# 2a +tkmind-proxy.mjs +agent-run-routes.mjs + +# 2b +agent-run-gateway.mjs +direct-chat-service.mjs + +# 2c +server.mjs +wechat-mp.mjs +mindspace-page-edit-session.mjs +mindspace-agent-runner.mjs +mindspace-conversation-package-routes.mjs +``` + +验收: + +- `POST /agent/runs` 行为不变。 +- `GET /sessions/:sessionId/events` 行为不变。 +- direct chat session 仍能创建和读取 snapshot。 +- goosed target pinning 仍正确。 +- §5.7 量化 grep 检查通过(测试 mock 除外)。 + +Feature flag(建议): + +```text +MEMIND_SESSION_BROKER_ENABLED=1 # 默认 off → on;off 时 fallback 到 userAuth 直调 +``` + +### Patch 3: Router 输出收敛 + +目标: + +- 在旧 router classification 上新增 normalized decision。 +- 输出 `route/mode/session_hint/flags`。 +- 旧字段继续兼容。 + +改动文件: + +```text +chat-intent-router.mjs +chat-intent-router.test.mjs +agent-run-gateway.mjs +``` + +验收: + +- 旧 direct chat / agent orchestration 判断不变。 +- Router 不执行 tool。 +- Router 不创建 session。 +- Router 不写 memory。 +- H5 chat 场景 `mode` 默认 `sse`;`session_hint` 不 override gateway 硬规则(§4.3)。 + +Feature flag(建议): + +```text +MEMIND_ROUTER_NORMALIZED_DECISION=1 # shadow: 只打日志不写行为;on: gateway 读取新字段 +``` + +### Patch 4: SSE event contract v0 + +目标: + +- 文档化并测试 delta/control/terminal 分类(§3.3)。 +- 保持现有 stream wire format 兼容。 +- 修正 header-sent 后的错误输出,继续走 SSE error frame。 + +改动文件: + +```text +agent-run-routes.mjs +tkmind-proxy.mjs +src/api/client.ts # 仅注释/常量;尽量不改解析逻辑 +src/hooks/useTKMindChat.ts # 若动 finish/sync 逻辑需跑 regression guard +相关测试 +``` + +验收: + +- session stream 断开重连不退化。 +- `Last-Event-ID` 继续透传。 +- session terminal event 后前端能稳定 idle/sync(merge,非盲覆盖)。 +- run stream terminal 与 session finish 职责分离(§1.3)。 +- 不要求第一阶段完成完整 replay。 + +### Patch 5: goosed 入口硬边界 + +目标: + +- 明确 H5 chat 主链路上 Portal → goosed 的唯一入口是 proxy adapter。 +- 增加测试或静态检查(`scripts/check-goosed-proxy-boundary.mjs` 或 eslint),防止新增绕过 proxy 的 goosed fetch。 +- 对 §7.2 例外模块做 allowlist。 +- 保留 goosed API,不改 goosed core。 + +改动文件: + +```text +tkmind-proxy.mjs +server.mjs +scripts/check-goosed-proxy-boundary.mjs # 新增 +tests +``` + +验收: + +- H5 不能直接访问 goosed。 +- gateway/router 不直接 fetch goosed。 +- 旧 reply path 继续返回 `410 AGENT_RUNS_REQUIRED`。 +- MindSpace 例外模块在 allowlist 内,检查脚本通过。 + +## 11. 上线顺序 + +### Phase 1: 安全收口 + +1. Patch 1: Broker facade,无行为变化。 +2. Patch 2a: proxy + agent-run-routes 改走 broker,`MEMIND_SESSION_BROKER_ENABLED` 可回退。 +3. Patch 2b: gateway + direct-chat-service 接 broker。 +4. Patch 2c: server / wechat / mindspace 旁路入口接 broker。 + +### Phase 2: 协议与 router 收敛 + +5. Patch 3: router 输出新增字段,旧字段兼容;可先 shadow 日志。 +6. Patch 4a: SSE event taxonomy 和 terminal 行为测试。 + +### Phase 3: 边界加固 + +7. Patch 5: goosed proxy 唯一入口约束 + allowlist。 +8. Patch 4b: run stream replay / event id 评估,不在第一阶段强行上线。 + +## 12. 风险与控制 + +| 风险 | 等级 | 控制方式 | +|---|---|---| +| Broker 变成第二套 session store | 高 | 只包 `h5_user_sessions`,禁止 cache/message/tool/memory | +| Gateway 一次性重构过大 | 高 | 第一阶段只抽 session,保留 direct/tool/retry | +| Patch 2 漏改旁路入口 | 高 | §5.7 清单 + grep 量化验收 | +| Direct Chat 被误认为 goosed session | 中 | 保留 `h5direct_` 和 `h5-direct` target | +| WeChat dedicated session 被误清理 | 中 | Broker 保留 origin,不统一 rotate | +| SSE 固化变成 event-sourcing 重构 | 高 | v0 只做 taxonomy/terminal,不改 wire format | +| Run/session 双通道语义混淆 | 高 | §1.3 明确分工;Patch 4 禁止合并通道 | +| goosed target list 扩缩导致 session 路由错乱 | 中 | 继续以 `goosed_target` 为优先,legacy node fallback | +| MindSpace Finish 同步被移动错层 | 高 | Finish 后 public HTML/materialize 逻辑仍留在 stream finish 处理附近 | +| 旧接口兼容破坏 | 中 | 保持 `/agent/runs` 返回结构、旧 reply path 410 | +| Snapshot 缓存误命中导致对话清空 | 高 | 保留 `hint_mc` + `hint_ua` 双 hint 规则 | + +## 13. 验收清单 + +### 13.1 单测 + +建议新增/补充: + +```text +session-broker.test.mjs +chat-intent-router.test.mjs +agent-run-routes.test.mjs +tkmind-proxy.test.mjs +direct-chat-service.test.mjs +``` + +重点断言: + +- ownership 校验失败返回 403。 +- direct chat target `h5-direct` 可解析且不会走 goosed URL。 +- `goosed_target` 优先于 legacy `goosed_node`。 +- router 新字段默认值稳定;`session_hint` 不 override 空 session_id。 +- `/sessions/:id/reply` 仍返回 410。 +- session event error 在 headers sent 后走 SSE frame。 +- run terminal 与 session finish 职责分离(§1.3)。 + +### 13.2 回归守卫 + +如果修改触及以下路径,必须执行对应 guard: + +```bash +npm run verify:mindspace-publish-guards +npm run verify:mindspace-publish-guards:full +npm run verify:mindspace-page-sync-guards +npm run verify:chat-finish-sync # Patch 4 触及 useTKMindChat / client.ts 时必跑 +``` + +受保护路径包括: + +- `src/hooks/useTKMindChat.ts` +- `chat-finish-sync.mjs` +- `mindspace-public-finish-sync.mjs` +- `conversation-display.mjs` +- `server.mjs` +- `tkmind-proxy.mjs` + +### 13.3 发布前验证 + +发布前必须: + +```bash +bash scripts/check-release-ready.sh +``` + +并按当前仓库发布规则执行完整 `main` 打包发布,不允许从脏工作区或单修复散包发布。 + +### 13.4 可观测性(建议,非阻塞第一阶段) + +broker/proxy 接入后可补充 metrics 或 structured log: + +```text +session_broker.resolve_target.miss # goosed_target 空且 node fallback +session_broker.ownership.denied # 403 计数 +session_broker.register.duplicate # 重复 register +goosed_proxy.stream.started / ended # 已有,保持 +``` + +用于验证「session 不再 socket 化」后排障是否按 broker → proxy → goosed 分层定位。 + +## 14. 回滚策略 + +### Patch 1 回滚 + +删除 broker facade 和测试即可,无数据迁移。 + +### Patch 2 回滚 + +设置 `MEMIND_SESSION_BROKER_ENABLED=0`,调用切回 `userAuth` 原函数。底层仍是同一张表,不需要 DB 回滚。 + +### Patch 3 回滚 + +设置 `MEMIND_ROUTER_NORMALIZED_DECISION=0`,继续使用旧 `direct_chat/agent_orchestration`。 + +### Patch 4 回滚 + +保留现有 SSE wire format;如果新 event taxonomy 影响前端,先关闭新解析逻辑,仍按旧 event type 处理。 + +### Patch 5 回滚 + +恢复旧 proxy 调用路径或禁用 boundary check script,但必须保留旧 reply path 410,避免重新打开已修复的直接 reply 风险。 + +## 15. 实施禁区 + +第一阶段明确禁止: + +- 新建第二套 session 表。 +- 把 message history 放进 broker。 +- 把 tool state 放进 broker。 +- 把 memory 选择逻辑放进 broker。 +- 把 session snapshot 合并进 broker。 +- 把 router 改成多步 planner。 +- 把 gateway 的 direct chat/tool gateway/retry 一次性拆掉。 +- 合并 run stream 与 session stream 为单通道。 +- 修改 goosed core。 +- 修改 H5 UI(Patch 4 尽量避免改 `useTKMindChat.ts` 行为)。 +- 修改生产拓扑或 goosed 容器部署方式。 + +## 16. 预期解决的问题 + +执行后能解决: + +- session 不再依赖某条 socket/stream 连接。 +- session ownership 和 runtime target 有统一入口。 +- 多 goosed target 下 session pinning 更清晰。 +- H5 / Portal / goosed 协议边界更稳定。 +- router 不再向执行规划膨胀。 +- gateway 的 run lifecycle 与 session lifecycle 逐步分离。 +- 生产排障能按 router、broker、gateway、proxy、goosed、SSE 分层定位。 + +不会直接解决: + +- 模型回答质量。 +- 单次推理速度。 +- 工具本身执行失败。 +- 已污染老 session 的自动修复。 +- 完整 run/session replay。 +- memory 内容质量。 + +这些属于后续 P2/P3 稳定性增强,不应塞进第一阶段。 + +## 17. 最终判断标准 + +改造是否成功,看三点: + +1. session 不再依赖 WebSocket/SSE 连接状态。 +2. goosed 不再承担 H5 用户、计费、router、memory 的业务决策。 +3. router 不输出执行步骤,只输出受控 decision。 + +补充量化验收: + +4. §5.7 调用点清单全部经 broker,grep 检查通过。 +5. H5 双 SSE 通道行为与 Patch 前一致(含 direct chat、finish merge、Last-Event-ID)。 + +只要以上成立,系统就从 socket-driven agent system 进入 event-driven control-plane system 的正确轨道。 diff --git a/docs/local-dev.md b/docs/local-dev.md index a30122a..1ed9b67 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -63,6 +63,45 @@ pnpm dev:all | `PLAZA_APP_DIR` | ../memind_plaza | Plaza 专用脚本的源码路径(`pnpm dev:plaza` / `pnpm start:plaza` / `pnpm dev:all`) | | `MEMIND_SESSION_BROKER_ENABLED` | `0`(未设置) | Session Broker 灰度开关;Patch 2+ 本地验证时可设 `1`,见 [H5 Session 架构](./h5-session-architecture-20260706.md) | +### H5 Session 架构本地全量联调(`0706-bug干净` 分支) + +在 **本机 `.env`**(勿提交 Git)打开下列开关后重启 `pnpm dev`: + +```bash +MEMIND_SESSION_BROKER_ENABLED=1 +MEMIND_ROUTER_NORMALIZED_DECISION=1 +MEMIND_SSE_EVENT_TAXONOMY=1 +MEMIND_RUN_STREAM_REPLAY=1 +MEMIND_H5_HTML_FINISH_GUARD=1 +``` + +| 开关 | 验证点 | +|------|--------| +| `MEMIND_SESSION_BROKER_ENABLED` | Portal 启动日志、`sessionAccess` 路径 | +| `MEMIND_SESSION_BROKER_METRICS` | `[session_broker.*]` structured log(§13.4) | +| `MEMIND_ROUTER_NORMALIZED_DECISION` | gateway 读 `decision.route` / `session_hint`;`shadow` 仅打 `[router-shadow]` 日志 | +| `MEMIND_SSE_EVENT_TAXONOMY` | SSE payload 多 `taxonomy` 字段 | +| `MEMIND_RUN_STREAM_REPLAY` | run SSE 含 `id:`,断线重连补发 | +| `MEMIND_SESSION_STREAM_REPLAY` | session SSE 持久化 + Last-Event-ID 重连补发(4c) | +| `MEMIND_H5_HTML_FINISH_GUARD` | Finish 后假交付检测 / 自动 repair | + +启动后应看到类似: + +```text +[Portal] H5 session flags: MEMIND_SESSION_BROKER_ENABLED, ... +``` + +回归: + +```bash +npm run verify:h5-session-patches +# 内含:单测、§5.7 session broker coverage、goosed proxy boundary、MindSpace publish guards +``` + +手工 soak 清单(全量开发完成后执行):[pending-fixes/h5-local-soak-20260706.md](./pending-fixes/h5-local-soak-20260706.md) + +关闭某项:对应变量改 `0` 或删除后重启 dev。 + Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、同步与回滚不要在这里处理,统一看 [生产更新发布指南](./release-deploy.md)。 ### 本地 `.env` 与 103 生产 `.env` 分离 diff --git a/docs/pending-fixes/README.md b/docs/pending-fixes/README.md index 214a13e..2283020 100644 --- a/docs/pending-fixes/README.md +++ b/docs/pending-fixes/README.md @@ -5,6 +5,9 @@ | 文档 | 问题 | 状态 | 阻塞原因 | |------|------|------|----------| | [h5-finish-before-run-gate-20260706.md](./h5-finish-before-run-gate-20260706.md) | Finish 先于 agent-run gate 导致 UI 永久 loading | 已修复(分支内) | 随 H5 session 分支合并 | +| [h5-run-stream-replay-4b-20260706.md](./h5-run-stream-replay-4b-20260706.md) | Run SSE replay(4b) | 已修复(分支内) | 随 H5 session 分支合并 | +| [h5-session-stream-replay-4c-20260706.md](./h5-session-stream-replay-4c-20260706.md) | Session SSE replay(4c) | 已修复(分支内) | 全量 soak 待执行 | | [h5-public-html-fake-delivery-guard-20260706.md](./h5-public-html-fake-delivery-guard-20260706.md) | H5 Agent 宣称「页面已生成」但 `public/*.html` 未落盘(无 write_file) | 待修复 | 等 H5 session / broker / SSE 收口分支合并后,与 `server.mjs`、`tkmind-proxy.mjs` 统一改 | +| [h5-local-soak-20260706.md](./h5-local-soak-20260706.md) | 本地全开开关 soak 验收清单 | **待执行** | 等 P0–P1 开发全部完成后再跑 | 开工前:读对应 md 全文 + [h5-session-architecture-20260706.md](../h5-session-architecture-20260706.md),确认主线已合并再动 Finish 路径。 diff --git a/docs/pending-fixes/h5-local-soak-20260706.md b/docs/pending-fixes/h5-local-soak-20260706.md new file mode 100644 index 0000000..7f6e1a6 --- /dev/null +++ b/docs/pending-fixes/h5-local-soak-20260706.md @@ -0,0 +1,88 @@ +# H5 Session 本地 Soak 测试 + +> **状态:自动化已通过(2026-07-06 14:57);手工场景待 john 账号验证** +> 分支:`0706-bug干净` · 禁止 merge main / 上 103 / commit(除非另行指令) + +## 前置 + +```bash +# .env 建议全开(勿提交 Git) +MEMIND_SESSION_BROKER_ENABLED=1 +MEMIND_SESSION_BROKER_METRICS=1 # 场景 8 +MEMIND_ROUTER_NORMALIZED_DECISION=1 +MEMIND_SSE_EVENT_TAXONOMY=1 +MEMIND_RUN_STREAM_REPLAY=1 +MEMIND_SESSION_STREAM_REPLAY=1 # 场景 5(4c) +MEMIND_H5_HTML_FINISH_GUARD=1 + +pnpm dev +# 访问 http://127.0.0.1:5173/ 全屏聊天 +``` + +**2026-07-06 检查**:本地 `.env` 已有 5 个开关,**缺少** `MEMIND_SESSION_STREAM_REPLAY`、`MEMIND_SESSION_BROKER_METRICS`。 +手工测场景 5/8 前请补上并 **重启 `pnpm dev`**。 + +**DB**:已执行 `migrateSchema`,`h5_session_stream_events` 表已创建。若 dev 在迁移前启动,需重启后 4c store 才会注入。 + +--- + +## 自动化验收(2026-07-06 14:57) + +| 命令 | 结果 | +|------|------| +| `npm run verify:h5-session-patches` | ✅ pass(含单测 + broker coverage + goosed boundary + publish guards) | +| `npm run verify:chat-finish-sync` | ✅ pass | +| `npm run verify:mindspace-page-sync-guards` | ✅ pass | +| `node scripts/check-session-broker-coverage.mjs` | ✅ pass | +| `node scripts/check-goosed-proxy-boundary.mjs` | ✅ pass | +| 本地服务探活 `5173` / `8081` | ✅ HTTP 200 | +| `h5_session_stream_events` 表 | ✅ 迁移后存在 | + +### 历史数据辅助(john,近 2h intent_routed) + +| 指标 | 值 | +|------|-----| +| 采样条数 | 10 | +| router timeout fallback | 2/10(睡前故事、讲笑话各 1 次) | +| 其余 | `source: llm`,路由正常 | + +> 自动化**不能**替代场景 1–8 的浏览器/DevTools 验证。 + +--- + +## 测试账号 + +| 用户 | 用途 | +|------|------| +| john | 主 soak 账号(已有 session 历史) | + +## 场景清单 + +| # | 场景 | 操作 | 预期 | 结果 | 备注 | +|---|------|------|------|------|------| +| 1 | 普通 direct_chat | 「讲一个睡前故事吧」 | ~7s 内整段回复;loading 点消失 | ☐ 待人工 | 历史:7.1s,曾 router timeout | +| 2 | 简单 hi | 「hi」 | 2–3s 回复;无永久 loading | ☐ 待人工 | Finish 竞态 | +| 3 | agent 生成页 | 「帮我生成一个唐诗页面…」 | agent 路径;Finish 后 public HTML 落盘 | ☐ 待人工 | publish guards 已自动化 ✅ | +| 4 | run SSE 重连 | 发消息 → DevTools 断网 3s → 恢复 | run events 从 Last-Event-ID 补发 | ☐ 待人工 | 4b,需 `MEMIND_RUN_STREAM_REPLAY=1` | +| 5 | session SSE 重连 | 流式回复中断网 | 重连后 merge,不盲覆盖 | ☐ 待人工 | 4c,需补 `MEMIND_SESSION_STREAM_REPLAY=1` + 重启 dev | +| 6 | MindSpace 小窗 | `/?preview=mindspace` 发消息 | 同全屏,Finish 不丢消息 | ☐ 待人工 | | +| 7 | Router 稳定性 | 连发笑话/写诗/故事各 1 条 | 无频繁 1500ms timeout fallback | ☐ 待人工 | 历史 2/10 timeout;快路径已合入未 soak | +| 8 | Broker metrics | 403 访问他人 session | Portal 日志含 `[session_broker.ownership.denied]` | ☐ 待人工 | 需 `MEMIND_SESSION_BROKER_METRICS=1` | + +## 记录模板(每条失败场景填写) + +```text +场景 #: +时间: +sessionId / runId: +现象: +Network / Console: +是否 blocker: +``` + +## 结论(手工测试后填写) + +- **自动化闸门**:✅ 通过(2026-07-06) +- **整体 soak**:☐ 待人工 +- **blocker 列表**:(暂无) +- **是否可进入 merge main 讨论**:等手工场景 1–8 完成后再定 diff --git a/docs/pending-fixes/h5-session-stream-replay-4c-20260706.md b/docs/pending-fixes/h5-session-stream-replay-4c-20260706.md new file mode 100644 index 0000000..c82efd8 --- /dev/null +++ b/docs/pending-fixes/h5-session-stream-replay-4c-20260706.md @@ -0,0 +1,59 @@ +# Patch 4c:Session SSE Replay(进行中,禁止 merge main / 上 103) + +> **状态:代码 + 单测已完成(2026-07-06);全量 soak 待开发完成后执行。** + +## 目标 + +解决 H5 session SSE 重连丢状态问题(§8.3): + +- Portal 持久化经 sanitizer 后的 session 事件到 `h5_session_stream_events` +- SSE 帧带 Portal `id:` cursor(与 run replay 4b 对齐) +- 客户端带 `Last-Event-ID` 重连时,先从 DB 补发 missed events +- 若补发以 `Finish`/`Error` 终态结束,则不再挂 upstream(避免重复 Finish) + +## 开关 + +```bash +MEMIND_SESSION_STREAM_REPLAY=1 +``` + +与 4b 独立:可只开 run replay、只开 session replay、或两者同开。 + +## 改动文件 + +| 文件 | 说明 | +|------|------| +| `session-stream.mjs` | replay 纯函数、flag | +| `session-stream-store.mjs` | MySQL 读写 | +| `session-stream*.test.mjs` | 单测 | +| `db.mjs` / `schema.sql` | `h5_session_stream_events` 表 | +| `tkmind-proxy.mjs` | 重连补发 + live persist + SSE `id:` | +| `server.mjs` | store 注入、启动 flag 日志 | +| `.env.example` / `docs/local-dev.md` | 文档 | + +## 行为说明 + +| 场景 | 行为 | +|------|------| +| 首次订阅(无 Last-Event-ID) | 纯透传 goosed,live 持久化(开关开启时) | +| 重连(有 Last-Event-ID) | DB 补发 → 若未到 terminal 再挂 upstream | +| cursor miss | 全量补发(同 4b) | +| direct chat session | 仍走 `sendDirectChatSessionEvents`,不经本路径 | + +## 验证(全量开发完成后) + +```bash +npm run verify:h5-session-patches +``` + +手工: + +1. `.env` 加 `MEMIND_SESSION_STREAM_REPLAY=1`,重启 `pnpm dev` +2. agent 路径发消息,DevTools 看 `/sessions/:id/events` 含 `id:` 行 +3. 流式过程中断网/刷新,确认重连后 token/Finish 不丢 + +## 与 4b 关系 + +- **4b**:`GET /agent/runs/:id/events` + `h5_agent_run_events` +- **4c**:`GET /sessions/:id/events` + `h5_session_stream_events` +- 双通道职责仍分离(§1.3) diff --git a/memory-v2-admin-config.mjs b/memory-v2-admin-config.mjs index aa815b8..6874d45 100644 --- a/memory-v2-admin-config.mjs +++ b/memory-v2-admin-config.mjs @@ -207,9 +207,10 @@ function applyEnv(config, secrets, env = process.env) { config.chatIntentRouter.memoryResolveLimit = '8'; } if (!normalizeString(config.chatIntentRouter.timeoutMs)) { - config.chatIntentRouter.timeoutMs = '1500'; + config.chatIntentRouter.timeoutMs = '2500'; } - if (!normalizeString(config.chatIntentRouter.fallbackRoute)) { + const fallbackRoute = normalizeString(config.chatIntentRouter.fallbackRoute); + if (!fallbackRoute || fallbackRoute === 'direct_chat') { config.chatIntentRouter.fallbackRoute = 'agent_orchestration'; } return { config, secrets }; diff --git a/ops/src/App.tsx b/ops/src/App.tsx index 445f27b..ef25bc3 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -11,7 +11,6 @@ import { ReviewPage } from './pages/ReviewPage'; import { SummaryPage } from './pages/admin/SummaryPage'; import { UsersPage } from './pages/admin/UsersPage'; import { BillingPage } from './pages/admin/BillingPage'; -import { WechatPage } from './pages/admin/WechatPage'; export function App() { return ( diff --git a/policies.mjs b/policies.mjs index 556cb12..e89b8d8 100644 --- a/policies.mjs +++ b/policies.mjs @@ -196,6 +196,7 @@ export function applyPoliciesToCapabilities(capabilities, policies) { effective.shell = false; } effective.computer = false; + effective.web = false; effective.code_sandbox = false; } return effective; diff --git a/policies.test.mjs b/policies.test.mjs index 34bef1d..68a1a46 100644 --- a/policies.test.mjs +++ b/policies.test.mjs @@ -32,11 +32,12 @@ test('readonly workspace strips write capabilities', () => { test('network deny strips egress capabilities', () => { const effective = applyPoliciesToCapabilities( - { shell: true, computer: true, charts: true }, + { shell: true, computer: true, web: true, charts: true }, { network_egress: 'deny' }, ); assert.equal(effective.shell, false); assert.equal(effective.computer, false); + assert.equal(effective.web, false); assert.equal(effective.charts, true); }); diff --git a/schema.sql b/schema.sql index 3ba9ae7..aa42367 100644 --- a/schema.sql +++ b/schema.sql @@ -395,6 +395,20 @@ CREATE TABLE IF NOT EXISTS h5_agent_run_events ( CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Patch 4c session SSE replay cursor store (rollback: DROP TABLE h5_session_stream_events). +CREATE TABLE IF NOT EXISTS h5_session_stream_events ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + agent_session_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + payload_json LONGTEXT NOT NULL, + upstream_event_id VARCHAR(255) NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_session_stream_session (agent_session_id, created_at, id), + KEY idx_h5_session_stream_user (user_id, created_at), + CONSTRAINT fk_h5_session_stream_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_conversation_packages ( id VARCHAR(64) PRIMARY KEY, user_id CHAR(36) NOT NULL, diff --git a/scripts/check-session-broker-coverage.mjs b/scripts/check-session-broker-coverage.mjs new file mode 100644 index 0000000..55c9881 --- /dev/null +++ b/scripts/check-session-broker-coverage.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + checkSessionBrokerCoverage, + formatSessionBrokerCoverageViolations, + SESSION_BROKER_PATCH2_FILES, +} from '../session-broker-coverage.mjs'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const violations = checkSessionBrokerCoverage(rootDir); + +if (violations.length > 0) { + console.error('session broker coverage check failed:\n'); + console.error(formatSessionBrokerCoverageViolations(violations)); + console.error('\nSession ownership must go through session-broker.mjs (createSessionAccess / resolveSessionAccess).'); + console.error(`Patch 2 modules: ${SESSION_BROKER_PATCH2_FILES.join(', ')}`); + process.exit(1); +} + +console.log( + `session broker coverage ok (patch2=${SESSION_BROKER_PATCH2_FILES.length}, facade=session-broker.mjs)`, +); diff --git a/scripts/verify-h5-session-patches.mjs b/scripts/verify-h5-session-patches.mjs index c70a29b..2f69460 100644 --- a/scripts/verify-h5-session-patches.mjs +++ b/scripts/verify-h5-session-patches.mjs @@ -16,6 +16,10 @@ const testFiles = [ 'chat-finish-sync.test.mjs', 'conversation-display.test.mjs', 'mindspace-h5-html-finish-guard.test.mjs', + 'session-broker-coverage.test.mjs', + 'session-stream.test.mjs', + 'session-stream-store.test.mjs', + 'session-broker-metrics.test.mjs', ]; function run(label, command, args) { @@ -31,6 +35,7 @@ function run(label, command, args) { } run('unit tests', process.execPath, ['--test', ...testFiles]); +run('session broker coverage', process.execPath, ['scripts/check-session-broker-coverage.mjs']); run('goosed proxy boundary', process.execPath, ['scripts/check-goosed-proxy-boundary.mjs']); run('mindspace publish guards', process.execPath, ['scripts/verify-mindspace-publish-guards.mjs']); diff --git a/scripts/verify-world-cup-flow-e2e.mjs b/scripts/verify-world-cup-flow-e2e.mjs new file mode 100644 index 0000000..1ca2797 --- /dev/null +++ b/scripts/verify-world-cup-flow-e2e.mjs @@ -0,0 +1,358 @@ +#!/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); +}); diff --git a/scripts/verify-world-cup-routing.mjs b/scripts/verify-world-cup-routing.mjs new file mode 100644 index 0000000..f099ff8 --- /dev/null +++ b/scripts/verify-world-cup-routing.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * Local smoke: verify "世界杯现在赛况如何" routes to agent_orchestration. + * Usage: node scripts/verify-world-cup-routing.mjs + */ +import crypto from 'node:crypto'; +import { loadH5Environment } from './load-env.mjs'; +import { createDbPool } from '../db.mjs'; +import { createUserAuth } from '../user-auth.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_WAIT_MS = 120_000; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function login() { + const pool = await createDbPool(); + const auth = createUserAuth(pool); + let result = await auth.login({ username: USERNAME, password: PASSWORD, ip: '127.0.0.1' }); + if (!result.ok) { + throw new Error(`john 登录失败(请设置 JOHN_PASSWORD 环境变量): ${result.message ?? 'unknown'}`); + } + await pool.end(); + 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: `tkmind_user_session=${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 body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(`POST /agent/runs ${response.status}: ${JSON.stringify(body)}`); + } + const run = body.run ?? body; + return { runId: run.id, requestId, status: run.status }; +} + +async function waitForRun(token, runId) { + const started = Date.now(); + while (Date.now() - started < MAX_WAIT_MS) { + const response = await fetch(`${PORTAL}/api/agent/runs/${runId}`, { + headers: { Cookie: `tkmind_user_session=${token}` }, + }); + const payload = await response.json().catch(() => ({})); + const body = payload.run ?? payload; + if (!response.ok) { + throw new Error(`GET /agent/runs/${runId} ${response.status}: ${JSON.stringify(payload)}`); + } + if (['succeeded', 'failed'].includes(body.status)) { + return body; + } + await sleep(1000); + } + throw new Error(`run ${runId} 未在 ${MAX_WAIT_MS}ms 内完成`); +} + +async function loadRunEvents(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 loadAssistantReply(sessionId) { + const pool = await createDbPool(); + const [rows] = await pool.query( + `SELECT text FROM h5_conversation_messages + WHERE agent_session_id = ? AND role = 'assistant' + ORDER BY created_at DESC LIMIT 1`, + [sessionId], + ); + await pool.end(); + return rows[0]?.text ?? ''; +} + +async function main() { + console.log(`==> Portal: ${PORTAL}`); + const token = await login(); + console.log('==> john 登录成功'); + + const { runId } = await createRun(token); + console.log(`==> run 已创建: ${runId}`); + + const run = await waitForRun(token, runId); + console.log(`==> run 终态: ${run.status} session=${run.sessionId ?? run.agent_session_id ?? 'n/a'}`); + + const events = await loadRunEvents(runId); + const routed = events.find((row) => row.event_type === 'intent_routed'); + const route = routed?.data_json?.route ?? null; + const source = routed?.data_json?.source ?? null; + const reason = routed?.data_json?.reason ?? null; + + console.log('\n--- intent_routed ---'); + console.log(JSON.stringify({ route, source, reason, suggestedSkill: routed?.data_json?.suggestedSkill ?? null }, null, 2)); + + const sessionId = run.sessionId ?? run.agent_session_id; + if (sessionId) { + const reply = await loadAssistantReply(sessionId); + console.log('\n--- assistant 回复摘要 ---'); + console.log(reply.slice(0, 500)); + if (/无法实时获取|无法获取.*最新赛况/i.test(reply)) { + console.error('\nFAIL: 仍走 direct_chat 拒答路径'); + process.exit(1); + } + } + + if (route !== 'agent_orchestration') { + console.error(`\nFAIL: 期望 route=agent_orchestration,实际 ${route}`); + process.exit(1); + } + + if (run.status !== 'succeeded') { + console.error(`\nFAIL: run 终态 ${run.status}: ${run.error ?? ''}`); + process.exit(1); + } + + console.log('\nPASS: 世界杯查询已走 agent 路径'); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs index b76d195..aa055ad 100644 --- a/server.mjs +++ b/server.mjs @@ -22,6 +22,7 @@ import { } from './agent-run-routes.mjs'; import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs'; import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs'; +import { isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs'; import { clearUserSessionCookie, createUserAuth, @@ -173,7 +174,9 @@ import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs'; import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs'; import { repairSessionConversationFromDb } from './conversation-repair.mjs'; import { filterNonemptyUserVisibleMessages } from './conversation-transcript-persist.mjs'; -import { createManagedChatIntentRouter } from './chat-intent-router.mjs'; +import { createSessionStreamStore } from './session-stream-store.mjs'; +import { isSessionStreamReplayEnabled } from './session-stream.mjs'; +import { createManagedChatIntentRouter, resolveNormalizedRouterDecisionMode } from './chat-intent-router.mjs'; import { createSessionSnapshotService } from './session-snapshot.mjs'; import { createConversationMemoryService } from './conversation-memory.mjs'; import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs'; @@ -293,22 +296,27 @@ if (ACCESS_PASSWORD && !isDatabaseConfigured()) { let userAuth = null; let sessionAccess = null; +let sessionStreamStore = null; let tkmindProxy = null; +async function resolveActiveSessionAccess() { + if (sessionAccess) return sessionAccess; + if (!userAuth) return null; + return createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() }); +} + async function ownsAgentSession(userId, sessionId) { if (!userId || !sessionId) return false; - if (sessionAccess) return sessionAccess.validateOwnership(userId, sessionId); - if (userAuth) return userAuth.ownsSession(userId, sessionId); - return false; + const access = await resolveActiveSessionAccess(); + if (!access) return false; + return access.validateOwnership(userId, sessionId); } async function unregisterAgentSessionForUser(userId, sessionId) { if (!userId || !sessionId) return; - if (sessionAccess) { - await sessionAccess.unregisterSession({ userId, sessionId }); - return; - } - if (userAuth) await unregisterAgentSessionForUser(userId, sessionId); + const access = await resolveActiveSessionAccess(); + if (!access) return; + await access.unregisterSession({ userId, sessionId }); } let agentRunGateway = null; let chatIntentRouter = null; @@ -470,6 +478,19 @@ async function bootstrapUserAuth() { if (sessionAccess.enabled) { console.log('[Portal] Session Broker enabled (MEMIND_SESSION_BROKER_ENABLED=1)'); } + const routerDecisionMode = resolveNormalizedRouterDecisionMode(process.env); + const h5SessionFlags = [ + sessionAccess.enabled && 'MEMIND_SESSION_BROKER_ENABLED', + isSessionBrokerMetricsEnabled() && 'MEMIND_SESSION_BROKER_METRICS', + routerDecisionMode !== 'off' && `MEMIND_ROUTER_NORMALIZED_DECISION=${routerDecisionMode}`, + isSessionStreamReplayEnabled() && 'MEMIND_SESSION_STREAM_REPLAY', + ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_SSE_EVENT_TAXONOMY ?? '').trim().toLowerCase()) && 'MEMIND_SSE_EVENT_TAXONOMY', + ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_RUN_STREAM_REPLAY ?? '').trim().toLowerCase()) && 'MEMIND_RUN_STREAM_REPLAY', + ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_H5_HTML_FINISH_GUARD ?? '').trim().toLowerCase()) && 'MEMIND_H5_HTML_FINISH_GUARD', + ].filter(Boolean); + if (h5SessionFlags.length) { + console.log(`[Portal] H5 session flags: ${h5SessionFlags.join(', ')}`); + } wechatPayClient = createWechatPayClient(loadWechatPayConfig()); wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth }); rechargeService = createRechargeService(pool, { @@ -575,6 +596,9 @@ async function bootstrapUserAuth() { conversationMemoryService, memoryV2, }); + if (isSessionStreamReplayEnabled()) { + sessionStreamStore = createSessionStreamStore({ pool }); + } directChatService = createDirectChatService({ userAuth, sessionAccess, @@ -595,6 +619,7 @@ async function bootstrapUserAuth() { apiSecret: API_SECRET, userAuth, sessionAccess, + sessionStreamStore, llmProviderService, subscriptionService, sessionSnapshotService, diff --git a/session-broker-coverage.mjs b/session-broker-coverage.mjs new file mode 100644 index 0000000..8b40e87 --- /dev/null +++ b/session-broker-coverage.mjs @@ -0,0 +1,169 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * SESSION BROKER COVERAGE (H5 Session architecture Patch 2 / §5.7) + * + * Business code must not call userAuth session ownership helpers directly. + * Use session-broker.mjs facade via createSessionAccess / resolveSessionAccess. + * + * See docs/h5-session-architecture-20260706.md §5.7 + */ + +/** @typedef {{ file: string, line: number, rule: string, text: string }} SessionBrokerCoverageViolation */ + +export const SESSION_BROKER_FACADE_FILE = 'session-broker.mjs'; +export const SESSION_BROKER_DEFINITION_FILE = 'user-auth.mjs'; + +/** Files allowed to call userAuth.registerAgentSession / ownsSession directly. */ +export const SESSION_BROKER_DIRECT_ALLOWLIST = new Set([ + SESSION_BROKER_FACADE_FILE, + SESSION_BROKER_DEFINITION_FILE, +]); + +/** Patch 2 modules that must route session ownership through sessionAccess. */ +export const SESSION_BROKER_PATCH2_FILES = [ + 'tkmind-proxy.mjs', + 'agent-run-routes.mjs', + 'agent-run-gateway.mjs', + 'direct-chat-service.mjs', + 'server.mjs', + 'wechat-mp.mjs', + 'mindspace-page-edit-session.mjs', + 'mindspace-agent-runner.mjs', + 'mindspace-conversation-package-routes.mjs', +]; + +export const SESSION_BROKER_FORBIDDEN_CALLS = [ + { id: 'register-agent-session', pattern: /\buserAuth\.registerAgentSession\s*\(/ }, + { id: 'owns-session', pattern: /\buserAuth\.ownsSession\s*\(/ }, + { id: 'get-session-target', pattern: /\buserAuth\.getSessionTarget\s*\(/ }, + { id: 'unregister-agent-session', pattern: /\buserAuth\.unregisterAgentSession\s*\(/ }, +]; + +export const SESSION_BROKER_PATCH2_MARKERS = [ + /\bresolveSessionAccess\b/, + /\bcreateSessionAccess\b/, + /\bsessionAccess\b/, +]; + +export const SESSION_BROKER_SCAN_IGNORE = [ + /^scripts\//, + /^mindspace-service\//, + /^\.runtime\//, + /^node_modules\//, + /\.test\.mjs$/, + /\.test\.ts$/, +]; + +export const SESSION_BROKER_LINE_ALLOW = [ + /SESSION BROKER COVERAGE/, + /§5\.7/, + /session-broker\.mjs/, + /createSessionAccess/, + /resolveSessionAccess/, + /sessionAccess\./, + /getSessionAccess/, +]; + +function relativePosix(rootDir, absolutePath) { + return path.relative(rootDir, absolutePath).split(path.sep).join('/'); +} + +function isIgnoredScanPath(relativePath) { + return SESSION_BROKER_SCAN_IGNORE.some((pattern) => pattern.test(relativePath)); +} + +function isTestFile(relativePath) { + return /\.test\.mjs$/.test(relativePath); +} + +function isLineAllowed(line) { + return SESSION_BROKER_LINE_ALLOW.some((pattern) => pattern.test(line)); +} + +function scanDirectUserAuthCalls(relativePath, content) { + /** @type {SessionBrokerCoverageViolation[]} */ + const violations = []; + if (SESSION_BROKER_DIRECT_ALLOWLIST.has(relativePath) || isTestFile(relativePath)) { + return violations; + } + + const lines = content.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line.trim() || line.trim().startsWith('//') || line.trim().startsWith('*')) continue; + if (isLineAllowed(line)) continue; + for (const rule of SESSION_BROKER_FORBIDDEN_CALLS) { + if (!rule.pattern.test(line)) continue; + violations.push({ + file: relativePath, + line: index + 1, + rule: rule.id, + text: line.trim(), + }); + } + } + return violations; +} + +function checkPatch2ModuleWiring(rootDir) { + /** @type {SessionBrokerCoverageViolation[]} */ + const violations = []; + for (const relativePath of SESSION_BROKER_PATCH2_FILES) { + const absolutePath = path.join(rootDir, relativePath); + if (!fs.existsSync(absolutePath)) { + violations.push({ + file: relativePath, + line: 0, + rule: 'missing-patch2-file', + text: 'Patch 2 module missing from repository', + }); + continue; + } + const content = fs.readFileSync(absolutePath, 'utf8'); + if (!SESSION_BROKER_PATCH2_MARKERS.some((pattern) => pattern.test(content))) { + violations.push({ + file: relativePath, + line: 0, + rule: 'missing-session-access-wiring', + text: 'Expected resolveSessionAccess/createSessionAccess/sessionAccess wiring', + }); + } + } + return violations; +} + +export function checkSessionBrokerCoverage(rootDir, { scanRepo = true } = {}) { + /** @type {SessionBrokerCoverageViolation[]} */ + const violations = [...checkPatch2ModuleWiring(rootDir)]; + + if (!scanRepo) return violations; + + const queue = [rootDir]; + while (queue.length > 0) { + const current = queue.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + const absolutePath = path.join(current, entry.name); + const relativePath = relativePosix(rootDir, absolutePath); + if (entry.isDirectory()) { + queue.push(absolutePath); + continue; + } + if (!entry.name.endsWith('.mjs')) continue; + if (isIgnoredScanPath(relativePath)) continue; + const content = fs.readFileSync(absolutePath, 'utf8'); + violations.push(...scanDirectUserAuthCalls(relativePath, content)); + } + } + + return violations; +} + +export function formatSessionBrokerCoverageViolations(violations) { + return violations + .map((item) => `${item.file}:${item.line} [${item.rule}] ${item.text}`) + .join('\n'); +} diff --git a/session-broker-coverage.test.mjs b/session-broker-coverage.test.mjs new file mode 100644 index 0000000..43841cb --- /dev/null +++ b/session-broker-coverage.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + checkSessionBrokerCoverage, + SESSION_BROKER_PATCH2_FILES, +} from './session-broker-coverage.mjs'; + +test('checkSessionBrokerCoverage passes on repository root', () => { + const rootDir = path.resolve(import.meta.dirname); + const violations = checkSessionBrokerCoverage(rootDir); + assert.equal( + violations.length, + 0, + violations.map((item) => `${item.file}:${item.line} ${item.rule}`).join('\n'), + ); +}); + +test('checkSessionBrokerCoverage flags direct userAuth.registerAgentSession', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'broker-coverage-')); + try { + fs.writeFileSync( + path.join(tmpDir, 'bad-module.mjs'), + 'export async function run(userAuth) { await userAuth.registerAgentSession("u", "s", 0); }\n', + ); + const violations = checkSessionBrokerCoverage(tmpDir, { scanRepo: true }); + assert.ok(violations.some((item) => item.rule === 'register-agent-session')); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('SESSION_BROKER_PATCH2_FILES includes gateway and proxy', () => { + assert.ok(SESSION_BROKER_PATCH2_FILES.includes('tkmind-proxy.mjs')); + assert.ok(SESSION_BROKER_PATCH2_FILES.includes('agent-run-gateway.mjs')); + assert.ok(SESSION_BROKER_PATCH2_FILES.includes('server.mjs')); +}); diff --git a/session-broker-metrics.mjs b/session-broker-metrics.mjs new file mode 100644 index 0000000..68e44d9 --- /dev/null +++ b/session-broker-metrics.mjs @@ -0,0 +1,41 @@ +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function isSessionBrokerMetricsEnabled(env = process.env) { + return envFlag(env.MEMIND_SESSION_BROKER_METRICS, false); +} + +export function formatSessionBrokerMetric(metric, fields = {}) { + return `[session_broker.${metric}] ${JSON.stringify({ + metric: `session_broker.${metric}`, + ...fields, + })}`; +} + +export function createSessionBrokerMetrics({ + logger = console, + enabled = isSessionBrokerMetricsEnabled(), +} = {}) { + function emit(metric, fields = {}) { + if (!enabled) return; + const line = formatSessionBrokerMetric(metric, fields); + if (typeof logger.info === 'function') logger.info(line); + else logger.log(line); + } + + return { + enabled, + ownershipDenied(fields) { + emit('ownership.denied', fields); + }, + registerDuplicate(fields) { + emit('register.duplicate', fields); + }, + resolveTargetMiss(fields) { + emit('resolve_target.miss', fields); + }, + }; +} diff --git a/session-broker-metrics.test.mjs b/session-broker-metrics.test.mjs new file mode 100644 index 0000000..5409e74 --- /dev/null +++ b/session-broker-metrics.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createSessionBrokerMetrics, + formatSessionBrokerMetric, + isSessionBrokerMetricsEnabled, +} from './session-broker-metrics.mjs'; + +test('isSessionBrokerMetricsEnabled respects env flag', () => { + assert.equal(isSessionBrokerMetricsEnabled({}), false); + assert.equal(isSessionBrokerMetricsEnabled({ MEMIND_SESSION_BROKER_METRICS: '1' }), true); +}); + +test('createSessionBrokerMetrics emits structured lines when enabled', () => { + const lines = []; + const metrics = createSessionBrokerMetrics({ + enabled: true, + logger: { log: (line) => lines.push(line) }, + }); + metrics.ownershipDenied({ userId: 'u1', sessionId: 's1' }); + metrics.registerDuplicate({ userId: 'u1', sessionId: 's1', target: 'https://127.0.0.1:18006' }); + metrics.resolveTargetMiss({ sessionId: 's1', node: 2, reason: 'legacy_node_fallback' }); + assert.equal(lines.length, 3); + assert.match(lines[0], /\[session_broker\.ownership\.denied\]/); + assert.match(lines[1], /register\.duplicate/); + assert.match(lines[2], /resolve_target\.miss/); + assert.match(formatSessionBrokerMetric('ownership.denied', { sessionId: 's1' }), /"sessionId":"s1"/); +}); + +test('createSessionBrokerMetrics stays silent when disabled', () => { + const lines = []; + const metrics = createSessionBrokerMetrics({ + enabled: false, + logger: { log: (line) => lines.push(line) }, + }); + metrics.ownershipDenied({ userId: 'u1', sessionId: 's1' }); + assert.equal(lines.length, 0); +}); diff --git a/session-broker.mjs b/session-broker.mjs index 516db99..0ae1815 100644 --- a/session-broker.mjs +++ b/session-broker.mjs @@ -5,6 +5,8 @@ * See docs/h5-session-architecture-20260706.md Patch 1. */ +import { createSessionBrokerMetrics, isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs'; + function envFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; @@ -21,21 +23,33 @@ function requireUserAuthFn(userAuth, name) { } } -export function createSessionBroker({ userAuth } = {}) { +export function createSessionBroker({ + userAuth, + logger = console, + metricsEnabled = isSessionBrokerMetricsEnabled(), +} = {}) { requireUserAuthFn(userAuth, 'registerAgentSession'); requireUserAuthFn(userAuth, 'getSessionTarget'); requireUserAuthFn(userAuth, 'ownsSession'); requireUserAuthFn(userAuth, 'unregisterAgentSession'); + const metrics = createSessionBrokerMetrics({ logger, enabled: metricsEnabled }); async function validateOwnership(userId, sessionId) { if (!userId || !sessionId) return false; - return userAuth.ownsSession(userId, sessionId); + const owned = await userAuth.ownsSession(userId, sessionId); + if (!owned) { + metrics.ownershipDenied({ userId, sessionId }); + } + return owned; } async function registerSession({ userId, sessionId, target = 0, origin = 'h5' } = {}) { if (!userId || !sessionId) { throw new Error('registerSession requires userId and sessionId'); } + if (await userAuth.ownsSession(userId, sessionId)) { + metrics.registerDuplicate({ userId, sessionId, target, origin }); + } await userAuth.registerAgentSession(userId, sessionId, target); if (typeof userAuth.setSessionOrigin === 'function' && (origin === 'h5' || origin === 'wechat')) { await userAuth.setSessionOrigin(sessionId, origin); @@ -49,7 +63,15 @@ export function createSessionBroker({ userAuth } = {}) { async function resolveSessionTarget(sessionId) { if (!sessionId) return { target: null, node: 0 }; - return userAuth.getSessionTarget(sessionId); + const resolved = await userAuth.getSessionTarget(sessionId); + if (!resolved?.target && Number(resolved?.node ?? 0) > 0) { + metrics.resolveTargetMiss({ + sessionId, + node: Number(resolved.node), + reason: 'legacy_node_fallback', + }); + } + return resolved; } async function resolveSession(sessionId) { @@ -76,12 +98,20 @@ export function createSessionBroker({ userAuth } = {}) { * Unified session ownership/target access with optional broker routing. * When disabled, delegates to userAuth with identical behavior (Patch 2 fallback). */ -export function createSessionAccess({ userAuth, enabled = isSessionBrokerEnabled(), broker = null } = {}) { +export function createSessionAccess({ + userAuth, + enabled = isSessionBrokerEnabled(), + broker = null, + logger = console, + metricsEnabled = isSessionBrokerMetricsEnabled(), +} = {}) { if (!userAuth) { throw new Error('createSessionAccess requires userAuth'); } const useBroker = Boolean(enabled); - const sessionBroker = useBroker ? (broker ?? createSessionBroker({ userAuth })) : null; + const sessionBroker = useBroker + ? (broker ?? createSessionBroker({ userAuth, logger, metricsEnabled })) + : null; async function validateOwnership(userId, sessionId) { if (useBroker) return sessionBroker.validateOwnership(userId, sessionId); diff --git a/session-broker.test.mjs b/session-broker.test.mjs index 384e5d2..50111fb 100644 --- a/session-broker.test.mjs +++ b/session-broker.test.mjs @@ -153,3 +153,29 @@ test('createSessionAccess with enabled=true routes through broker', async () => assert.equal(resolved.origin, 'wechat'); assert.equal(resolved.target, 'h5-direct'); }); + +test('createSessionBroker emits metrics when enabled', async () => { + const userAuth = createMockUserAuth(); + const lines = []; + const broker = createSessionBroker({ + userAuth, + metricsEnabled: true, + logger: { log: (line) => lines.push(line) }, + }); + await broker.registerSession({ + userId: 'user-1', + sessionId: 'sess-metrics', + target: 'https://127.0.0.1:18006', + }); + await broker.registerSession({ + userId: 'user-1', + sessionId: 'sess-metrics', + target: 'https://127.0.0.1:18006', + }); + assert.equal(await broker.validateOwnership('user-2', 'sess-metrics'), false); + await userAuth.registerAgentSession('user-1', 'sess-node-only', 2); + await broker.resolveSessionTarget('sess-node-only'); + assert.ok(lines.some((line) => line.includes('register.duplicate'))); + assert.ok(lines.some((line) => line.includes('ownership.denied'))); + assert.ok(lines.some((line) => line.includes('resolve_target.miss'))); +}); diff --git a/session-reply-wait.mjs b/session-reply-wait.mjs new file mode 100644 index 0000000..c7e68bd --- /dev/null +++ b/session-reply-wait.mjs @@ -0,0 +1,80 @@ +import { Readable } from 'node:stream'; + +function parseSseDataLines(frame) { + let data = ''; + for (const line of String(frame ?? '').split('\n')) { + if (line.startsWith('data:')) data += line.slice(5).trim(); + } + return data; +} + +export function parseSessionStreamEvent(frame) { + const data = parseSseDataLines(frame); + if (!data) return null; + try { + return JSON.parse(data); + } catch { + return null; + } +} + +export function eventMatchesRequest(event, requestId) { + if (!requestId) return true; + const routingId = event?.chat_request_id ?? event?.request_id ?? null; + return !routingId || routingId === requestId; +} + +export async function consumeSessionEventsUntilFinish( + body, + { + requestId = null, + timeoutMs = 15 * 60 * 1000, + onEvent = null, + } = {}, +) { + if (!body) { + const err = new Error('session event stream unavailable'); + err.code = 'SESSION_EVENT_STREAM_UNAVAILABLE'; + throw err; + } + + const reader = Readable.fromWeb(body); + const decoder = new TextDecoder(); + let buffer = ''; + const deadline = Date.now() + Math.max(1, Number(timeoutMs) || 1); + + for await (const chunk of reader) { + if (Date.now() > deadline) { + const err = new Error(`session reply timed out after ${timeoutMs}ms`); + err.code = 'SESSION_REPLY_TIMEOUT'; + throw err; + } + + buffer += decoder.decode(chunk, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() ?? ''; + for (const frame of frames) { + const trimmed = frame.trim(); + if (!trimmed || trimmed.startsWith(':')) continue; + const event = parseSessionStreamEvent(trimmed); + if (!event) continue; + if (!eventMatchesRequest(event, requestId)) continue; + onEvent?.(event); + if (event.type === 'Error') { + const err = new Error(String(event.error ?? 'session reply failed')); + err.code = 'SESSION_REPLY_ERROR'; + throw err; + } + if (event.type === 'Finish') { + return { + finishEvent: event, + tokenState: event.token_state ?? null, + }; + } + } + } + + const err = new Error('session event stream ended before Finish'); + err.code = 'SESSION_REPLY_INCOMPLETE'; + throw err; +} diff --git a/session-reply-wait.test.mjs b/session-reply-wait.test.mjs new file mode 100644 index 0000000..c40e4d1 --- /dev/null +++ b/session-reply-wait.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ReadableStream } from 'node:stream/web'; +import { + consumeSessionEventsUntilFinish, + eventMatchesRequest, + parseSessionStreamEvent, +} from './session-reply-wait.mjs'; + +test('parseSessionStreamEvent parses SSE data payload', () => { + const event = parseSessionStreamEvent('id: 1\ndata: {"type":"Finish","token_state":null}\n'); + assert.equal(event?.type, 'Finish'); +}); + +test('eventMatchesRequest scopes by request id when present', () => { + assert.equal(eventMatchesRequest({ request_id: 'req-1' }, 'req-1'), true); + assert.equal(eventMatchesRequest({ request_id: 'req-2' }, 'req-1'), false); + assert.equal(eventMatchesRequest({ type: 'Finish' }, 'req-1'), true); +}); + +test('consumeSessionEventsUntilFinish resolves on Finish', async () => { + const frames = [ + 'data: {"type":"Message","request_id":"req-1","message":{"role":"assistant"}}\n\n', + 'data: {"type":"Finish","request_id":"req-1","token_state":{"totalTokens":12}}\n\n', + ]; + const stream = new ReadableStream({ + start(controller) { + for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame)); + controller.close(); + }, + }); + const result = await consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000 }); + assert.equal(result.finishEvent.type, 'Finish'); + assert.equal(result.tokenState.totalTokens, 12); +}); diff --git a/session-stream-store.mjs b/session-stream-store.mjs new file mode 100644 index 0000000..1876a87 --- /dev/null +++ b/session-stream-store.mjs @@ -0,0 +1,124 @@ +import crypto from 'node:crypto'; + +function safeJsonParse(value, fallback = null) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +export function createSessionStreamStore({ pool }) { + if (!pool) { + throw new Error('createSessionStreamStore requires pool'); + } + + async function appendEvent({ + userId, + sessionId, + payload, + id = crypto.randomUUID(), + upstreamEventId = null, + } = {}) { + if (!userId || !sessionId || !payload) { + throw new Error('appendEvent requires userId, sessionId, and payload'); + } + const createdAt = Date.now(); + const eventType = String(payload?.type ?? 'unknown').slice(0, 64); + await pool.query( + `INSERT INTO h5_session_stream_events + (id, user_id, agent_session_id, event_type, payload_json, upstream_event_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + id, + userId, + sessionId, + eventType, + JSON.stringify(payload), + upstreamEventId || null, + createdAt, + ], + ); + return { + id, + createdAt, + eventType, + payload, + }; + } + + async function listEventsForUser(userId, sessionId, { afterEventId = null, limit = 500 } = {}) { + if (!userId || !sessionId) return null; + + let afterCreatedAt = null; + let cursorMiss = false; + if (afterEventId) { + const [cursorRows] = await pool.query( + `SELECT created_at + FROM h5_session_stream_events + WHERE id = ? AND agent_session_id = ? AND user_id = ? + LIMIT 1`, + [afterEventId, sessionId, userId], + ); + if (!cursorRows[0]) { + cursorMiss = true; + } else { + afterCreatedAt = Number(cursorRows[0].created_at); + } + } + + const [rows] = await pool.query( + afterCreatedAt == null + ? `SELECT id, event_type, payload_json, upstream_event_id, created_at + FROM h5_session_stream_events + WHERE agent_session_id = ? AND user_id = ? + ORDER BY created_at ASC, id ASC + LIMIT ?` + : `SELECT id, event_type, payload_json, upstream_event_id, created_at + FROM h5_session_stream_events + WHERE agent_session_id = ? AND user_id = ? AND created_at > ? + ORDER BY created_at ASC, id ASC + LIMIT ?`, + afterCreatedAt == null + ? [sessionId, userId, limit] + : [sessionId, userId, afterCreatedAt, limit], + ); + + return { + events: rows.map((row) => ({ + id: row.id, + eventType: row.event_type, + payload: safeJsonParse(row.payload_json, null), + upstreamEventId: row.upstream_event_id ?? null, + createdAt: Number(row.created_at), + })), + cursorMiss, + }; + } + + async function getLatestEventForUser(userId, sessionId) { + const [rows] = await pool.query( + `SELECT id, event_type, payload_json, upstream_event_id, created_at + FROM h5_session_stream_events + WHERE agent_session_id = ? AND user_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 1`, + [sessionId, userId], + ); + const row = rows[0]; + if (!row) return null; + return { + id: row.id, + eventType: row.event_type, + payload: safeJsonParse(row.payload_json, null), + upstreamEventId: row.upstream_event_id ?? null, + createdAt: Number(row.created_at), + }; + } + + return { + appendEvent, + listEventsForUser, + getLatestEventForUser, + }; +} diff --git a/session-stream-store.test.mjs b/session-stream-store.test.mjs new file mode 100644 index 0000000..cfc122e --- /dev/null +++ b/session-stream-store.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSessionStreamStore } from './session-stream-store.mjs'; + +function createMockPool() { + let clock = 1; + const events = []; + return { + events, + async query(sql, params = []) { + if (sql.includes('INSERT INTO h5_session_stream_events')) { + const createdAt = clock++; + events.push({ + id: params[0], + user_id: params[1], + agent_session_id: params[2], + event_type: params[3], + payload_json: params[4], + upstream_event_id: params[5], + created_at: createdAt, + }); + return [{ insertId: 1 }]; + } + if (sql.includes('WHERE id = ? AND agent_session_id = ?')) { + const row = events.find((item) => item.id === params[0]); + return [[row ? { created_at: row.created_at } : undefined].filter(Boolean)]; + } + if (sql.includes('created_at > ?')) { + const after = Number(params[2]); + const rows = events + .filter( + (item) => + item.agent_session_id === params[0] && + item.user_id === params[1] && + Number(item.created_at) > after, + ) + .sort((a, b) => Number(a.created_at) - Number(b.created_at)); + return [rows]; + } + if (sql.includes('ORDER BY created_at ASC, id ASC')) { + const rows = events + .filter( + (item) => item.agent_session_id === params[0] && item.user_id === params[1], + ) + .sort((a, b) => Number(a.created_at) - Number(b.created_at)) + .slice(0, params[2]); + return [rows]; + } + if (sql.includes('ORDER BY created_at DESC, id DESC')) { + const row = events + .filter( + (item) => item.agent_session_id === params[0] && item.user_id === params[1], + ) + .sort((a, b) => Number(b.created_at) - Number(a.created_at))[0]; + return [[row].filter(Boolean)]; + } + throw new Error(`unexpected sql: ${sql}`); + }, + }; +} + +test('session stream store append and replay with cursor', async () => { + const pool = createMockPool(); + const store = createSessionStreamStore({ pool }); + const first = await store.appendEvent({ + userId: 'user-1', + sessionId: 'sess-1', + payload: { type: 'Message', message: { role: 'assistant', content: [] } }, + id: 'evt-1', + }); + await store.appendEvent({ + userId: 'user-1', + sessionId: 'sess-1', + payload: { type: 'Finish' }, + id: 'evt-2', + }); + + const head = await store.listEventsForUser('user-1', 'sess-1'); + assert.equal(head.events.length, 2); + + const tail = await store.listEventsForUser('user-1', 'sess-1', { afterEventId: 'evt-1' }); + assert.equal(tail.events.length, 1); + assert.equal(tail.events[0].payload.type, 'Finish'); + assert.equal(tail.cursorMiss, false); + + const miss = await store.listEventsForUser('user-1', 'sess-1', { afterEventId: 'missing' }); + assert.equal(miss.cursorMiss, true); +}); diff --git a/session-stream.mjs b/session-stream.mjs new file mode 100644 index 0000000..ad5ea8f --- /dev/null +++ b/session-stream.mjs @@ -0,0 +1,56 @@ +export const SESSION_STREAM_TERMINAL_TYPES = new Set(['Finish', 'Error']); + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function isSessionStreamReplayEnabled(env = process.env) { + return envFlag(env.MEMIND_SESSION_STREAM_REPLAY, false); +} + +export function parseSessionStreamLastEventId(value) { + const id = String(value ?? '').trim(); + return id || null; +} + +export function isTerminalSessionEvent(event) { + const type = String(event?.type ?? '').trim(); + return SESSION_STREAM_TERMINAL_TYPES.has(type); +} + +export function shouldPersistSessionStreamEvent(event) { + const type = String(event?.type ?? '').trim(); + if (!type) return false; + return type !== 'Ping'; +} + +export function parseSessionSseBlock(block) { + const lines = String(block ?? '').split('\n'); + let id = null; + let eventName = null; + let data = null; + for (const line of lines) { + if (line.startsWith('id:')) id = line.slice(3).trim() || null; + if (line.startsWith('event:')) eventName = line.slice(6).trim() || null; + if (line.startsWith('data:')) data = line.slice(5).trim(); + } + return { id, eventName, data }; +} + +export function formatSessionStreamSseChunk({ id = null, event = null, data }) { + const lines = []; + if (id) lines.push(`id: ${id}`); + if (event) lines.push(`event: ${event}`); + lines.push(`data: ${JSON.stringify(data)}`); + return `${lines.join('\n')}\n\n`; +} + +export function shouldSkipUpstreamAfterSessionReplay(events, { cursorMiss = false } = {}) { + if (!Array.isArray(events) || events.length === 0) { + return cursorMiss ? false : false; + } + const last = events[events.length - 1]; + return isTerminalSessionEvent(last?.payload ?? last); +} diff --git a/session-stream.test.mjs b/session-stream.test.mjs new file mode 100644 index 0000000..54ac194 --- /dev/null +++ b/session-stream.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + formatSessionStreamSseChunk, + isSessionStreamReplayEnabled, + isTerminalSessionEvent, + parseSessionSseBlock, + parseSessionStreamLastEventId, + shouldPersistSessionStreamEvent, + shouldSkipUpstreamAfterSessionReplay, +} from './session-stream.mjs'; + +test('formatSessionStreamSseChunk includes id for replay cursor', () => { + const chunk = formatSessionStreamSseChunk({ + id: 'evt-1', + data: { type: 'Message', message: { role: 'assistant' } }, + }); + assert.match(chunk, /^id: evt-1\n/); + assert.match(chunk, /"type":"Message"/); +}); + +test('parseSessionSseBlock extracts id and data', () => { + const parsed = parseSessionSseBlock('id: upstream-1\ndata: {"type":"Finish"}\n\n'); + assert.equal(parsed.id, 'upstream-1'); + assert.equal(parsed.data, '{"type":"Finish"}'); +}); + +test('terminal detection and upstream skip policy', () => { + assert.equal(isTerminalSessionEvent({ type: 'Finish' }), true); + assert.equal(isTerminalSessionEvent({ type: 'Message' }), false); + assert.equal( + shouldSkipUpstreamAfterSessionReplay([ + { payload: { type: 'Message' } }, + { payload: { type: 'Finish' } }, + ]), + true, + ); + assert.equal( + shouldSkipUpstreamAfterSessionReplay([{ payload: { type: 'Message' } }]), + false, + ); + assert.equal(shouldPersistSessionStreamEvent({ type: 'Ping' }), false); + assert.equal(shouldPersistSessionStreamEvent({ type: 'Message' }), true); + assert.equal(isSessionStreamReplayEnabled({ MEMIND_SESSION_STREAM_REPLAY: '1' }), true); + assert.equal(parseSessionStreamLastEventId(' abc '), 'abc'); +}); diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index eabdf0e..aa16d52 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -192,6 +192,7 @@ export function ChatPanel({ remainingHeight: number; }>(null); const prevMessageCountRef = useRef(messages.length); + const showAssistantTyping = chatState === 'waiting' || chatState === 'streaming'; inputRef.current = input; pendingImagesRef.current = pendingImages; @@ -293,13 +294,14 @@ export function ChatPanel({ } const messageCountIncreased = messages.length > prevMessageCountRef.current; - if ((nearBottomRef.current || chatState === 'streaming') && messageCountIncreased) { + if ((nearBottomRef.current || showAssistantTyping) && messageCountIncreased) { container.scrollTop = container.scrollHeight; nearBottomRef.current = true; } prevMessageCountRef.current = messages.length; }, [ chatState, + showAssistantTyping, historyHasMore, historyLoadingMore, messages.length, @@ -308,6 +310,13 @@ export function ChatPanel({ session?.id, ]); + useLayoutEffect(() => { + if (!showAssistantTyping) return; + const container = mainRef.current; + if (!container || !nearBottomRef.current) return; + container.scrollTop = container.scrollHeight; + }, [showAssistantTyping, messages.length]); + const busy = chatState === 'streaming' || chatState === 'loading' || @@ -331,9 +340,7 @@ export function ChatPanel({ ? '正在上传图片,发出后会自动继续…' : chatState === 'connecting' ? '正在创建会话…' - : chatState === 'waiting' - ? '请求已发出,正在等待后台开始处理…' - : null; + : null; const sendButtonLabel = uploadingImage ? '上传中…' @@ -672,7 +679,7 @@ export function ChatPanel({ )} + + + + + + + ); +} + +function shouldShowEndTypingIndicator(messages: Message[], streaming: boolean) { + if (!streaming) return false; + const lastUserIndex = messages.findLastIndex((message) => message.role === 'user'); + if (lastUserIndex < 0) return true; + const assistantReply = messages + .slice(lastUserIndex + 1) + .find((message) => message.role === 'assistant'); + if (!assistantReply) return true; + if (getDisplayText(assistantReply).trim() || getThinking(assistantReply)) return false; + if (hasToolActivity(assistantReply)) return false; + return true; +} + function ToolBadge({ message, active }: { message: Message; active: boolean }) { if (message.role !== 'assistant') return null; const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse'); @@ -265,6 +290,7 @@ function MessageRow({ sessionId, compact = false, activeToolMessage = false, + showInlineTyping = false, }: { message: Message; avatarUrl: string | null; @@ -279,6 +305,7 @@ function MessageRow({ sessionId?: string; compact?: boolean; activeToolMessage?: boolean; + showInlineTyping?: boolean; }) { const [actionsOpen, setActionsOpen] = useState(false); const rawText = getDisplayText(message); @@ -295,7 +322,11 @@ function MessageRow({
- + {showInlineTyping ? ( + + ) : ( + + )}
); @@ -471,6 +502,20 @@ export function MessageList({ const visibleMessages = renderableMessages.filter( (message) => !isToolOnlyAssistantMessage(message) || message === activeToolMessage, ); + const showEndTyping = shouldShowEndTypingIndicator(renderableMessages, streaming); + const lastUserIndex = renderableMessages.findLastIndex((message) => message.role === 'user'); + const inlineTypingMessage = + lastUserIndex >= 0 + ? renderableMessages + .slice(lastUserIndex + 1) + .find( + (message) => + message.role === 'assistant' && + !getDisplayText(message).trim() && + !getThinking(message) && + !hasToolActivity(message), + ) ?? null + : null; return (
@@ -499,21 +544,16 @@ export function MessageList({ publishUsername={publishUsername} compact={compact} activeToolMessage={message === activeToolMessage} + showInlineTyping={showEndTyping && message === inlineTypingMessage} />
); })} - {streaming && ( -
+ {showEndTyping && !inlineTypingMessage && ( +
-
- - - - - -
+
)} diff --git a/src/components/MindSpaceSpaceChat.tsx b/src/components/MindSpaceSpaceChat.tsx index 70e0e32..6a863ed 100644 --- a/src/components/MindSpaceSpaceChat.tsx +++ b/src/components/MindSpaceSpaceChat.tsx @@ -112,7 +112,7 @@ export function MindSpaceSpaceChat({ ) : null} setOpen(!open)} /> diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 09bd169..e26308e 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import crypto from 'node:crypto'; import path from 'node:path'; import { Readable, Transform, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; @@ -16,13 +17,23 @@ import { import { buildCurrentTimeAgentPrefix, buildTaskRoutingAgentText } from './user-memory-profile.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs'; +import { createSessionBrokerMetrics, isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; import { ensureGooseUserMessageMetadata } from './goose-message.mjs'; +import { consumeSessionEventsUntilFinish } from './session-reply-wait.mjs'; import { memoryLimitForIntervention, resolveMemoryInterventionMode, } from './memory-intervention.mjs'; +import { + formatSessionStreamSseChunk, + isSessionStreamReplayEnabled, + parseSessionSseBlock, + parseSessionStreamLastEventId, + shouldPersistSessionStreamEvent, + shouldSkipUpstreamAfterSessionReplay, +} from './session-stream.mjs'; const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false }, @@ -566,41 +577,57 @@ export function sanitizeSessionConversationPublicHtmlLinks(conversation, current return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser)); } -function createSessionEventSanitizer(currentUser, { onEvent } = {}) { +function createSessionEventSanitizer(currentUser, { onEvent, onPersistFrame, normalizeReplayIds = false } = {}) { let buffer = ''; - const flushChunk = (controller, chunk) => { - if (!chunk) return; - const block = String(chunk); - if (!block.includes('data: ')) { + const flushChunk = (controller, block) => { + if (!block) return; + const parsed = parseSessionSseBlock(block); + if (!parsed.data) { controller.push(block); return; } + let event; + try { + event = JSON.parse(parsed.data); + } catch { + controller.push(block); + return; + } + event = finalizeSessionStreamEvent(event); + if (typeof onEvent === 'function') { + try { + onEvent(event); + } catch { + // Ignore side-effect failures and keep SSE flowing to the client. + } + } + if (event?.type === 'Message' && event.message) { + event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser); + } else if (event?.type === 'UpdateConversation' && Array.isArray(event.conversation)) { + event.conversation = sanitizeSessionConversationPublicHtmlLinks(event.conversation, currentUser); + } + if (normalizeReplayIds) { + const frameId = parsed.id || crypto.randomUUID(); + if (typeof onPersistFrame === 'function' && shouldPersistSessionStreamEvent(event)) { + try { + onPersistFrame({ id: frameId, upstreamEventId: parsed.id ?? null, payload: event }); + } catch { + // Ignore persistence failures and keep SSE flowing to the client. + } + } + controller.push(formatSessionStreamSseChunk({ + id: frameId, + event: parsed.eventName, + data: event, + })); + return; + } const lines = block.split('\n'); const sanitizedLines = lines.map((line) => { if (!line.startsWith('data: ')) return line; - const raw = line.slice(6); - let event; - try { - event = JSON.parse(raw); - } catch { - return line; - } - event = finalizeSessionStreamEvent(event); - if (typeof onEvent === 'function') { - try { - onEvent(event); - } catch { - // Ignore side-effect failures and keep SSE flowing to the client. - } - } - if (event?.type === 'Message' && event.message) { - event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser); - } else if (event?.type === 'UpdateConversation' && Array.isArray(event.conversation)) { - event.conversation = sanitizeSessionConversationPublicHtmlLinks(event.conversation, currentUser); - } return `data: ${JSON.stringify(event)}`; }); - controller.push(sanitizedLines.join('\n')); + controller.push(`${sanitizedLines.join('\n')}\n`); }; return new Transform({ @@ -844,6 +871,7 @@ export function createTkmindProxy({ apiSecret, userAuth, sessionAccess = null, + sessionStreamStore = null, llmProviderService, localFetchAsset, subscriptionService, @@ -853,6 +881,9 @@ export function createTkmindProxy({ }) { const sessionStore = sessionAccess ?? createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() }); + const brokerMetrics = isSessionBrokerMetricsEnabled() && sessionStore.enabled + ? createSessionBrokerMetrics({ logger: console }) + : null; const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : []; const primaryTarget = targets[0] ?? apiTarget ?? ''; let rrIdx = 0; @@ -1215,6 +1246,16 @@ export function createTkmindProxy({ // Only honor it if that upstream is still configured; otherwise fall back to // the legacy integer index, then to primary. if (target && targets.includes(target)) return target; + if (brokerMetrics) { + if (target && !targets.includes(target)) { + brokerMetrics.resolveTargetMiss({ + sessionId, + target, + node, + reason: 'stale_pinned_target', + }); + } + } return targets[node] ?? primaryTarget; } catch { return primaryTarget; @@ -1319,7 +1360,7 @@ export function createTkmindProxy({ ); } - async function submitSessionReplyForUser( + async function prepareSessionReplyBody( userId, sessionId, requestId, @@ -1377,8 +1418,24 @@ export function createTkmindProxy({ ...body, user_message: ensureGooseUserMessageMetadata(body.user_message), }; - const target = await resolveTarget(sessionId); + return { body, target }; + } + + async function submitSessionReplyForUser( + userId, + sessionId, + requestId, + userMessage, + options = {}, + ) { + const { body, target } = await prepareSessionReplyBody( + userId, + sessionId, + requestId, + userMessage, + options, + ); const upstream = await apiFetch( target, apiSecret, @@ -1395,6 +1452,56 @@ export function createTkmindProxy({ return { ok: true }; } + async function submitSessionReplyAndAwaitFinishForUser( + userId, + sessionId, + requestId, + userMessage, + { toolMode = 'chat', forceDeepReasoning = false, timeoutMs = 15 * 60 * 1000 } = {}, + ) { + const { body, target } = await prepareSessionReplyBody( + userId, + sessionId, + requestId, + userMessage, + { toolMode, forceDeepReasoning }, + ); + const eventsResponse = await apiFetch( + target, + apiSecret, + `/sessions/${encodeURIComponent(sessionId)}/events`, + { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + }, + ); + if (!eventsResponse.ok || !eventsResponse.body) { + const text = await eventsResponse.text().catch(() => ''); + throw new Error(text || `无法建立 session 事件流 (${eventsResponse.status})`); + } + + const finishPromise = consumeSessionEventsUntilFinish(eventsResponse.body, { + requestId, + timeoutMs, + }); + const replyResponse = await apiFetch( + target, + apiSecret, + `/sessions/${encodeURIComponent(sessionId)}/reply`, + { + method: 'POST', + body: JSON.stringify(body), + }, + ); + if (!replyResponse.ok) { + const text = await replyResponse.text().catch(() => ''); + throw new Error(text || `发送失败 (${replyResponse.status})`); + } + replyResponse.body?.cancel?.().catch?.(() => {}); + const finish = await finishPromise; + return { ok: true, ...finish }; + } + const requireUser = async (req, res, next) => { try { const session = req.userSession; @@ -1745,12 +1852,66 @@ export function createTkmindProxy({ const upstreamAbort = new AbortController(); const streamRequestedAt = Date.now(); let clientClosed = false; + const replayEnabled = isSessionStreamReplayEnabled() && sessionStreamStore; + const initialLastEventId = parseSessionStreamLastEventId(req.get('last-event-id')); const abortUpstream = () => { clientClosed = true; upstreamAbort.abort(); }; req.once('close', abortUpstream); + + const writeReplayToClient = async (events) => { + for (const event of events) { + if (clientClosed || res.writableEnded) return; + await writeClientChunk(formatSessionStreamSseChunk({ + id: event.id, + data: event.payload, + })); + } + }; + + let writeClientChunk = async () => {}; + let pendingBalance = null; + const waitForDrain = () => new Promise((resolve) => res.once('drain', resolve)); + writeClientChunk = async (chunk) => { + if (res.writableEnded || clientClosed) return; + let needsDrain = !res.write(chunk); + if (pendingBalance != null && !res.writableEnded && !clientClosed) { + needsDrain = !res.write(appendBalanceEvent(pendingBalance)) || needsDrain; + pendingBalance = null; + } + if (needsDrain && !res.writableEnded && !clientClosed) { + await waitForDrain(); + } + }; + try { + if (replayEnabled && initialLastEventId) { + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + + let batch = await sessionStreamStore.listEventsForUser(req.currentUser.id, sessionId, { + afterEventId: initialLastEventId, + }); + if (!batch) { + res.status(404).end(); + return; + } + if (batch.cursorMiss) { + batch = await sessionStreamStore.listEventsForUser(req.currentUser.id, sessionId); + } + await writeReplayToClient(batch?.events ?? []); + if (shouldSkipUpstreamAfterSessionReplay(batch?.events ?? [], { cursorMiss: batch?.cursorMiss })) { + req.off('close', abortUpstream); + if (!res.writableEnded) res.end(); + return; + } + } + const pathname = `/sessions/${sessionId}/events`; const sessionTarget = await resolveTarget(sessionId); const upstream = await apiFetch(sessionTarget, apiSecret, pathname, { @@ -1765,18 +1926,23 @@ export function createTkmindProxy({ if (!upstream.ok || !upstream.body) { const text = await upstream.text().catch(() => ''); - res.status(upstream.status).send(text); + if (!res.headersSent) { + res.status(upstream.status).send(text); + } else if (!res.writableEnded) { + res.end(); + } return; } - res.status(upstream.status); - res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - res.setHeader('Cache-Control', 'no-cache, no-transform'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - res.flushHeaders?.(); + if (!res.headersSent) { + res.status(upstream.status); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + } - let pendingBalance = null; const billingTransform = createSseBillingTransform({ onFinish: async (event) => { const billingRequestId = event.request_id ?? event.chat_request_id ?? null; @@ -1815,19 +1981,21 @@ export function createTkmindProxy({ callback(null, chunk); }, }); - const linkSanitizer = createSessionEventSanitizer(req.currentUser, { onEvent }); - const waitForDrain = () => new Promise((resolve) => res.once('drain', resolve)); - const writeClientChunk = async (chunk) => { - if (res.writableEnded || clientClosed) return; - let needsDrain = !res.write(chunk); - if (pendingBalance != null && !res.writableEnded && !clientClosed) { - needsDrain = !res.write(appendBalanceEvent(pendingBalance)) || needsDrain; - pendingBalance = null; - } - if (needsDrain && !res.writableEnded && !clientClosed) { - await waitForDrain(); - } - }; + const linkSanitizer = createSessionEventSanitizer(req.currentUser, { + onEvent, + normalizeReplayIds: replayEnabled, + onPersistFrame: replayEnabled + ? ({ id, upstreamEventId, payload }) => { + void sessionStreamStore.appendEvent({ + userId: req.currentUser.id, + sessionId, + id, + upstreamEventId, + payload, + }).catch(() => {}); + } + : undefined, + }); const clientSink = new Writable({ write(chunk, _encoding, callback) { writeClientChunk(chunk).then(() => callback(), callback); @@ -2032,6 +2200,7 @@ export function createTkmindProxy({ startSessionForUser, getRuntimeStatus, submitSessionReplyForUser, + submitSessionReplyAndAwaitFinishForUser, apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init), apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init), }; diff --git a/user-auth.mjs b/user-auth.mjs index 423047b..f54b4d7 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1680,6 +1680,17 @@ export function createUserAuth(pool, options = {}) { ); }; + /** Enable platform/web (web_search, fetch_url) for existing role defaults. */ + const upgradeWebCapability = async () => { + const now = Date.now(); + await pool.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', 'user', 'web', 1, ?) + ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, + [now], + ); + }; + /** Enable platform skill loading + chat recall for existing role defaults. */ const upgradeDefaultUserCapabilities = async () => { const now = Date.now(); @@ -2117,6 +2128,7 @@ export function createUserAuth(pool, options = {}) { } await seedRoleCapabilityDefaults(); await upgradeMemoryStoreCapability(); + await upgradeWebCapability(); await upgradeDefaultUserCapabilities(); await seedRolePolicyDefaults(); await seedRoleSkillDefaults();