Merge branch 'feature/wechat-image-followup-poison-fix'
Memind CI / Test, build, and release guards (push) Failing after 3m29s

Fix WeChat image-then-text report follow-up image_url session poisoning.
This commit is contained in:
john
2026-08-15 08:21:39 +08:00
6 changed files with 147 additions and 127 deletions
+14 -10
View File
@@ -122,11 +122,10 @@ export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUr
}
/**
* Remove image attachments from a persisted user message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
* Remove image attachments from a persisted message so later turns cannot reuse them.
*/
export function scrubUserMessageImageAttachments(message) {
if (!message || message.role !== 'user') return { message, changed: false };
function scrubPersistedImageAttachments(message) {
if (!message) return { message, changed: false };
const metadata =
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
@@ -153,15 +152,13 @@ export function scrubUserMessageImageAttachments(message) {
return null;
}
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
const nextText = stripAgentImageText(item.text);
const nextText = message.role === 'user' ? stripAgentImageText(item.text) : item.text;
if (nextText === item.text) return item;
contentChanged = true;
return nextText ? { ...item, text: nextText } : null;
}).filter(Boolean)
: message.content;
const displayText =
typeof metadata.displayText === 'string' ? metadata.displayText : null;
const changed = hadImageMetadata || contentChanged;
if (!changed) return { message, changed: false };
@@ -170,12 +167,20 @@ export function scrubUserMessageImageAttachments(message) {
...message,
content,
metadata,
...(displayText != null ? {} : {}),
},
changed: true,
};
}
/**
* Remove image attachments from a persisted user message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
*/
export function scrubUserMessageImageAttachments(message) {
if (!message || message.role !== 'user') return { message, changed: false };
return scrubPersistedImageAttachments(message);
}
export function messageContentHasImageUrl(content) {
if (!Array.isArray(content)) return false;
return content.some((item) => item?.type === 'image_url' && item?.image_url?.url);
@@ -203,9 +208,8 @@ export function scrubConversationHistoricalImageAttachments(conversation, active
let changed = false;
const nextConversation = conversation.map((message) => {
if (message?.role !== 'user') return message;
if (String(message?.id ?? '').trim() === activeId) return message;
const scrubbed = scrubUserMessageImageAttachments(message);
const scrubbed = scrubPersistedImageAttachments(message);
if (scrubbed.changed) changed = true;
return scrubbed.message;
});
+24
View File
@@ -103,6 +103,30 @@ test('scrubConversationHistoricalImageAttachments keeps only active turn attachm
]);
});
test('scrubConversationHistoricalImageAttachments removes assistant image_url parts', () => {
const { conversation, changed } = scrubConversationHistoricalImageAttachments(
[
{
id: 'assistant-old',
role: 'assistant',
content: [
{ type: 'text', text: '已识别报告' },
{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } },
],
},
{
id: 'user-new',
role: 'user',
content: [{ type: 'text', text: '解读报告,生成页面' }],
},
],
'user-new',
);
assert.equal(changed, true);
assert.equal(conversation[0].content.some((item) => item.type === 'image_url'), false);
});
test('buildCurrentTurnImageScopeNote states one independent topic per upload', () => {
const note = buildCurrentTurnImageScopeNote([
{
+11
View File
@@ -2009,6 +2009,17 @@ export function createTkmindProxy({
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
throw error;
}
if (
requireHistoricalImageIsolation
&& imageIsolation.hasImageUrlContent
&& !imageIsolation.updated
) {
const error = new Error(
`historical_image_session_update_unsupported:${imageIsolation.status ?? 'image_url_content_present'}`,
);
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
throw error;
}
}
let finalUserMessage = userMessage;
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
+1 -1
View File
@@ -1569,7 +1569,7 @@ test('submitSessionReplyForUser rotates when assistant history still has image_u
},
{ requireHistoricalImageIsolation: true },
),
/historical_image_session_update_unsupported:image_url_content_present/,
/historical_image_session_update_unsupported:(405|image_url_content_present)/,
);
assert.equal(replyBodies.length, 0);
}, {
+38
View File
@@ -1094,6 +1094,25 @@ export function isWechatHistoricalImageSessionError(message) {
);
}
export const WECHAT_IMAGE_STORED_ACK_TEXT =
'已收到图片。请直接告诉我接下来要做什么,例如:解读报告并生成页面。';
export function prepareWechatIntentForHistoricalImageRetry(intent) {
if (!intent || typeof intent !== 'object') return intent;
const imageUrl = String(intent.media?.publicUrl ?? '').trim();
const agentText = String(intent.agentText ?? '').trim();
if (imageUrl && !agentText.includes(imageUrl)) {
intent.agentText = agentText
? `${agentText}\n\n[附件图片]: ${imageUrl}`
: `[附件图片]: ${imageUrl}`;
}
delete intent.media;
delete intent.attachment;
delete intent.recentMediaItems;
delete intent.recentMediaBatchId;
return intent;
}
export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
@@ -3157,6 +3176,9 @@ export function createWechatMpService({
sessionId = route.sessionId;
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
if (historicalImageError) {
prepareWechatIntentForHistoricalImageRetry(intent);
}
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
@@ -3683,6 +3705,22 @@ export function createWechatMpService({
};
intent.agentText = `[图片1]: ${persisted.publicUrl}`;
rememberRecentMedia(inbound.fromUserName, intent);
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
await userAuth.finishWechatMpMessage({
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
status: 'done',
agentSessionId: null,
});
}
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: await buildPassiveReplyBody(WECHAT_IMAGE_STORED_ACK_TEXT),
};
} catch (error) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
+59 -116
View File
@@ -14,6 +14,8 @@ import {
isRecoverableWechatAgentSessionError,
isWechatAgentApiErrorText,
isWechatHistoricalImageSessionError,
WECHAT_IMAGE_STORED_ACK_TEXT,
prepareWechatIntentForHistoricalImageRetry,
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
@@ -3498,6 +3500,19 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
});
test('prepareWechatIntentForHistoricalImageRetry keeps image url in text only', () => {
const intent = prepareWechatIntentForHistoricalImageRetry({
agentText: '解读报告,生成页面',
media: { publicUrl: 'https://example.com/report.png' },
recentMediaItems: [{ media: { publicUrl: 'https://example.com/report.png' } }],
recentMediaBatchId: 'batch-1',
});
assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/);
assert.equal(intent.media, undefined);
assert.equal(intent.recentMediaItems, undefined);
assert.equal(intent.recentMediaBatchId, undefined);
});
test('wechat mp rotates and retries when historical image isolation is unsupported', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -4917,7 +4932,7 @@ test('wechat mp wildcard media access persists image and routes image url into a
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-image';
try {
const result = await service.handleInboundMessage(
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
@@ -4929,9 +4944,15 @@ test('wechat mp wildcard media access persists image and routes image url into a
signature: signatureFor(token, timestamp, nonce),
},
);
assert.equal(result.status, 200);
assert.match(result.body, /<Content>/);
await result.task;
assert.equal(imageResult.status, 200);
assert.match(imageResult.body ?? '', /已收到图片/);
assert.equal(prompts.length, 0);
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '请分析刚才图片' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await followupResult.task;
} finally {
crypto.randomUUID = originalRandomUuid;
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), {
@@ -4941,17 +4962,13 @@ test('wechat mp wildcard media access persists image and routes image url into a
}
assert.equal(prompts.length, 1);
assert.match(prompts[0], /【微信服务号图片消息】/);
assert.match(
prompts[0],
/\[图片1\]: https:\/\/example\.com\/MindSpace\/test-user-image\/public\/wechat-mp\//,
);
assert.match(prompts[0], /请分析刚才图片/);
assert.equal(metadataCalls.length, 1);
assert.equal(metadataCalls[0].source, 'wechat_mp');
assert.equal(metadataCalls[0].msgType, 'image');
assert.equal(metadataCalls[0].msgType, 'text');
assert.equal(metadataCalls[0].imageUrls.length, 1);
assert.match(metadataCalls[0].imageUrls[0], /\/public\/wechat-mp\//);
assert.equal(detailCalls.length, 1);
assert.equal(detailCalls.length, 2);
assert.match(detailCalls[0].mediaPublicUrl, /\/wechat-mp\//);
});
@@ -5014,7 +5031,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
});
try {
const result = await service.handleInboundMessage(
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
@@ -5022,7 +5039,14 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
assert.match(imageResult.body ?? '', /已收到图片/);
assert.equal(submitCalls.length, 0);
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '请分析刚才图片' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await followupResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
@@ -5030,6 +5054,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
assert.equal(submitCalls.length, 1);
assert.equal(submitCalls[0].userId, testUserId);
assert.equal(submitCalls[0].sessionId, 'session-1');
assert.equal(submitCalls[0].options?.requireHistoricalImageIsolation, true);
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//);
});
@@ -5337,8 +5362,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
const nonce = 'nonce';
const testUserId = 'test-user-image-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
@@ -5351,25 +5374,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
},
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',
@@ -5419,28 +5423,21 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 0);
assert.match(imageResult.body ?? '', new RegExp(WECHAT_IMAGE_STORED_ACK_TEXT.slice(0, 8)));
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.length, 1);
assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
});
test('wechat mp reattaches recent image for report interpretation follow-up', async () => {
@@ -5449,8 +5446,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
const nonce = 'nonce';
const testUserId = 'test-user-report-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
@@ -5463,25 +5458,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
},
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',
@@ -5531,29 +5507,22 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 0);
assert.match(imageResult.body ?? '', /已收到图片/);
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, '解读详细报告,做成页面');
assert.equal(submitCalls.length, 1);
assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[0].userMessage.metadata.displayText, '解读详细报告,做成页面');
});
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
@@ -5562,8 +5531,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
const nonce = 'nonce';
const testUserId = 'test-user-multi-image-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
@@ -5576,25 +5543,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
},
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-first","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-ok","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"处理完成。"}]}}\n\n',
@@ -5648,7 +5596,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 0);
assert.match(firstImageResult.body ?? '', /已收到图片/);
const secondImageResult = await service.handleInboundMessage(
inboundXml({
@@ -5662,6 +5611,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(submitCalls.length, 0);
assert.match(secondImageResult.body ?? '', /已收到图片/);
const followupResult = await service.handleInboundMessage(
inboundXml({
@@ -5670,12 +5621,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 1);
releaseFirst();
await firstImageResult.task;
await secondImageResult.task;
await followupResult.task;
const laterResult = await service.handleInboundMessage(
@@ -5690,14 +5635,12 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.equal(submitCalls.length, 4);
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
assert.equal(submitCalls[1].userMessage.metadata.imageUrls.length, 1);
assert.equal(submitCalls[2].userMessage.metadata.imageUrls.length, 2);
assert.match(submitCalls[2].userMessage.metadata.imageUrls[0], /media-first/);
assert.match(submitCalls[2].userMessage.metadata.imageUrls[1], /media-second/);
assert.equal(submitCalls[2].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[3].userMessage.metadata.imageUrls, undefined);
assert.equal(submitCalls.length, 2);
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 2);
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /media-first/);
assert.match(submitCalls[0].userMessage.metadata.imageUrls[1], /media-second/);
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[1].userMessage.metadata.imageUrls, undefined);
});
test('wechat mp service persists Word and Excel files in user public area and reuses H5 attachment metadata', async () => {