From 7b3acb681351f57ead5a83f3aef80ccf3b17af45 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 22 Jul 2026 09:38:13 +0800 Subject: [PATCH] fix(wechat): retry missing page thumbnails in fresh sessions --- wechat-mp.mjs | 15 ++-- wechat-mp.test.mjs | 210 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 5 deletions(-) diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 3a8d112..6d45803 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1030,6 +1030,7 @@ export function isRecoverableWechatAgentSessionError(message) { const normalized = String(message ?? '').trim(); if (!normalized) return false; if (/stale_session_poisoned_completion/i.test(normalized)) return true; + if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true; 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 (/session already has an active request|active request.*cancel/i.test(normalized)) return true; @@ -1840,6 +1841,7 @@ export function createWechatMpService({ user, imagePolicy, publishDir, + notifyFailure = true, }) => { if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return; const images = collectWechatGeneratedImages(reply?.messages ?? []); @@ -1860,14 +1862,16 @@ export function createWechatMpService({ } } const text = buildPagePublishFailureText({ missingFreshThumbnail: true }); - try { - await sendCustomerServiceText(openid, text, user); - } catch (sendErr) { - logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr); + if (notifyFailure) { + try { + await sendCustomerServiceText(openid, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr); + } } const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`); error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED'; - throw markWechatUserNotified(error); + throw notifyFailure ? markWechatUserNotified(error) : error; }; const ensureSessionProvider = async (sessionId) => { @@ -2351,6 +2355,7 @@ export function createWechatMpService({ user, imagePolicy, publishDir: workingDir, + notifyFailure: false, }); } const pageDataOutcome = await enforcePageDataCollectDelivery({ diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 452ce9e..9af6740 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -2723,6 +2723,12 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history', true, ); assert.equal(isRecoverableWechatAgentSessionError('stale_session_poisoned_completion'), true); + assert.equal( + isRecoverableWechatAgentSessionError( + 'wechat_page_fresh_thumbnail_required:fresh_image_not_generated', + ), + true, + ); assert.equal(isRecoverableWechatAgentSessionError('无权访问该会话'), true); assert.equal( isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'), @@ -5119,6 +5125,210 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai assert.match(wechatPayloads[0].text.content, /fresh\.html/); }); +test('wechat mp retries a page in a new session before reporting a missing fresh thumbnail', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-retry-'); + const htmlPath = path.join(workspaceRoot, 'public', 'retry.html'); + const generated = { + ok: true, + jobId: 'job-fresh-page-retry', + source: { mimeType: 'image/webp', width: 1280, height: 720 }, + asset: { + id: 'asset-fresh-page-retry', + htmlSrc: 'images/retry-cover.webp', + publicUrl: 'https://example.com/MindSpace/user-1/public/images/retry-cover.webp', + workspaceRelativePath: 'public/images/retry-cover.webp', + }, + }; + const firstHtml = previewReadyPageHtml({ + title: 'Retry', + subtitle: '首次没有新缩略图', + cover: 'images/missing-cover.webp', + }); + const retryHtml = previewReadyPageHtml({ + title: 'Retry', + subtitle: '重试生成新缩略图', + cover: generated.asset.htmlSrc, + }); + const pageImage = await sharp({ + create: { width: 64, height: 64, channels: 3, background: '#cc6633' }, + }).webp().toBuffer(); + const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`; + const wechatPayloads = []; + let routeCleared = false; + let startedSessions = 0; + + const replyEvents = ({ requestId, html, includeImage = false }) => [ + eventFrame({ + type: 'Message', + request_id: requestId, + message: { + id: `${requestId}-tools`, + role: 'assistant', + metadata: { userVisible: true }, + content: [ + { + id: `${requestId}-page-skill`, + type: 'toolRequest', + toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } }, + }, + ...(includeImage + ? [ + { + id: `${requestId}-image`, + type: 'toolRequest', + toolCall: { + value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } }, + }, + }, + { + id: `${requestId}-image`, + type: 'toolResponse', + toolResult: { + status: 'success', + value: { content: [{ type: 'text', text: JSON.stringify(generated) }] }, + }, + }, + ] + : []), + { + id: `${requestId}-write`, + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__write_file', + arguments: { path: 'public/retry.html', content: html }, + }, + }, + }, + ], + }, + }), + eventFrame({ + type: 'Message', + request_id: requestId, + message: { + id: `${requestId}-final`, + role: 'assistant', + metadata: { userVisible: true }, + content: [{ + type: 'text', + text: '页面已完成:https://example.com/MindSpace/user-1/public/retry.html', + }], + }, + }), + eventFrame({ + type: 'Finish', + request_id: requestId, + token_state: { inputTokens: 1, outputTokens: 2 }, + }), + ].join(''); + + const service = createBoundWechatService({ + token, + config: { requireFreshPageThumbnail: true }, + startAgentSession: async () => { + startedSessions += 1; + return { id: 'session-2' }; + }, + userAuth: { + async getWechatAgentRoute() { + return routeCleared ? null : { agentSessionId: 'session-1' }; + }, + async clearWechatAgentRoute() { + routeCleared = true; + }, + async upsertWechatAgentRoute({ agentSessionId }) { + assert.equal(agentSessionId, 'session-2'); + }, + async resolveWorkingDir() { + return workspaceRoot; + }, + async getUserPublishLayout() { + return { + publishDir: workspaceRoot, + displayName: 'John', + username: 'john', + slug: 'john', + constraints: null, + }; + }, + }, + sessionApiFetch: async (sessionId, pathname) => { + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === `/sessions/${sessionId}/reply`) { + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + if (sessionId === 'session-1') { + fs.writeFileSync(htmlPath, firstHtml, 'utf8'); + } else { + fs.writeFileSync(htmlPath, retryHtml, 'utf8'); + fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true }); + fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage); + } + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/sessions/session-1/events') { + return new Response( + replyEvents({ requestId: 'req-thumbnail-first', html: firstHtml }), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/sessions/session-2/events') { + return new Response( + replyEvents({ requestId: 'req-thumbnail-retry', html: retryHtml, includeImage: true }), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + throw new Error(`unexpected session path: ${sessionId} ${pathname}`); + }, + 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')) { + wechatPayloads.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 originalRandomUuid = crypto.randomUUID; + crypto.randomUUID = (() => { + const ids = ['req-thumbnail-first', 'req-thumbnail-retry']; + return () => ids.shift() ?? 'req-thumbnail-retry'; + })(); + try { + const result = await service.handleInboundMessage( + inboundXml({ content: '生成一个活动页面,只要文字,不要正文图片' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + assert.equal(result.status, 200); + await result.task; + assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'retry.thumbnail.svg')), true); + } finally { + crypto.randomUUID = originalRandomUuid; + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + + assert.equal(routeCleared, true); + assert.equal(startedSessions, 1); + assert.equal(wechatPayloads.length, 1); + assert.equal(wechatPayloads[0].msgtype, 'text'); + assert.match(wechatPayloads[0].text.content, /retry\.html/); + assert.doesNotMatch(wechatPayloads[0].text.content, /没有完成服务号要求的本轮新缩略图/); +}); + test('wechat mp standalone image intent sends a native image message and text', async () => { const token = 'token'; const timestamp = '1710000000'; -- 2.52.0