diff --git a/mindspace-page-data-finish-guard.integration.test.mjs b/mindspace-page-data-finish-guard.integration.test.mjs index 0effe43..62b85e4 100644 --- a/mindspace-page-data-finish-guard.integration.test.mjs +++ b/mindspace-page-data-finish-guard.integration.test.mjs @@ -268,7 +268,13 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra const order = []; const reviewCalls = []; const wechatCalls = []; + const submittedMessages = []; + const finishedMessages = []; let failReview = false; + const userAuth = createWechatUserAuth(workspaceRoot); + userAuth.finishWechatMpMessage = async (input) => { + finishedMessages.push(input); + }; const service = createWechatMpService({ config: { @@ -286,7 +292,7 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra pageDataAiderReviewEnabled: true, pageDataAiderReviewUsers: ['唐'], }, - userAuth: createWechatUserAuth(workspaceRoot), + userAuth, htmlDeliveryAuthority: { async prepareWechatHtmlDelivery() { const artifact = { @@ -339,7 +345,7 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra }; }, }, - sessionApiFetch: async (sessionId, pathname) => { + sessionApiFetch: async (sessionId, pathname, init = {}) => { if (pathname === `/sessions/${sessionId}/events`) { return new Response( [ @@ -351,6 +357,7 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra ); } if (pathname === `/sessions/${sessionId}/reply`) { + submittedMessages.push(JSON.parse(init.body ?? '{}').user_message); return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { @@ -377,6 +384,10 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra assert.equal(reviewCalls[0].sourceChannel, 'wechat_mp'); assert.equal(reviewCalls[0].sourceMessageId, '10001'); assert.equal(reviewCalls[0].forcePageData, true); + assert.equal( + submittedMessages[0]?.metadata?.memindRun?.pgRequired, + true, + ); assert.deepEqual( reviewCalls[0].relativePaths, ['public/children-diet-survey.html'], @@ -398,6 +409,11 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra assert.ok(failedSend); const failedPayload = JSON.parse(failedSend[2]); assert.match(failedPayload.text.content, /Page Data|数据收集|绑定/); + assert.equal(finishedMessages.at(-1)?.status, 'failed'); + assert.equal( + finishedMessages.at(-1)?.agentSessionId, + 'session-page-data-1', + ); assert.doesNotMatch( failedPayload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-page-data\/public\/children-diet-survey\.html/, diff --git a/server.mjs b/server.mjs index 81b01f1..792a658 100644 --- a/server.mjs +++ b/server.mjs @@ -512,6 +512,7 @@ async function bootstrapUserAuth() { await bootstrapPortalIntegrationServices({ pool, h5Root: H5_ROOT, + codeRoot: __dirname, env: process.env, usersRoot: USERS_ROOT, wechatMpConfig: WECHAT_MP_CONFIG, diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 9d1e9ea..fc7d88d 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -20,6 +20,7 @@ import { export async function bootstrapPortalIntegrationServices({ pool, h5Root, + codeRoot = h5Root, env = process.env, usersRoot, wechatMpConfig, @@ -76,7 +77,9 @@ export async function bootstrapPortalIntegrationServices({ ); } - const wechatMp = await loadWechatMpModuleFn(h5Root); + const wechatMpRoot = codeRoot || h5Root; + logger.log?.(`[WeChat MP] Loading runtime module from code root: ${wechatMpRoot}`); + const wechatMp = await loadWechatMpModuleFn(wechatMpRoot); const pageDataDeliveryReviewer = wechatMpConfig?.pageDataAiderReviewEnabled === true ? createPageDataDeliveryCodeReviewServiceFn({ @@ -122,6 +125,7 @@ export async function bootstrapPortalIntegrationServices({ tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy, + origin: 'wechat', }), sessionApiFetch: async ( sessionId, diff --git a/server/portal-integration-services-bootstrap.test.mjs b/server/portal-integration-services-bootstrap.test.mjs index 0c03b59..3f73bdd 100644 --- a/server/portal-integration-services-bootstrap.test.mjs +++ b/server/portal-integration-services-bootstrap.test.mjs @@ -97,6 +97,7 @@ function createSetup(overrides = {}) { const options = { pool, h5Root: '/app', + codeRoot: '/runtime', env: { H5_SCHEDULE_ENABLED: '1', H5_REMINDER_WORKER_ENABLED: '1', @@ -236,6 +237,10 @@ test('preserves WeChat service configuration and proxy callbacks', async () => { ); const { wechatOptions } = setup.getCaptured(); + assert.deepEqual( + setup.calls.find(([kind]) => kind === 'load-wechat'), + ['load-wechat', '/runtime'], + ); assert.equal( wechatOptions.pageDataFinishGuard, setup.options.mindSpacePublicFinish, @@ -268,6 +273,18 @@ test('preserves WeChat service configuration and proxy callbacks', async () => { }), { sessionId: 'session-1' }, ); + assert.deepEqual( + setup.calls.find(([kind]) => kind === 'start-session'), + [ + 'start-session', + 'user-1', + { + workingDir: '/workspace', + sessionPolicy: { sandboxed: true }, + origin: 'wechat', + }, + ], + ); assert.deepEqual( await wechatOptions.sessionApiFetch( 'session-1', diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 8e9c6ff..c8aff0c 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -1430,6 +1430,7 @@ export function createTkmindProxy({ sessionPolicy = null, recipe = null, disableImageReading = false, + origin = 'h5', } = {}, ) { const resolvedWorkingDir = workingDir ?? await userAuth.resolveWorkingDir(userId); @@ -1458,7 +1459,12 @@ export function createTkmindProxy({ throw new Error('创建会话失败:缺少 session id'); } await rememberSessionTarget(session.id, startTarget); - await sessionStore.registerAgentSession(userId, session.id, startTarget); + await sessionStore.registerSession({ + userId, + sessionId: session.id, + target: startTarget, + origin: origin === 'wechat' ? 'wechat' : 'h5', + }); const baseSessionScopedPolicy = typeof userAuth.getAgentSessionPolicy === 'function' diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index 1b3053a..1458e64 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -1006,6 +1006,34 @@ test('startSessionForUser resolves memories through Memory V2 facade', async () }); }); +test('startSessionForUser records WeChat origin without changing the selected target', async () => { + const registrations = []; + let selectedTarget = ''; + await withFakeGoosedSession(async ({ apiTarget, workingDir }) => { + selectedTarget = apiTarget; + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: createMemoryTestUserAuth(workingDir), + sessionAccess: { + enabled: true, + async registerSession(input) { + registrations.push(input); + }, + }, + }); + + await proxy.startSessionForUser('user-1', { origin: 'wechat' }); + }); + + assert.deepEqual(registrations, [{ + userId: 'user-1', + sessionId: 'session-1', + target: selectedTarget, + origin: 'wechat', + }]); +}); + test('startSessionForUser does not inject memories when Memory V2 is disabled', async () => { let legacyTouched = false; await withFakeGoosedSession(async ({ apiTarget, workingDir, harnessEntries }) => { diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 240144b..bc857d2 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -65,6 +65,7 @@ const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn'; const WECHAT_RECENT_MEDIA_TTL_MS = 15 * 60 * 1000; const WECHAT_RECENT_IMAGE_MAX_COUNT = 10; const DEFAULT_WECHAT_AGENT_REPLY_TIMEOUT_MS = 15 * 60 * 1000; +const WECHAT_SESSION_RESET_ACK_TEXT = '已切换到新会话,请发送你的新需求。'; export { loadWechatMpConfig }; const PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi; @@ -1517,6 +1518,7 @@ export function createWechatMpService({ expiresAt: 0, }; const rememberedWechatContexts = new Map(); + const confirmedWechatSessionOrigins = new Set(); const messageTasksByOpenid = new Map(); const recentMediaByOpenid = new Map(); let jsapiTicketCache = { @@ -1527,6 +1529,19 @@ export function createWechatMpService({ const fetchForSession = (sessionId, pathname, init) => sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init); + const confirmWechatSessionOrigin = async (sessionId) => { + if (!sessionId || confirmedWechatSessionOrigins.has(sessionId)) return; + if (typeof userAuth.setSessionOrigin === 'function') { + try { + await userAuth.setSessionOrigin(sessionId, 'wechat'); + } catch (err) { + logger.warn?.('WeChat MP session origin update failed:', err); + return; + } + } + confirmedWechatSessionOrigins.add(sessionId); + }; + const enqueueMessageTask = (openid, taskFactory) => { const key = String(openid ?? '').trim(); const previous = messageTasksByOpenid.get(key) ?? Promise.resolve(); @@ -2038,6 +2053,7 @@ export function createWechatMpService({ sessionPolicy, ).catch(() => false); if (routeHasTools) { + await confirmWechatSessionOrigin(existingRoute.agentSessionId); if (typeof userAuth.touchWechatAgentRoute === 'function') { await userAuth.touchWechatAgentRoute(config.appId, openid, now).catch((err) => { logger.warn?.('WeChat MP route touch failed:', err); @@ -2057,7 +2073,12 @@ export function createWechatMpService({ throw new Error(gate.message || '当前用户无法使用聊天能力'); } const started = startAgentSession - ? await startAgentSession({ userId, workingDir, sessionPolicy }) + ? await startAgentSession({ + userId, + workingDir, + sessionPolicy, + origin: 'wechat', + }) : await readJsonResponse( await apiFetch('/agent/start', { method: 'POST', @@ -2094,6 +2115,7 @@ export function createWechatMpService({ origin: 'wechat', }); } + await confirmWechatSessionOrigin(sessionId); await reconcileAgentSession( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, @@ -2167,7 +2189,14 @@ export function createWechatMpService({ } }; - const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false, imagePolicy = null } = {}) => { + const buildIntentMetadata = ( + intent, + { + mediaAnalysisEnabled = false, + imagePolicy = null, + pgRequired = false, + } = {}, + ) => { const mediaPublicUrl = intent.media?.publicUrl || null; const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0 ? intent.recentMediaItems @@ -2199,7 +2228,10 @@ export function createWechatMpService({ recognition: intent.msgType === 'voice' ? intent.agentText || null : null, location: intent.location || null, link: intent.link || null, - memindRun: buildWechatImageRunMetadata(imagePolicy), + memindRun: { + ...buildWechatImageRunMetadata(imagePolicy), + ...(pgRequired ? { pgRequired: true } : {}), + }, }; }; @@ -2348,6 +2380,14 @@ export function createWechatMpService({ }); let sessionId = route.sessionId; await ensureSessionProvider(sessionId); + if (wechatIntent.kind === 'session.reset') { + await sendCustomerServiceText( + inbound.fromUserName, + WECHAT_SESSION_RESET_ACK_TEXT, + user, + ); + return { sessionId }; + } await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); let finished = false; let progressTimer = null; @@ -2384,7 +2424,11 @@ export function createWechatMpService({ sessionId, requestId, agentPrompt, - buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }), + buildIntentMetadata(intent, { + mediaAnalysisEnabled, + imagePolicy, + pgRequired: isPageDataRequest, + }), { prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({ userId: user.userId, @@ -2574,6 +2618,14 @@ export function createWechatMpService({ }); return { sessionId }; } catch (err) { + if ( + err && + typeof err === 'object' && + sessionId && + !err.wechatAgentSessionId + ) { + err.wechatAgentSessionId = sessionId; + } const message = err instanceof Error ? err.message : String(err); if (err?.code === 'WECHAT_AGENT_REPLY_TIMEOUT') { await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => { @@ -2628,7 +2680,11 @@ export function createWechatMpService({ sessionId, retryId, retryPrompt, - buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }), + buildIntentMetadata(intent, { + mediaAnalysisEnabled, + imagePolicy, + pgRequired: isPageDataRequest, + }), { prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({ userId: user.userId, @@ -3284,6 +3340,7 @@ export function createWechatMpService({ openid: inbound.fromUserName, msgId: inbound.msgId, status: 'failed', + agentSessionId: err?.wechatAgentSessionId ?? null, }).catch(() => {}); } logger.error?.('WeChat MP background reply failed:', err); diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 0ab544b..b4dd768 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -333,6 +333,94 @@ test('Page Data requests always rotate away from an existing WeChat route', () = assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '换新会话'), true); }); +test('WeChat session reset acknowledges without sending the control text to Agent', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const sentPayloads = []; + const finishedMessages = []; + let activeRoute = { agentSessionId: 'session-old' }; + let startedSessions = 0; + let agentReplies = 0; + const sessionOrigins = []; + + const service = createBoundWechatService({ + token, + startAgentSession: async (input) => { + startedSessions += 1; + assert.equal(input.origin, 'wechat'); + return { id: 'session-new' }; + }, + userAuth: { + async getWechatAgentRoute() { + return activeRoute; + }, + async clearWechatAgentRoute() { + activeRoute = null; + }, + async upsertWechatAgentRoute({ agentSessionId }) { + activeRoute = { agentSessionId }; + }, + async setSessionOrigin(sessionId, origin) { + sessionOrigins.push({ sessionId, origin }); + }, + async finishWechatMpMessage(input) { + finishedMessages.push(input); + }, + }, + sessionApiFetch: async (_sessionId, pathname) => { + if (pathname.includes('/reply')) { + agentReplies += 1; + throw new Error('session reset must not reach Agent'); + } + return new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + wechatFetch: async (url, init = {}) => { + if (String(url).includes('/cgi-bin/stable_token')) { + return new Response(JSON.stringify({ + access_token: 'access-1', + expires_in: 7200, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (String(url).includes('/cgi-bin/message/custom/send')) { + sentPayloads.push(JSON.parse(init.body)); + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected wechat url: ${url}`); + }, + }); + + const result = await service.handleInboundMessage( + inboundXml({ content: '新会话' }), + { + timestamp, + nonce, + signature: signatureFor(token, timestamp, nonce), + }, + ); + await result.task; + + assert.equal(startedSessions, 1); + assert.equal(agentReplies, 0); + assert.equal(activeRoute?.agentSessionId, 'session-new'); + assert.deepEqual(sessionOrigins, [{ + sessionId: 'session-new', + origin: 'wechat', + }]); + assert.match(sentPayloads[0]?.text?.content ?? '', /已切换到新会话/); + assert.equal(finishedMessages.at(-1)?.status, 'done'); + assert.equal(finishedMessages.at(-1)?.agentSessionId, 'session-new'); +}); + test('WeChat Page Data repair intent receives the guarded repair workflow', () => { assert.equal(isWechatPageDataTask('提交失败,数据没有保存成功'), true); assert.equal(isWechatPageDataTask('修复问卷的 Page Data 绑定'), true);