fix(wechat): retry missing page thumbnails in fresh sessions #21

Merged
tkmind merged 1 commits from codex/wechat-thumbnail-retry-20260722 into main 2026-07-22 01:43:28 +00:00
2 changed files with 220 additions and 5 deletions
+10 -5
View File
@@ -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({
+210
View File
@@ -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';