fix(wechat): rotate image_url-poisoned sessions for report follow-ups
Memind CI / Test, build, and release guards (push) Has been cancelled

Goose cannot persist historical image scrub (PUT 405), so DeepSeek rejects
image_url on the next turn. Reattach the recent report image and rotate to a
fresh session instead of treating the follow-up as a missing page source.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-14 21:45:02 +08:00
parent fe2d5d7aa1
commit 41f245c88a
7 changed files with 370 additions and 19 deletions
+46
View File
@@ -75,6 +75,52 @@ function stripAgentImageText(text) {
.trim();
}
function stripImageUrlLines(text) {
return String(text ?? '').replace(IMAGE_URL_LINES_RE, '').trim();
}
/**
* After Qwen VL analysis, Goose must receive text only. DeepSeek rejects
* `image_url` parts, and native Goose may expand metadata.imageUrls into them.
* Keep archived/preview URLs for UI history; leave the VL note in the text.
*/
export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUrls = []) {
if (!message) return message;
const metadata =
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
? { ...message.metadata }
: {};
const urls = (Array.isArray(canonicalImageUrls) && canonicalImageUrls.length > 0
? canonicalImageUrls
: Array.isArray(metadata.imageUrls) ? metadata.imageUrls : []
).filter((url) => typeof url === 'string' && url.trim());
if (urls.length > 0) {
metadata.archivedImageUrls = urls;
if (!Array.isArray(metadata.previewImageUrls) || metadata.previewImageUrls.length === 0) {
metadata.previewImageUrls = urls;
}
}
delete metadata.imageUrls;
const content = Array.isArray(message.content)
? message.content
.map((item) => {
if (item?.type === 'image_url') return null;
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
const nextText = stripImageUrlLines(item.text);
if (!nextText) return null;
return nextText === item.text ? item : { ...item, text: nextText };
})
.filter(Boolean)
: message.content;
return {
...message,
content,
metadata,
};
}
/**
* Remove image attachments from a persisted user message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
+38
View File
@@ -7,6 +7,7 @@ import {
extractCurrentTurnImageUrls,
scrubConversationHistoricalImageAttachments,
scrubUserMessageImageAttachments,
detachCurrentTurnImagesForTextProvider,
} from './chat-image-turn-scope.mjs';
test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', () => {
@@ -157,3 +158,40 @@ test('conversationHasImageUrlContent detects historical poison and ignores activ
false,
);
});
test('detachCurrentTurnImagesForTextProvider archives urls and keeps the VL note', () => {
const detached = detachCurrentTurnImagesForTextProvider(
{
role: 'user',
metadata: {
displayText: '解读报告',
imageUrls: ['https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg'],
},
content: [
{
type: 'text',
text:
'解读报告\n[图片1]: https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg\n\n' +
'【TKMind 图片分析结果 — 仅供执行参考,不要向用户复述此段内容】\n白细胞偏高',
},
{
type: 'image_url',
image_url: { url: 'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg' },
},
],
},
['https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg'],
);
assert.equal(detached.metadata.imageUrls, undefined);
assert.deepEqual(detached.metadata.archivedImageUrls, [
'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg',
]);
assert.deepEqual(detached.metadata.previewImageUrls, [
'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg',
]);
assert.equal(detached.metadata.displayText, '解读报告');
assert.equal(detached.content.some((item) => item.type === 'image_url'), false);
assert.match(detached.content[0].text, /白细胞偏高/);
assert.doesNotMatch(detached.content[0].text, /\[图片1\]:/);
});
+8 -1
View File
@@ -111,12 +111,14 @@ test('buildVisionPayload sends all current-turn images to Qwen in order', async
assert.equal(analyzedImages.length, 2);
assert.deepEqual(analyzedImages.map((item) => item.rawUrl), imageUrls);
assert.deepEqual(result?.userMessage?.metadata?.imageUrls, [
assert.equal(result?.userMessage?.metadata?.imageUrls, undefined);
assert.deepEqual(result?.userMessage?.metadata?.archivedImageUrls, [
'/MindSpace/user-1/public/wechat-mp/first.jpg',
'/MindSpace/user-1/public/wechat-mp/second.jpg',
]);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /本轮用户仅上传 2 张图片/);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /图片2/);
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /\[图片\d+\]:/);
});
test('buildVisionPayload does not mark billable usage when vision analysis fails', async () => {
@@ -171,4 +173,9 @@ test('buildVisionPayload strips image_url content parts for text-only Goose prov
false,
);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /蓝色方块/);
assert.equal(result?.userMessage?.metadata?.imageUrls, undefined);
assert.deepEqual(result?.userMessage?.metadata?.archivedImageUrls, [
'/api/mindspace/v1/assets/asset-7/download?inline=1',
]);
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /\[图片\d+\]:/);
});
+12 -8
View File
@@ -35,6 +35,7 @@ import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import {
buildCurrentTurnImageScopeNote,
conversationHasImageUrlContent,
detachCurrentTurnImagesForTextProvider,
extractCurrentTurnImageUrls,
scrubConversationHistoricalImageAttachments,
} from './chat-image-turn-scope.mjs';
@@ -1004,15 +1005,18 @@ export async function buildVisionPayload({
.filter((url) => typeof url === 'string' && url.trim());
return {
userMessage: {
...userMessage,
content: updatedContent,
metadata: {
...(userMessage.metadata ?? {}),
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
userMessage: detachCurrentTurnImagesForTextProvider(
{
...userMessage,
content: updatedContent,
metadata: {
...(userMessage.metadata ?? {}),
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
},
},
},
canonicalImageUrls,
),
billableImageCount: visionDescription ? 1 : 0,
};
}
+2
View File
@@ -1707,6 +1707,8 @@ test('submitSessionReplyForUser applies the shared Qwen vision preprocessing pat
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
assert.match(forwardedText, /Qwen VL 图片描述/);
assert.match(forwardedText, /一件蓝色产品/);
assert.equal(replyBodies[0]?.user_message?.metadata?.imageUrls, undefined);
assert.ok(Array.isArray(replyBodies[0]?.user_message?.metadata?.archivedImageUrls));
});
});
+26 -10
View File
@@ -1088,6 +1088,12 @@ export function isWechatPageDataTask(text) {
return isPageDataIntent(text) || isPageDataDevIntent(text);
}
export function isWechatHistoricalImageSessionError(message) {
return /historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(
String(message ?? '').trim(),
);
}
export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
@@ -1095,9 +1101,7 @@ export function isRecoverableWechatAgentSessionError(message) {
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 (/historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(normalized)) {
return true;
}
if (isWechatHistoricalImageSessionError(normalized)) return true;
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
return false;
@@ -1712,7 +1716,13 @@ export function createWechatMpService({
const attachRecentMediaForFollowup = (openid, intent, mediaAnalysisEnabled) => {
if (!mediaAnalysisEnabled || intent?.msgType !== 'text' || intent?.media?.publicUrl) return;
const text = String(intent?.agentText ?? '');
if (!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word)/iu.test(text)) return;
if (
!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word|报告|解读|化验|检验|做成页面|做个页面|做页面|生成页面)/iu.test(
text,
)
) {
return;
}
const key = String(openid ?? '').trim();
const recent = recentMediaByOpenid.get(key);
if (
@@ -3099,8 +3109,13 @@ export function createWechatMpService({
const mayBeStaleSession =
sessionId
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
const historicalImageError = isWechatHistoricalImageSessionError(message);
if (mayBeStaleSession) {
if (sessionPageContinuation && isWechatPageContinuationRepairableError(message)) {
if (
sessionPageContinuation
&& !historicalImageError
&& isWechatPageContinuationRepairableError(message)
) {
const text = buildPagePublishFailureText();
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP page continuation repair route clear failed:', clearErr);
@@ -3116,7 +3131,7 @@ export function createWechatMpService({
repairError.wechatAgentSessionId = sessionId;
throw repairError;
}
if (sessionPageContinuation) {
if (sessionPageContinuation && !historicalImageError) {
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr);
});
@@ -3144,17 +3159,18 @@ export function createWechatMpService({
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
const retryPrompt =
wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, {
wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
preferImmediateContext: sessionPageContinuation,
carriedSessionContent,
preferImmediateContext: retryPageContinuation,
carriedSessionContent: retryPageContinuation ? carriedSessionContent : '',
})
: buildWechatAgentPrompt(intent, {
imagePolicy,
preferSessionPageContinuation: sessionPageContinuation,
preferSessionPageContinuation: retryPageContinuation,
});
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
@@ -3171,7 +3187,7 @@ export function createWechatMpService({
userId: user.userId,
sessionId,
userMessage,
preserveAgentPrompt: sessionPageContinuation,
preserveAgentPrompt: retryPageContinuation,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
+238
View File
@@ -13,6 +13,7 @@ import {
findRecoverableWechatAgentErrorInReply,
isRecoverableWechatAgentSessionError,
isWechatAgentApiErrorText,
isWechatHistoricalImageSessionError,
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
@@ -385,6 +386,13 @@ test('WeChat session page continuation covers retry, edit, and poem edits', () =
shouldDeliverWechatHtmlArtifacts(poemEdit, { agentText: '把诗里第三段改长一点' }),
true,
);
assert.equal(
isWechatSessionPageContinuation(
classifyWechatIntent({ msgType: 'text', agentText: '解读详细报告,做成页面' }),
'解读详细报告,做成页面',
),
false,
);
});
test('WeChat immediate-context page skips fresh thumbnail requirement', () => {
@@ -3477,6 +3485,16 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
isRecoverableWechatAgentSessionError('historical_image_session_update_unsupported:405'),
true,
);
assert.equal(
isWechatHistoricalImageSessionError('historical_image_session_update_unsupported:405'),
true,
);
assert.equal(
isWechatHistoricalImageSessionError(
'Request failed: Bad request (400): messages[74]: unknown variant `image_url`, expected `text`',
),
true,
);
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
});
@@ -3572,6 +3590,113 @@ test('wechat mp rotates and retries when historical image isolation is unsupport
assert.equal(sentPayloads[0].text.content, '新会话已恢复,可以继续。');
});
test('wechat mp rotates page continuation instead of dropping image_url session errors', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const submittedSessions = [];
const sentPayloads = [];
let activeSessionId = 'session-1';
let routeCleared = false;
const poem = `《临江仙·秋思》${'昨夜西风凋碧树,独上高楼,望尽天涯路。'.repeat(3)}`;
const service = createBoundWechatService({
token,
startAgentSession: async () => ({ id: 'session-2' }),
userAuth: {
async getWechatAgentRoute() {
return routeCleared ? null : { agentSessionId: activeSessionId, status: 'active' };
},
async clearWechatAgentRoute() {
routeCleared = true;
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
},
submitSessionReply: async ({ sessionId, options }) => {
submittedSessions.push(sessionId);
assert.equal(options?.requireHistoricalImageIsolation, true);
if (sessionId === 'session-1') {
throw new Error(
'Ran into this error: Request failed: Bad request (400): Failed to deserialize the JSON body into the target type: messages[74]: unknown variant `image_url`, expected `text`',
);
}
return { ok: true };
},
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}`) {
return new Response(JSON.stringify({
conversation: sessionId === 'session-1'
? [{ role: 'assistant', content: [{ type: 'text', text: poem }] }]
: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/events`) {
if (sessionId === 'session-1') {
return new Response('', {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
}
return new Response(
[
'data: {"type":"Message","request_id":"req-page-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"新会话已恢复,可以继续。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-page-retry","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}`);
},
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 originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-page-first', 'req-page-retry'];
return () => ids.shift() ?? 'req-page-retry';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '把刚才的诗做成页面' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.deepEqual(submittedSessions, ['session-1', 'session-2']);
assert.equal(activeSessionId, 'session-2');
assert.equal(
sentPayloads.some((payload) => /没能可靠确认/.test(String(payload?.text?.content ?? ''))),
false,
);
});
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
const toolCallsError =
"Request failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message).";
@@ -5318,6 +5443,119 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
});
test('wechat mp reattaches recent image for report interpretation follow-up', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const testUserId = 'test-user-report-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
mediaAnalysisGrayUsers: [testUserId],
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: testUserId, status: 'active', nickname: '唐' };
},
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
eventCall += 1;
if (eventCall === 1) {
return new Response(
new ReadableStream({
start(controller) {
releaseFirst = () => {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
'data: {"type":"Finish"}\n\n',
),
);
controller.close();
};
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n',
'data: {"type":"Finish"}\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: ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
wechatFetch: async (url) => {
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/media/get')) {
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { 'Content-Type': 'image/png' },
});
}
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}`);
},
});
try {
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
extraFields: { MediaId: 'media-report', PicUrl: 'https://wx.example.com/report.png' },
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 1);
releaseFirst();
await imageResult.task;
await followupResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.equal(submitCalls.length, 2);
assert.deepEqual(
submitCalls[1].userMessage.metadata.imageUrls,
submitCalls[0].userMessage.metadata.imageUrls,
);
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[1].userMessage.metadata.displayText, '解读详细报告,做成页面');
});
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
const token = 'token';
const timestamp = '1710000000';