From 9f7633be2412f53c77c28270fcd31f6375f4ac7f Mon Sep 17 00:00:00 2001 From: john Date: Sat, 15 Aug 2026 09:06:00 +0800 Subject: [PATCH] fix(wechat): rotate polluted image sessions before follow-up turns After a successful image report flow, Goose may keep image_url parts that DeepSeek rejects on later turns while PUT scrub returns 405. Detect polluted sessions up front, rotate to a fresh agent route with carried assistant context, and recover image URLs from recent media on historical retries. Co-authored-by: Cursor --- wechat-mp.mjs | 97 +++++++++++++++++++++++++++++++-- wechat-mp.test.mjs | 133 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 6 deletions(-) diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 2ca2c13..298c171 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -85,6 +85,7 @@ import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs'; import { buildPageDataCollectFailureText, } from './mindspace-page-data-finish-guard.mjs'; +import { conversationHasImageUrlContent } from './chat-image-turn-scope.mjs'; export { buildWechatAgentPrompt }; @@ -1097,9 +1098,14 @@ export function isWechatHistoricalImageSessionError(message) { export const WECHAT_IMAGE_STORED_ACK_TEXT = '已收到图片。请直接告诉我接下来要做什么,例如:解读报告并生成页面。'; -export function prepareWechatIntentForHistoricalImageRetry(intent) { +export function resolveWechatRecentMediaPublicUrl(recentMediaEntry) { + if (!recentMediaEntry?.items?.length) return ''; + return String(recentMediaEntry.items.at(-1)?.media?.publicUrl ?? '').trim(); +} + +export function prepareWechatIntentForHistoricalImageRetry(intent, { fallbackImageUrl = '' } = {}) { if (!intent || typeof intent !== 'object') return intent; - const imageUrl = String(intent.media?.publicUrl ?? '').trim(); + const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim(); const agentText = String(intent.agentText ?? '').trim(); if (imageUrl && !agentText.includes(imageUrl)) { intent.agentText = agentText @@ -2185,15 +2191,23 @@ export function createWechatMpService({ retainUnlinkedRoute = false, userContext = null, }) => { - if (forceNew) { - await userAuth.clearWechatAgentRoute(config.appId, openid); - } const workingDir = await userAuth.resolveWorkingDir(userId); let sessionPolicy = await userAuth.getAgentSessionPolicy(userId); const publishLayout = await userAuth.getUserPublishLayout(userId); const addressName = resolveWechatAddressName(userContext); let carriedSessionContentForNewSession = ''; + if (forceNew) { + const routeBeforeForceNew = await userAuth.getWechatAgentRoute(config.appId, openid); + if (routeBeforeForceNew?.agentSessionId) { + carriedSessionContentForNewSession = await fetchLastSubstantiveAssistantFromSession( + fetchForSession, + routeBeforeForceNew.agentSessionId, + ); + rememberedWechatContexts.delete(routeBeforeForceNew.agentSessionId); + } + await userAuth.clearWechatAgentRoute(config.appId, openid); + } const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid); if (existingRoute?.agentSessionId) { const now = Date.now(); @@ -2405,6 +2419,53 @@ export function createWechatMpService({ }; }; + const rotateWechatSessionIfImagePolluted = async ({ + userId, + openid, + sessionId, + user, + carriedSessionContent = '', + }) => { + if (!sessionId) { + return { sessionId, carriedSessionContent, rotated: false }; + } + try { + const response = await fetchForSession( + sessionId, + `/sessions/${encodeURIComponent(sessionId)}`, + ); + if (!response.ok) { + return { sessionId, carriedSessionContent, rotated: false }; + } + const payload = await readJsonResponse(response); + const conversation = Array.isArray(payload?.conversation) ? payload.conversation : []; + if (!conversationHasImageUrlContent(conversation)) { + return { sessionId, carriedSessionContent, rotated: false }; + } + logger.warn?.('WeChat MP rotating image-polluted agent session before reply:', { + agentSessionId: sessionId, + conversationLength: conversation.length, + }); + const nextRoute = await ensureWechatAgentSession({ + userId, + openid, + forceNew: true, + userContext: user, + }); + const nextCarried = String(carriedSessionContent ?? '').trim() + || String(nextRoute.carriedSessionContent ?? '').trim(); + return { + sessionId: nextRoute.sessionId, + carriedSessionContent: nextCarried, + rotated: true, + isNewSession: nextRoute.isNewSession, + }; + } catch (err) { + logger.warn?.('WeChat MP image-polluted session rotate skipped:', err); + return { sessionId, carriedSessionContent, rotated: false }; + } + }; + const refreshWechatSessionSnapshot = async (sessionId, userId) => { if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return; try { @@ -2712,6 +2773,26 @@ export function createWechatMpService({ return { sessionId }; } await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); + const pollutionRotation = await rotateWechatSessionIfImagePolluted({ + userId: user.userId, + openid: inbound.fromUserName, + sessionId, + user, + carriedSessionContent, + }); + if (pollutionRotation.rotated) { + sessionId = pollutionRotation.sessionId; + route = { + ...route, + sessionId, + isNewSession: pollutionRotation.isNewSession ?? true, + }; + if (pollutionRotation.carriedSessionContent) { + carriedSessionContent = pollutionRotation.carriedSessionContent; + } + await ensureSessionProvider(sessionId); + await rememberWechatUserContext(sessionId, user, { forceBootstrap: true }); + } if ( wechatIntent.kind === 'page.generate' && sessionPageContinuation @@ -3177,7 +3258,11 @@ export function createWechatMpService({ await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); if (historicalImageError) { - prepareWechatIntentForHistoricalImageRetry(intent); + prepareWechatIntentForHistoricalImageRetry(intent, { + fallbackImageUrl: resolveWechatRecentMediaPublicUrl( + recentMediaByOpenid.get(String(inbound.fromUserName ?? '').trim()), + ), + }); } const retryId = crypto.randomUUID(); const retryStartedAt = Date.now(); diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 969c499..76f3778 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -16,6 +16,7 @@ import { isWechatHistoricalImageSessionError, WECHAT_IMAGE_STORED_ACK_TEXT, prepareWechatIntentForHistoricalImageRetry, + resolveWechatRecentMediaPublicUrl, sanitizeWechatAgentOutboundText, loadWechatMpConfig, maybeAttachPublishedHtmlLink, @@ -3513,6 +3514,27 @@ test('prepareWechatIntentForHistoricalImageRetry keeps image url in text only', assert.equal(intent.recentMediaBatchId, undefined); }); +test('prepareWechatIntentForHistoricalImageRetry can recover image url from recent media cache', () => { + const intent = prepareWechatIntentForHistoricalImageRetry( + { agentText: '帮我把报告再详细点' }, + { fallbackImageUrl: 'https://example.com/report.png' }, + ); + assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/); + assert.equal(intent.media, undefined); +}); + +test('resolveWechatRecentMediaPublicUrl reads the latest remembered image', () => { + assert.equal( + resolveWechatRecentMediaPublicUrl({ + items: [ + { media: { publicUrl: 'https://example.com/first.png' } }, + { media: { publicUrl: 'https://example.com/second.png' } }, + ], + }), + 'https://example.com/second.png', + ); +}); + test('wechat mp rotates and retries when historical image isolation is unsupported', async () => { const token = 'token'; const timestamp = '1710000000'; @@ -5525,6 +5547,117 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as assert.equal(submitCalls[0].userMessage.metadata.displayText, '解读详细报告,做成页面'); }); +test('wechat mp rotates polluted image session before a content-edit follow-up', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const testUserId = 'test-user-image-followup-rotate'; + const submitCalls = []; + let activeSessionId = 'session-1'; + let nextSessionId = 2; + let routeCleared = false; + const pollutedConversation = [ + { + id: 'assistant-report', + role: 'assistant', + metadata: { userVisible: true }, + content: [{ type: 'text', text: '报告页面已生成。' }], + }, + { + id: 'assistant-image', + role: 'assistant', + metadata: { userVisible: false }, + content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }], + }, + ]; + + const service = createBoundWechatService({ + token, + config: { + mediaAnalysisGrayUsers: [testUserId], + }, + userAuth: { + async findWechatUserByOpenid() { + return { userId: testUserId, status: 'active', nickname: '唐' }; + }, + async getWechatAgentRoute() { + if (routeCleared || !activeSessionId) return null; + return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() }; + }, + async upsertWechatAgentRoute({ agentSessionId }) { + activeSessionId = agentSessionId; + routeCleared = false; + }, + async clearWechatAgentRoute() { + routeCleared = true; + }, + }, + startAgentSession: async () => ({ id: `session-${nextSessionId++}` }), + sessionApiFetch: async (sessionId, pathname) => { + if (pathname === `/sessions/${sessionId}`) { + const body = sessionId === 'session-1' + ? { conversation: pollutedConversation } + : { conversation: [] }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === `/sessions/${sessionId}/events`) { + return new Response( + [ + 'data: {"type":"Message","request_id":"req-followup","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已按你的要求补充报告细节。"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-followup","token_state":{"inputTokens":1,"outputTokens":2}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`unexpected api path: ${sessionId} ${pathname}`); + }, + submitSessionReply: async (input) => { + submitCalls.push(input); + return { ok: true }; + }, + 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')) { + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected wechat url: ${url}`); + }, + }); + + const originalRandomUuid = crypto.randomUUID; + crypto.randomUUID = (() => { + const ids = ['req-followup']; + return () => ids.shift() ?? 'req-followup'; + })(); + try { + const result = await service.handleInboundMessage( + inboundXml({ msgType: 'text', content: '帮我把报告再详细一点' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + await result.task; + } finally { + crypto.randomUUID = originalRandomUuid; + } + + assert.equal(submitCalls.length, 1); + assert.notEqual(submitCalls[0].sessionId, 'session-1'); + assert.equal(activeSessionId, submitCalls[0].sessionId); +}); + test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => { const token = 'token'; const timestamp = '1710000000';