fix(wechat): retry missing page thumbnails in fresh sessions #21
+10
-5
@@ -1030,6 +1030,7 @@ export function isRecoverableWechatAgentSessionError(message) {
|
|||||||
const normalized = String(message ?? '').trim();
|
const normalized = String(message ?? '').trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
if (/stale_session_poisoned_completion/i.test(normalized)) return true;
|
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 (/403|404|not found|无权访问/i.test(normalized)) return true;
|
||||||
if (/tool_calls|tool_call_id|insufficient tool messages/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;
|
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
||||||
@@ -1840,6 +1841,7 @@ export function createWechatMpService({
|
|||||||
user,
|
user,
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
publishDir,
|
publishDir,
|
||||||
|
notifyFailure = true,
|
||||||
}) => {
|
}) => {
|
||||||
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
||||||
const images = collectWechatGeneratedImages(reply?.messages ?? []);
|
const images = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||||
@@ -1860,14 +1862,16 @@ export function createWechatMpService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
|
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
|
||||||
try {
|
if (notifyFailure) {
|
||||||
await sendCustomerServiceText(openid, text, user);
|
try {
|
||||||
} catch (sendErr) {
|
await sendCustomerServiceText(openid, text, user);
|
||||||
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
|
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
|
||||||
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
|
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
|
||||||
throw markWechatUserNotified(error);
|
throw notifyFailure ? markWechatUserNotified(error) : error;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureSessionProvider = async (sessionId) => {
|
const ensureSessionProvider = async (sessionId) => {
|
||||||
@@ -2351,6 +2355,7 @@ export function createWechatMpService({
|
|||||||
user,
|
user,
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
publishDir: workingDir,
|
publishDir: workingDir,
|
||||||
|
notifyFailure: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
||||||
|
|||||||
@@ -2723,6 +2723,12 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(isRecoverableWechatAgentSessionError('stale_session_poisoned_completion'), 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('无权访问该会话'), true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'),
|
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/);
|
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 () => {
|
test('wechat mp standalone image intent sends a native image message and text', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
|
|||||||
Reference in New Issue
Block a user