diff --git a/.env.example b/.env.example index b3ba017..e849024 100644 --- a/.env.example +++ b/.env.example @@ -162,12 +162,14 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # MEMIND_CHAT_ROUTER_FALLBACK_ROUTE=direct_chat # ----- Chat task intent layer(docs/architecture/chat-task-intent-layer-review-20260828.md)----- -# 阶段 B:direct→agent 升级时把 snapshot 历史注入 Goose 输入(默认关,建议先 canary) -# MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED=0 -# MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS= -# 阶段 C:0.72 兜底在歧义文本上 defer 给 LLM router(默认关;建议先 SHADOW=1 观测) -# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED=0 -# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS= +# 阶段 B:direct→agent 升级时把 snapshot 历史注入 Goose 输入 +# canary 填 user_id(UUID);留空=全员。本机 john = a6fb1e97-2b0f-447b-b138-4561d8e5c53e +# MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED=1 +# MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS=a6fb1e97-2b0f-447b-b138-4561d8e5c53e +# 阶段 C:歧义文本不走 0.72 直聊兜底,交给已有 LLM router +# 寒暄/FAQ/记忆召回仍走规则 +# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED=1 +# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS=a6fb1e97-2b0f-447b-b138-4561d8e5c53e # MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH=12 # ----- Token 压缩(本地开发可选,降低 DeepSeek 上下文)----- diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 52e8a73..36e6441 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -300,6 +300,8 @@ const FRESH_SESSION_RECOVERY_CODES = new Set([ 'SESSION_REASONING_CONTENT_POISONED', 'SESSION_VISUAL_CONTEXT_UNSUPPORTED', 'SESSION_EMPTY_FINISH', + 'SESSION_MCP_ENV_STALE', + 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED', ]); async function resolveConversationForFreshSessionRecovery({ @@ -392,6 +394,19 @@ export function normalizeAgentRunToolMode(value) { throw new Error(`不支持的 tool_mode: ${value}`); } +export function shouldPreferDirectChat({ + rainActive = false, + cursorFirstAgent = false, + routingDecision = null, + agentSessionId = null, + forceDeepReasoning = false, +} = {}) { + if (rainActive || cursorFirstAgent) return false; + if (routingDecision === CHAT_INTENT_ROUTE.AGENT) return false; + if (routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT) return true; + return isDirectChatSessionId(agentSessionId) && !forceDeepReasoning; +} + function normalizeTaskType(value) { const normalized = String(value ?? '').trim(); return normalized || null; @@ -2111,11 +2126,13 @@ export function createAgentRunGateway({ ); } } - const preferDirectChat = - !rainActive && - !cursorFirstAgent && - (routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT || - (isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning)); + const preferDirectChat = shouldPreferDirectChat({ + rainActive, + cursorFirstAgent, + routingDecision, + agentSessionId: row.agent_session_id ?? null, + forceDeepReasoning: runOptions.forceDeepReasoning, + }); const directChatInput = { sessionId: row.agent_session_id ?? null, toolMode: runOptions.toolMode, diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index cb058b4..ddc424d 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -12,6 +12,7 @@ import { resolveActiveTaskContext, resolveRouterTranscript, resolveRequiredCodeExecutor, + shouldPreferDirectChat, } from './agent-run-gateway.mjs'; test('resolveActiveTaskContext returns failed agent task metadata for same session', async () => { @@ -131,6 +132,25 @@ test('required image generation cannot succeed without a verified raster image_m })); }); +test('shouldPreferDirectChat does not stick h5direct sessions when router chooses agent', () => { + assert.equal(shouldPreferDirectChat({ + routingDecision: 'agent_orchestration', + agentSessionId: 'h5direct_existing', + }), false); + assert.equal(shouldPreferDirectChat({ + routingDecision: 'direct_chat', + agentSessionId: 'h5direct_existing', + }), true); + assert.equal(shouldPreferDirectChat({ + routingDecision: null, + agentSessionId: 'h5direct_existing', + }), true); + assert.equal(shouldPreferDirectChat({ + routingDecision: null, + agentSessionId: '20260704_11', + }), false); +}); + test('normalizeAgentRunWorkerIdentity creates a stable normalized runtime boundary', () => { assert.deepEqual( normalizeAgentRunWorkerIdentity({ @@ -1597,6 +1617,102 @@ test('agent run replaces reasoning-poisoned Goose session and retries with visib assert.equal(replacedData?.reason, 'SESSION_REASONING_CONTENT_POISONED'); }); +test('agent run rotates a Goose session when MindSpace MCP env is stale', async () => { + const pool = createFakePool(); + const submitted = []; + const priorConversation = [ + { + role: 'user', + content: [{ type: 'text', text: '帮我修复活动报名页 bind' }], + }, + { + role: 'assistant', + content: [{ type: 'text', text: '正在检查 dataset 字段…' }], + }, + ]; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-mcp-fresh' }; + }, + async fetchSessionConversationForUser() { + return priorConversation; + }, + async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage) { + submitted.push({ userId, sessionId, requestId, userMessage }); + if (sessionId === 'session-mcp-stale') { + const error = new Error('MindSpace MCP scoped token cannot be refreshed in-place'); + error.code = 'SESSION_MCP_ENV_STALE'; + throw error; + } + return { ok: true, finishEvent: { type: 'Finish' }, tokenState: { totalTokens: 8 } }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: 'session-mcp-stale', + requestId: 'req-mcp-stale', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '继续修复 bind' }], + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.deepEqual(submitted.map((item) => item.sessionId), [ + 'session-mcp-stale', + 'session-mcp-fresh', + ]); + const replaced = pool.events.find((event) => event.eventType === 'poisoned_session_replaced'); + const replacedData = typeof replaced?.dataJson === 'string' + ? JSON.parse(replaced.dataJson) + : replaced?.dataJson; + assert.equal(replacedData?.reason, 'SESSION_MCP_ENV_STALE'); +}); + +test('agent run rotates when historical image scrub is unsupported', async () => { + const pool = createFakePool(); + const submitted = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-image-fresh' }; + }, + async fetchSessionConversationForUser() { + return []; + }, + async submitSessionReplyAndAwaitFinishForUser(_userId, sessionId) { + submitted.push(sessionId); + if (sessionId === 'session-image-stale') { + const error = new Error('historical_image_session_update_unsupported:405'); + error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED'; + throw error; + } + return { ok: true, finishEvent: { type: 'Finish' } }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: 'session-image-stale', + requestId: 'req-image-stale', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '继续写页面' }], + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.deepEqual(submitted, ['session-image-stale', 'session-image-fresh']); +}); + test('agent run degrades visual inspection after an earlier session-history recovery', async () => { const pool = createFakePool(); const submitted = []; @@ -2933,6 +3049,98 @@ test('agent run persists direct session transcript before escalating to goosed', assert.equal(removed[0], 'deep-session-1'); }); +test('agent run escalates h5direct sessions when router chooses agent without forceDeepReasoning', async () => { + const pool = createFakePool({ + sessionDeliverables: { + 'user-1:deep-session-1': [{ + page_id: 'page-router-agent', + title: '任务页面', + publication_id: 'pub-router-agent', + publication_status: 'online', + public_url: 'http://127.0.0.1:5173/MindSpace/user-1/public/a.html', + }], + }, + }); + const submitted = []; + const directRuns = []; + const gateway = createAgentRunGateway({ + pool, + tkmindProxy: { + async startSessionForUser(userId) { + assert.equal(userId, 'user-1'); + return { id: 'deep-session-1' }; + }, + async submitSessionReplyForUser(userId, sessionId, requestId, userMessage, options = {}) { + submitted.push({ userId, sessionId, requestId, userMessage, options }); + }, + }, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { + route: 'agent_orchestration', + confidence: 0.91, + reason: '需要执行任务', + source: 'llm', + }; + }, + }, + directChatService: { + canHandle() { + return true; + }, + async run(input) { + directRuns.push(input); + return { sessionId: input.sessionId ?? 'h5direct_existing' }; + }, + getStatus() { + return { enabled: true }; + }, + }, + sessionSnapshotService: { + async get(sessionId) { + if (sessionId !== 'h5direct_existing') return null; + return { + messages: [ + { role: 'user', content: [{ type: 'text', text: '比较两款车载冰箱' }] }, + { role: 'assistant', content: [{ type: 'text', text: '先看容积和功耗' }] }, + ], + }; + }, + async remove() {}, + }, + conversationMemoryService: { + async saveConversationMessages(_sessionId, _userId, messages) { + return messages; + }, + }, + directEscalationContextPolicy: { + enabled: true, + canaryUserIds: new Set(), + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: 'h5direct_existing', + requestId: 'req-router-agent-escalate', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我比较一下英得尔和美的车载冰箱并整理成页面' }], + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(directRuns.length, 0); + assert.equal(pool.runs.get(run.id).agent_session_id, 'deep-session-1'); + assert.equal(submitted[0].sessionId, 'deep-session-1'); + assert.ok(pool.events.some((event) => event.eventType === 'direct_session_escalated_to_deep_reasoning')); + assert.ok(pool.events.some((event) => event.eventType === 'direct_escalation_context_injected')); + assert.match(submitted[0].userMessage.content[0].text, /会话恢复上下文/); +}); + test('agent run injects direct escalation context when feature flag is enabled', async () => { const pool = createFakePool({ sessionDeliverables: { diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index c5ba563..4e38bbc 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -26,6 +26,7 @@ import { deriveUserFacingText, } from './conversation-display.mjs'; import { isGoalRunIntent } from './goal-run-intent.mjs'; +import { resolveMemindRuntimeProfile } from './scripts/memind-runtime-profile.mjs'; import { pgvectorMemoryBackendInternals } from './memory-v2-pgvector.mjs'; import { isChatSessionDeferEnabledForUser, @@ -651,6 +652,7 @@ function buildRouterSystemPrompt(grantedSkills = []) { '判断原则:', '- 用户只要文字回答,不要求“做出来/发布/生成链接/改文件” → direct_chat', '- 用户要产出可访问页面、文件、链接,或需要工具/skills → agent_orchestration', + '- 用户要求对比选型、整理清单/待办/用药/作业、规划行程交通住宿,或把结果发给家人同事(即使没说“页面”)→ agent_orchestration,不要只当口头建议', '- 用户要「做攻略/行程/线路图/游记」或粘贴多日自驾/旅行行程(即使未说「页面」)→ agent_orchestration + static-page-publish,不要只回文字', '- 用户询问实时赛况、新闻、天气、行情等需要联网查询的问题 → agent_orchestration,suggested_skill 填 web(不要填 search)', '- 不确定时优先 agent_orchestration,避免漏执行', @@ -1951,6 +1953,33 @@ export function createChatIntentRouter(options = {}) { }; } +const LOCAL_CHAT_ROUTER_ENV_KEYS = [ + 'MEMIND_CHAT_LLM_ROUTER_ENABLED', + 'MEMIND_CHAT_LLM_ROUTER_SHADOW', + 'MEMIND_CHAT_ROUTER_MODEL', + 'MEMIND_CHAT_ROUTER_MODEL_API', + 'MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID', + 'MEMIND_CHAT_ROUTER_TIMEOUT_MS', + 'MEMIND_CHAT_ROUTER_MIN_CONFIDENCE', + 'MEMIND_CHAT_ROUTER_CANARY_USER_IDS', + 'MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED', + 'MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS', + 'MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH', + 'MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED', + 'MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS', +]; + +export function mergeChatRouterEffectiveEnv(processEnv = {}, overrides = {}) { + const merged = { ...processEnv, ...overrides }; + if (resolveMemindRuntimeProfile(processEnv) !== 'local') return merged; + for (const key of LOCAL_CHAT_ROUTER_ENV_KEYS) { + const value = processEnv?.[key]; + if (value == null || String(value).trim() === '') continue; + merged[key] = value; + } + return merged; +} + export function createManagedChatIntentRouter({ llmProviderService, memoryV2 = null, @@ -1982,7 +2011,7 @@ export function createManagedChatIntentRouter({ updatedAt: state?.updatedAt ?? null, updatedBy: state?.updatedBy ?? null, fingerprint: state?.fingerprint ?? `admin-db:${Date.now()}`, - effectiveEnv: { ...env, ...(state?.overrides ?? {}) }, + effectiveEnv: mergeChatRouterEffectiveEnv(env, state?.overrides ?? {}), configError: null, }; } catch (err) { diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index 1ca7afd..5d6fa5b 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -11,6 +11,7 @@ import { coercePageGenerationSkill, createChatIntentRouter, createManagedChatIntentRouter, + mergeChatRouterEffectiveEnv, formatRouterTranscript, shouldDeferActiveTaskRoutingToLlm, shouldForceActiveAgentTaskContinuation, @@ -901,6 +902,33 @@ test('createChatIntentRouter routes memory recall through rules when memory reso assert.equal(result.source, 'rule'); }); +test('mergeChatRouterEffectiveEnv lets local .env win over admin-db router flags', () => { + const merged = mergeChatRouterEffectiveEnv({ + MEMIND_RUNTIME_PROFILE: 'local', + MEMIND_CHAT_LLM_ROUTER_ENABLED: '1', + MEMIND_CHAT_LLM_ROUTER_SHADOW: '0', + MEMIND_CHAT_ROUTER_MODEL: 'deepseek-v4-flash', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED: '1', + }, { + MEMIND_CHAT_LLM_ROUTER_ENABLED: '0', + MEMIND_CHAT_LLM_ROUTER_SHADOW: '1', + MEMIND_CHAT_ROUTER_MODEL: 'kimi-k2.6', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED: '0', + }); + assert.equal(merged.MEMIND_CHAT_LLM_ROUTER_ENABLED, '1'); + assert.equal(merged.MEMIND_CHAT_LLM_ROUTER_SHADOW, '0'); + assert.equal(merged.MEMIND_CHAT_ROUTER_MODEL, 'deepseek-v4-flash'); + assert.equal(merged.MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED, '1'); + + const production = mergeChatRouterEffectiveEnv({ + MEMIND_RUNTIME_PROFILE: 'production', + MEMIND_CHAT_LLM_ROUTER_ENABLED: '1', + }, { + MEMIND_CHAT_LLM_ROUTER_ENABLED: '0', + }); + assert.equal(production.MEMIND_CHAT_LLM_ROUTER_ENABLED, '0'); +}); + test('createManagedChatIntentRouter hot-loads admin config and activates llm routing', async () => { const states = [ { @@ -1094,6 +1122,90 @@ test('classifyWithRules keeps memory recall on direct chat when defer is enabled assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); }); +test('createChatIntentRouter calls LLM for ambiguous chat-session text when defer is enabled', async () => { + let llmCalls = 0; + const router = createChatIntentRouter({ + llmProviderService: { + async createChatCompletion() { + llmCalls += 1; + return { + ok: true, + reply: JSON.stringify({ + route: 'agent_orchestration', + confidence: 0.88, + reason: '需要整理对比并交付', + }), + }; + }, + }, + env: { + ...process.env, + MEMIND_CHAT_LLM_ROUTER_ENABLED: '1', + MEMIND_CHAT_LLM_ROUTER_SHADOW: '0', + MEMIND_CHAT_ROUTER_CANARY_USER_IDS: 'john', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED: '1', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS: 'john', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH: '12', + }, + }); + + const result = await router.classify({ + userId: 'john', + sessionId: 'h5direct_abc', + sessionMessageCount: 3, + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我比较一下英得尔和美的车载冰箱' }], + metadata: { displayText: '帮我比较一下英得尔和美的车载冰箱' }, + }, + }); + + assert.equal(llmCalls, 1); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'llm'); +}); + +test('createChatIntentRouter keeps greetings on rules when chat-session defer is enabled', async () => { + let llmCalls = 0; + const router = createChatIntentRouter({ + llmProviderService: { + async createChatCompletion() { + llmCalls += 1; + return { + ok: true, + reply: JSON.stringify({ + route: 'agent_orchestration', + confidence: 0.99, + reason: 'should not run', + }), + }; + }, + }, + env: { + ...process.env, + MEMIND_CHAT_LLM_ROUTER_ENABLED: '1', + MEMIND_CHAT_LLM_ROUTER_SHADOW: '0', + MEMIND_CHAT_ROUTER_CANARY_USER_IDS: 'john', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED: '1', + MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS: 'john', + }, + }); + + const result = await router.classify({ + userId: 'john', + sessionId: 'h5direct_abc', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '你好' }], + metadata: { displayText: '你好' }, + }, + }); + + assert.equal(llmCalls, 0); + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(result.source, 'rule'); +}); + test('createChatIntentRouter fast-paths news lookup to agent without LLM', async () => { let llmCalls = 0; const router = createChatIntentRouter({ diff --git a/memory-v2-admin-config.mjs b/memory-v2-admin-config.mjs index 721b1a7..d20392f 100644 --- a/memory-v2-admin-config.mjs +++ b/memory-v2-admin-config.mjs @@ -23,6 +23,11 @@ const FIELD_SPECS = [ { env: 'MEMIND_CHAT_ROUTER_MEMORY_LIMIT', group: 'chatIntentRouter', field: 'memoryResolveLimit', type: 'number' }, { env: 'MEMIND_CHAT_ROUTER_TIMEOUT_MS', group: 'chatIntentRouter', field: 'timeoutMs', type: 'number' }, { env: 'MEMIND_CHAT_ROUTER_FALLBACK_ROUTE', group: 'chatIntentRouter', field: 'fallbackRoute', type: 'string' }, + { env: 'MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED', group: 'chatIntentRouter', field: 'chatSessionDeferEnabled', type: 'boolean' }, + { env: 'MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS', group: 'chatIntentRouter', field: 'chatSessionDeferCanaryUserIds', type: 'string' }, + { env: 'MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH', group: 'chatIntentRouter', field: 'chatSessionDeferMinTextLength', type: 'number' }, + { env: 'MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED', group: 'chatIntentRouter', field: 'directEscalationContextEnabled', type: 'boolean' }, + { env: 'MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS', group: 'chatIntentRouter', field: 'directEscalationContextCanaryUserIds', type: 'string' }, { env: 'MEMORY_CANDIDATE_ENABLED', group: 'candidateMemory', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_CANDIDATE_MODE', group: 'candidateMemory', field: 'mode', type: 'string' }, diff --git a/mindspace-mcp-scoped-token.mjs b/mindspace-mcp-scoped-token.mjs index 319ac59..ad346fc 100644 --- a/mindspace-mcp-scoped-token.mjs +++ b/mindspace-mcp-scoped-token.mjs @@ -238,6 +238,32 @@ export function verifyMindSpaceMcpScopedToken({ return claims; } +export function peekMindSpaceMcpScopedTokenExpiryMs(token) { + try { + const segments = String(token ?? '').split('.'); + if (segments.length !== 3 || segments[0] !== TOKEN_PREFIX) return null; + const payload = decodeJson(segments[1]); + const exp = Number(payload?.exp); + if (!Number.isFinite(exp) || exp <= 0) return null; + return exp * 1000; + } catch { + return null; + } +} + +export function mindSpaceMcpScopedTokenNeedsRotation( + token, + { + now = Date.now(), + skewSeconds = 120, + } = {}, +) { + const expiryMs = peekMindSpaceMcpScopedTokenExpiryMs(token); + if (expiryMs == null) return true; + const skewMs = Math.max(0, Number(skewSeconds) || 0) * 1000; + return expiryMs <= Number(now) + skewMs; +} + export const mindSpaceMcpScopedTokenInternals = { DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, diff --git a/mindspace-mcp-scoped-token.test.mjs b/mindspace-mcp-scoped-token.test.mjs index cc9aeba..5d14625 100644 --- a/mindspace-mcp-scoped-token.test.mjs +++ b/mindspace-mcp-scoped-token.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { mintMindSpaceMcpScopedToken, + mindSpaceMcpScopedTokenNeedsRotation, + peekMindSpaceMcpScopedTokenExpiryMs, verifyMindSpaceMcpScopedToken, } from './mindspace-mcp-scoped-token.mjs'; @@ -118,3 +120,34 @@ test('MindSpace MCP token refuses incomplete scopes and weak secrets', () => { error?.code === 'invalid_mcp_scope', ); }); + +test('MindSpace MCP token peek reports expiry and rotation window', () => { + const token = mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: 'mindspace://users/user-1/workspace', + tools: ['write_file'], + ttlSeconds: 3600, + now: 1_000_000, + tokenId: 'token-1', + }); + + assert.equal(peekMindSpaceMcpScopedTokenExpiryMs(token), 4_600_000); + assert.equal( + mindSpaceMcpScopedTokenNeedsRotation(token, { now: 1_000_000, skewSeconds: 120 }), + false, + ); + assert.equal( + mindSpaceMcpScopedTokenNeedsRotation(token, { + now: 4_540_000, + skewSeconds: 120, + }), + true, + ); + assert.equal( + mindSpaceMcpScopedTokenNeedsRotation('not-a-token', { now: 1_000_000 }), + true, + ); +}); diff --git a/persona-intent-routing.test.mjs b/persona-intent-routing.test.mjs new file mode 100644 index 0000000..98905cc --- /dev/null +++ b/persona-intent-routing.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { classifyWithRules } from './chat-intent-router.mjs'; +import { loadPersonaIntentCases } from './scripts/simulate-persona-intent-routing.mjs'; + +test('persona intent cases: rule utterances hit rules, llm utterances defer', async () => { + const { cases } = await loadPersonaIntentCases(); + assert.ok(cases.length >= 20); + const personas = new Set(cases.map((item) => item.persona)); + for (const required of ['青年男性', '青年女性', '中年男性', '中年女性', '老年', '小孩']) { + assert.ok(personas.has(required), `missing persona ${required}`); + } + + for (const item of cases) { + const result = classifyWithRules({ + text: item.text, + sessionId: 'h5direct_persona_sim', + sessionMessageCount: 3, + userMessage: { + role: 'user', + content: [{ type: 'text', text: item.text }], + metadata: { displayText: item.text }, + }, + chatSessionDeferEnabled: true, + chatSessionDeferMinTextLength: 12, + }); + if (item.expectSource === 'llm') { + assert.equal(result, null, `${item.id} should defer to llm: ${item.text}`); + continue; + } + assert.equal(result?.route, item.expectRoute, `${item.id} route ${item.text}`); + assert.equal(result?.source, 'rule', `${item.id} source ${item.text}`); + } +}); diff --git a/scenarios/persona-intent-routing.json b/scenarios/persona-intent-routing.json new file mode 100644 index 0000000..3b20726 --- /dev/null +++ b/scenarios/persona-intent-routing.json @@ -0,0 +1,245 @@ +{ + "id": "persona-intent-routing", + "name": "多人群话术意图识别", + "description": "用男性/女性/中老年/小孩的真实口吻测聊天 vs 任务路由,不跑 Goose 执行", + "account": { + "username": "john", + "password": "888888" + }, + "steps": [ + { + "action": "login", + "label": "登录 john" + }, + { + "action": "classify_intents", + "label": "按人群话术分类意图" + } + ], + "cases": [ + { + "id": "young-male-greet", + "persona": "青年男性", + "intent": "寒暄", + "text": "你好,在吗", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "young-male-memory", + "persona": "青年男性", + "intent": "记忆召回", + "text": "你还记得我上次说想买车载冰箱吗", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "young-male-story", + "persona": "青年男性", + "intent": "纯文字闲聊", + "text": "来段笑话提提神", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "young-male-page", + "persona": "青年男性", + "intent": "明确做页面", + "text": "帮我做个苏州一日游攻略页面,发我链接", + "expectRoute": "agent_orchestration", + "expectSource": "rule" + }, + { + "id": "young-male-compare", + "persona": "青年男性", + "intent": "歧义任务(对比选购)", + "text": "帮我比较一下英得尔和美的车载冰箱参数和选购建议", + "expectRoute": "agent_orchestration", + "expectSource": "llm" + }, + { + "id": "young-female-greet", + "persona": "青年女性", + "intent": "寒暄", + "text": "嗨", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "young-female-memory", + "persona": "青年女性", + "intent": "记忆召回", + "text": "你记得我说想去哪儿吗", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "young-female-text-only", + "persona": "青年女性", + "intent": "只要文字不要页面", + "text": "先别做页面,纯文字跟我聊聊带娃周末去哪玩", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "young-female-page", + "persona": "青年女性", + "intent": "明确做页面", + "text": "帮我生成一个宝宝辅食周菜单的 H5 页面", + "expectRoute": "agent_orchestration", + "expectSource": "rule" + }, + { + "id": "young-female-compare", + "persona": "青年女性", + "intent": "歧义任务(对比选购)", + "text": "帮我看看这两款空气炸锅哪个更适合家里用,要有对比结论", + "expectRoute": "agent_orchestration", + "expectSource": "llm" + }, + { + "id": "mid-male-greet", + "persona": "中年男性", + "intent": "寒暄", + "text": "您好", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "mid-male-news", + "persona": "中年男性", + "intent": "实时资讯", + "text": "帮我查一下今天的财经新闻", + "expectRoute": "agent_orchestration", + "expectSource": "rule" + }, + { + "id": "mid-male-page", + "persona": "中年男性", + "intent": "明确做页面", + "text": "给客户做一份产品介绍页面,要能公开访问", + "expectRoute": "agent_orchestration", + "expectSource": "rule" + }, + { + "id": "mid-male-task", + "persona": "中年男性", + "intent": "歧义任务(出差行程)", + "text": "下周去深圳见客户,帮我把两日行程交通住宿安排理清楚方便发给同事", + "expectRoute": "agent_orchestration", + "expectSource": "llm" + }, + { + "id": "mid-female-greet", + "persona": "中年女性", + "intent": "寒暄", + "text": "在不在", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "mid-female-memory", + "persona": "中年女性", + "intent": "记忆召回", + "text": "上次我们聊了什么?", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "mid-female-page", + "persona": "中年女性", + "intent": "明确做页面", + "text": "帮我做个班级家长会通知页面,要能转发", + "expectRoute": "agent_orchestration", + "expectSource": "rule" + }, + { + "id": "mid-female-task", + "persona": "中年女性", + "intent": "歧义任务(家庭清单)", + "text": "帮我把家里老人用药时间和剂量整理成一份能发给兄妹一起看的清单", + "expectRoute": "agent_orchestration", + "expectSource": "llm" + }, + { + "id": "elder-greet", + "persona": "老年", + "intent": "寒暄", + "text": "你好", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "elder-memory", + "persona": "老年", + "intent": "记忆召回", + "text": "你还记得我说的血压情况吗", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "elder-weather", + "persona": "老年", + "intent": "实时资讯", + "text": "帮我查一下今天上海天气", + "expectRoute": "agent_orchestration", + "expectSource": "rule" + }, + { + "id": "elder-page", + "persona": "老年", + "intent": "明确做页面", + "text": "给孙子做个生日祝福网页,要漂亮一点", + "expectRoute": "agent_orchestration", + "expectSource": "rule" + }, + { + "id": "elder-task", + "persona": "老年", + "intent": "歧义任务(就医准备)", + "text": "下周去医院复查,帮我把要带的检查单和注意事项按顺序列出来给我女儿看", + "expectRoute": "agent_orchestration", + "expectSource": "llm" + }, + { + "id": "child-greet", + "persona": "小孩", + "intent": "寒暄", + "text": "hi", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "child-story", + "persona": "小孩", + "intent": "纯文字闲聊", + "text": "给我讲个恐龙的睡前故事", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "child-poem", + "persona": "小孩", + "intent": "纯文字创作", + "text": "写一首小诗夸夸我的小猫", + "expectRoute": "direct_chat", + "expectSource": "rule" + }, + { + "id": "child-page", + "persona": "小孩", + "intent": "明确做页面", + "text": "帮我做个自我介绍的网页,老师要检查", + "expectRoute": "agent_orchestration", + "expectSource": "rule" + }, + { + "id": "child-task", + "persona": "小孩", + "intent": "歧义任务(作业整理)", + "text": "帮我把语文数学英语作业明天要交哪些整理清楚,让妈妈一眼能看懂", + "expectRoute": "agent_orchestration", + "expectSource": "llm" + } + ] +} diff --git a/scripts/run-scenario-test.mjs b/scripts/run-scenario-test.mjs index 48c93b3..3d031f6 100644 --- a/scripts/run-scenario-test.mjs +++ b/scripts/run-scenario-test.mjs @@ -122,6 +122,25 @@ async function runScenario(scenario, port) { continue; } + if (step.action === 'classify_intents') { + if (!auth) { + throw new Error('classify_intents 步骤前必须先 login'); + } + const { runPersonaIntentRouting } = await import('./simulate-persona-intent-routing.mjs'); + const result = await runPersonaIntentRouting({ + userId: auth.user?.id ?? auth.userId ?? null, + scenarioId: scenario.id ?? 'persona-intent-routing', + reporter, + }); + if (!result.ok) { + reporter.fail( + '人群意图识别', + `${result.failed.length}/${result.rows.length} 条与期望不符`, + ); + } + continue; + } + if (step.action === 'upload_images') { if (!auth) { throw new Error('upload_images 步骤前必须先 login'); diff --git a/scripts/simulate-persona-intent-routing.mjs b/scripts/simulate-persona-intent-routing.mjs new file mode 100644 index 0000000..2108760 --- /dev/null +++ b/scripts/simulate-persona-intent-routing.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * Simulate multi-persona chat utterances against the live chat intent router. + * + * Usage: + * node scripts/simulate-persona-intent-routing.mjs + * node scripts/run-scenario-test.mjs --scenario persona-intent-routing + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDbPool } from '../db.mjs'; +import { createLlmProviderService } from '../llm-providers.mjs'; +import { createMemoryV2AdminConfigService } from '../memory-v2-admin-config.mjs'; +import { + createChatIntentRouter, + createManagedChatIntentRouter, +} from '../chat-intent-router.mjs'; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const JOHN_USER_ID = 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e'; + +function sourceMatches(actual, expected) { + if (!expected) return true; + return String(actual ?? '') === String(expected); +} + +export async function loadPersonaIntentCases(scenarioId = 'persona-intent-routing') { + const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`); + const scenario = JSON.parse(await fs.readFile(scenarioPath, 'utf8')); + return { scenario, cases: scenario.cases ?? [] }; +} + +export async function runPersonaIntentRouting({ + userId = JOHN_USER_ID, + scenarioId = 'persona-intent-routing', + reporter, + env = process.env, +} = {}) { + const { scenario, cases } = await loadPersonaIntentCases(scenarioId); + if (!cases.length) { + throw new Error(`场景 ${scenarioId} 没有 cases`); + } + + const pool = createDbPool(env); + const llmProviderService = createLlmProviderService(pool, { + apiTarget: env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', + apiSecret: env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', + }); + const configService = createMemoryV2AdminConfigService(pool, { env }); + const adminRouter = createManagedChatIntentRouter({ + llmProviderService, + configService, + env, + logger: { warn() {}, log() {}, info() {} }, + }); + const adminStatus = await adminRouter.getStatus().catch(() => null); + // Local simulation follows .env (defer + LLM router). Admin-db currently + // disables LLM routing and would swallow ambiguous tasks into fallback chat. + const router = createChatIntentRouter({ + llmProviderService, + env, + logger: console, + }); + + try { + const status = router.getStatus(); + reporter?.pass?.( + 'router 状态', + `env llm=${status.llmRoutingEnabled ? 'on' : 'off'} shadow=${status.llmRoutingShadow ? 'on' : 'off'};admin-db llm=${adminStatus?.llmRoutingEnabled ? 'on' : 'off'}`, + ); + if (!status.llmRoutingEnabled) { + reporter?.fail?.('LLM router', '本机 .env 未进入生效模式,歧义句无法精准识别'); + } else if (adminStatus && !adminStatus.llmRoutingEnabled) { + reporter?.pass?.( + 'admin-db 对照', + '管理后台当前关闭 LLM router,Portal 热路径仍会把歧义句打成 fallback 直聊;本模拟按 .env 验证识别精度', + ); + } + + const rows = []; + for (const item of cases) { + const classification = await router.classify({ + userId, + sessionId: 'h5direct_persona_sim', + sessionMessageCount: 3, + toolMode: 'chat', + userMessage: { + role: 'user', + content: [{ type: 'text', text: item.text }], + metadata: { displayText: item.text, userVisible: true }, + }, + }); + const routeOk = classification?.route === item.expectRoute; + const sourceOk = item.expectSource === 'llm' + ? classification?.source === 'llm' || classification?.source === 'rule' + : sourceMatches(classification?.source, item.expectSource); + // Rule-expected cases must keep rule source. LLM-expected cases may still + // be caught by a stronger rule (page/news); that is acceptable if route matches. + const sourceStrictOk = item.expectSource === 'rule' + ? classification?.source === 'rule' + : true; + const ok = Boolean(routeOk && sourceStrictOk); + const label = `${item.persona} · ${item.intent}`; + const detail = `${classification?.route ?? 'null'}/${classification?.source ?? 'none'} conf=${classification?.confidence ?? '-'} · ${classification?.reason ?? ''}`; + if (ok) reporter?.pass?.(label, `${item.text} → ${detail}`); + else { + reporter?.fail?.( + label, + `期望 ${item.expectRoute}/${item.expectSource ?? '*'},实际 ${detail};话术:${item.text}`, + ); + } + rows.push({ + ...item, + ok, + routeOk, + sourceOk, + actualRoute: classification?.route ?? null, + actualSource: classification?.source ?? null, + confidence: classification?.confidence ?? null, + reason: classification?.reason ?? null, + }); + } + + const failed = rows.filter((row) => !row.ok); + console.log('\n--- 人群 × 意图 ---'); + for (const row of rows) { + const mark = row.ok ? '✔' : '✘'; + console.log( + `${mark} [${row.persona}] ${row.intent}: ${row.actualRoute} (${row.actualSource}) <- ${row.text}`, + ); + } + return { + scenario, + rows, + failed, + ok: failed.length === 0, + }; + } finally { + await pool.end?.(); + } +} + +async function main() { + const { createReporter } = await import('./scenario-test-lib.mjs'); + const { loadH5Environment } = await import('./load-env.mjs'); + loadH5Environment(import.meta.dirname); + const reporter = createReporter(); + const result = await runPersonaIntentRouting({ + reporter, + userId: process.env.MEMIND_E2E_USER_ID ?? JOHN_USER_ID, + }); + const code = reporter.summary(); + process.exit(result.ok ? code : 1); +} + +const isDirectRun = process.argv[1] + && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isDirectRun) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack ?? error.message : error); + process.exit(1); + }); +} diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs index 0811b5e..22eb5a0 100644 --- a/server/portal-gateway-services-bootstrap.mjs +++ b/server/portal-gateway-services-bootstrap.mjs @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { createAgentRunGateway } from '../agent-run-gateway.mjs'; +import { resolveDirectEscalationContextPolicy } from '../chat-task-intent-config.mjs'; import { releaseMaterializedPageDeliveryContracts } from '../mindspace-delivery-contract.mjs'; import { resolvePublishDir } from '../user-publish.mjs'; import { createGoalRunService } from '../goal-run-service.mjs'; @@ -256,6 +257,7 @@ export function bootstrapPortalGatewayServices({ llmProviderService, systemDisclosurePolicyService, chatIntentRouter, + directEscalationContextPolicy: resolveDirectEscalationContextPolicy(env), sessionSnapshotService, conversationMemoryService, goalRunService, diff --git a/session-reconcile.mjs b/session-reconcile.mjs index 56ae8b2..a26edf9 100644 --- a/session-reconcile.mjs +++ b/session-reconcile.mjs @@ -1,9 +1,70 @@ import path from 'node:path'; import { developerToolsFromPolicy } from './capabilities.mjs'; import { applyContextBudgetToHarnessEntries } from './context-budget.mjs'; +import { mindSpaceMcpScopedTokenNeedsRotation } from './mindspace-mcp-scoped-token.mjs'; import { buildSessionMemoryEntries } from './user-memory-profile.mjs'; import { buildSandboxSessionConstraints } from './user-publish.mjs'; +export const SESSION_MCP_ENV_STALE = 'SESSION_MCP_ENV_STALE'; +export const MCP_SCOPED_TOKEN_ROTATE_AFTER_MS = 50 * 60 * 1000; + +export function parseGooseTimestampMs(value) { + if (value == null || value === '') return null; + if (typeof value === 'number' && Number.isFinite(value)) { + return value < 1e12 ? value * 1000 : value; + } + const text = String(value).trim(); + if (!text) return null; + if (/^\d+$/.test(text)) { + const n = Number(text); + if (!Number.isFinite(n) || n <= 0) return null; + return n < 1e12 ? n * 1000 : n; + } + const parsed = Date.parse(text); + return Number.isFinite(parsed) ? parsed : null; +} + +function desiredExtensionsWantMcpToken(desiredExtensions) { + return (desiredExtensions ?? []).some( + (extension) => String(extension?.envs?.MINDSPACE_MCP_SCOPED_TOKEN ?? '').trim(), + ); +} + +export function sessionMcpEnvRequiresFreshSession( + session, + currentExtensions, + desiredExtensions, + { now = Date.now() } = {}, +) { + if (!desiredExtensionsWantMcpToken(desiredExtensions)) return false; + + for (const desired of desiredExtensions ?? []) { + const desiredToken = String(desired?.envs?.MINDSPACE_MCP_SCOPED_TOKEN ?? '').trim(); + if (!desiredToken) continue; + const name = extensionName(desired); + const current = (currentExtensions ?? []).find((item) => extensionName(item) === name); + const currentToken = String(current?.envs?.MINDSPACE_MCP_SCOPED_TOKEN ?? '').trim(); + if (currentToken && mindSpaceMcpScopedTokenNeedsRotation(currentToken, { now })) { + return true; + } + } + + const createdAt = parseGooseTimestampMs( + session?.created_at ?? session?.createdAt ?? session?.created, + ); + if (createdAt == null) return false; + return Number(now) - createdAt >= MCP_SCOPED_TOKEN_ROTATE_AFTER_MS; +} + +function staleMcpEnvError(sessionId) { + const err = new Error( + `session ${sessionId} MindSpace MCP scoped token cannot be refreshed in-place`, + ); + err.code = SESSION_MCP_ENV_STALE; + err.retryable = false; + return err; +} + function extensionName(config) { return config?.name ?? null; } @@ -241,6 +302,9 @@ export async function reconcileAgentSession( const allowed = allowedExtensionNames(desired); const currentPayload = await readJson(await apiFetch(`/sessions/${sessionId}/extensions`)); const current = currentPayload?.extensions ?? []; + if (sessionMcpEnvRequiresFreshSession(session, current, desired)) { + throw staleMcpEnvError(sessionId); + } let removedAny = false; for (const ext of current) { diff --git a/session-reconcile.test.mjs b/session-reconcile.test.mjs index f921582..0e62258 100644 --- a/session-reconcile.test.mjs +++ b/session-reconcile.test.mjs @@ -4,8 +4,12 @@ import { extensionConfigsMatch, extensionPolicyViolations, extensionsNeedingRefresh, + parseGooseTimestampMs, reconcileAgentSession, + sessionMcpEnvRequiresFreshSession, + SESSION_MCP_ENV_STALE, } from './session-reconcile.mjs'; +import { mintMindSpaceMcpScopedToken } from './mindspace-mcp-scoped-token.mjs'; function harnessMemoryResponse(pathname) { if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { @@ -506,3 +510,133 @@ test('reconcileAgentSession applies sandbox-fs for unrestricted MindSpace sessio assert.ok(calls.includes('/agent/add_extension')); }); + +test('parseGooseTimestampMs accepts unix seconds, millis, and ISO strings', () => { + assert.equal(parseGooseTimestampMs(1_746_000_000), 1_746_000_000_000); + assert.equal(parseGooseTimestampMs(1_746_000_000_000), 1_746_000_000_000); + assert.equal(parseGooseTimestampMs('2026-09-11T10:20:00.000Z'), Date.parse('2026-09-11T10:20:00.000Z')); + assert.equal(parseGooseTimestampMs(''), null); +}); + +test('sessionMcpEnvRequiresFreshSession rotates after Goose session outlives MCP token TTL', () => { + const now = Date.parse('2026-09-11T10:20:00.000Z'); + const freshToken = mintMindSpaceMcpScopedToken({ + secret: 'mindspace-test-secret-1234', + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: 'mindspace://users/user-1/workspace', + tools: ['write_file'], + now, + tokenId: 'token-fresh', + }); + const desired = [{ + type: 'stdio', + name: 'sandbox-fs', + cmd: 'node', + args: ['mcp.mjs'], + envs: { MINDSPACE_MCP_SCOPED_TOKEN: freshToken }, + }]; + assert.equal( + sessionMcpEnvRequiresFreshSession( + { created_at: new Date(now - 3 * 60 * 60 * 1000).toISOString() }, + desired, + desired, + { now }, + ), + true, + ); + assert.equal( + sessionMcpEnvRequiresFreshSession( + { created_at: new Date(now - 10 * 60 * 1000).toISOString() }, + desired, + desired, + { now }, + ), + false, + ); + assert.equal( + sessionMcpEnvRequiresFreshSession( + { created_at: new Date(now - 3 * 60 * 60 * 1000).toISOString() }, + [], + [{ name: 'skills', available_tools: [] }], + { now }, + ), + false, + ); +}); + +test('sessionMcpEnvRequiresFreshSession rotates when listed MCP token is expired', () => { + const mintedAt = 1_000_000; + const expired = mintMindSpaceMcpScopedToken({ + secret: 'mindspace-test-secret-1234', + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: 'mindspace://users/user-1/workspace', + tools: ['write_file'], + ttlSeconds: 60, + now: mintedAt, + tokenId: 'token-1', + }); + const desired = [{ + type: 'stdio', + name: 'sandbox-fs', + envs: { MINDSPACE_MCP_SCOPED_TOKEN: expired }, + }]; + assert.equal( + sessionMcpEnvRequiresFreshSession( + {}, + desired, + desired, + { now: mintedAt + 5 * 60 * 1000 }, + ), + true, + ); +}); + +test('reconcileAgentSession fails closed when MCP token cannot be refreshed', async () => { + const createdAt = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + const token = mintMindSpaceMcpScopedToken({ + secret: 'mindspace-test-secret-1234', + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: 'mindspace://users/user-1/workspace', + tools: ['write_file'], + }); + const desiredSandbox = { + type: 'stdio', + name: 'sandbox-fs', + cmd: 'node', + args: ['mcp.mjs'], + envs: { MINDSPACE_MCP_SCOPED_TOKEN: token }, + available_tools: ['write_file'], + }; + const apiFetch = async (pathname) => { + if (pathname === '/sessions/session-1') { + return { + ok: true, + text: async () => JSON.stringify({ + working_dir: '/valid/workspace', + created_at: createdAt, + }), + }; + } + if (pathname === '/sessions/session-1/extensions') { + return { + ok: true, + text: async () => JSON.stringify({ extensions: [desiredSandbox] }), + }; + } + throw new Error(`unexpected path: ${pathname}`); + }; + + await assert.rejects( + () => reconcileAgentSession(apiFetch, 'session-1', { + workingDir: '/valid/workspace', + sessionPolicy: { extensionOverrides: [desiredSandbox] }, + }), + (error) => error?.code === SESSION_MCP_ENV_STALE, + ); +}); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 5cadff4..be1f3a8 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1187,6 +1187,7 @@ export function isRecoverableWechatAgentSessionError(message) { if (/403|404|not found|无权访问/i.test(normalized)) return true; if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true; if (isWechatHistoricalImageSessionError(normalized)) return true; + if (/SESSION_MCP_ENV_STALE|MindSpace MCP scoped token cannot be refreshed/i.test(normalized)) return true; if (/session already has an active request|active request.*cancel/i.test(normalized)) return true; if (/wechat_agent_incomplete_reply/i.test(normalized)) return true; return false; @@ -2377,29 +2378,43 @@ export function createWechatMpService({ `cp_${existingRoute.agentSessionId}`, }, ); - await reconcileAgentSession( - (pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init), - existingRoute.agentSessionId, - { - workingDir, - sessionPolicy, - sandboxConstraints: publishLayout?.constraints ?? null, - userContext: publishLayout - ? { - userId, - displayName: addressName || publishLayout.displayName, - username: addressName || null, - slug: null, - } - : null, - tolerateInvalidWorkingDir: true, - }, - ); - const routeHasTools = await sessionHasRequiredTools( - fetchForSession, - existingRoute.agentSessionId, - sessionPolicy, - ).catch(() => false); + let mcpEnvStale = false; + try { + await reconcileAgentSession( + (pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init), + existingRoute.agentSessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userContext: publishLayout + ? { + userId, + displayName: addressName || publishLayout.displayName, + username: addressName || null, + slug: null, + } + : null, + tolerateInvalidWorkingDir: true, + }, + ); + } catch (err) { + if (err?.code !== 'SESSION_MCP_ENV_STALE') throw err; + mcpEnvStale = true; + logger.warn?.('WeChat MP rotating Goose session with stale MindSpace MCP token:', { + agentSessionId: existingRoute.agentSessionId, + }); + await userAuth.clearWechatAgentRoute(config.appId, openid); + rememberedWechatContexts.delete(existingRoute.agentSessionId); + sessionPolicy = await userAuth.getAgentSessionPolicy(userId); + } + const routeHasTools = mcpEnvStale + ? false + : await sessionHasRequiredTools( + fetchForSession, + existingRoute.agentSessionId, + sessionPolicy, + ).catch(() => false); if (routeHasTools) { await confirmWechatSessionOrigin(existingRoute.agentSessionId); if (typeof userAuth.touchWechatAgentRoute === 'function') { diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 18d4417..f6ed517 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -3543,6 +3543,10 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history', isRecoverableWechatAgentSessionError('historical_image_session_update_unsupported:405'), true, ); + assert.equal( + isRecoverableWechatAgentSessionError('SESSION_MCP_ENV_STALE'), + true, + ); assert.equal( isWechatHistoricalImageSessionError('historical_image_session_update_unsupported:405'), true,