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. * Remove image attachments from a persisted message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
*/ */
export function scrubUserMessageImageAttachments(message) { function scrubPersistedImageAttachments(message) {
if (!message || message.role !== 'user') return { message, changed: false }; if (!message) return { message, changed: false };
const metadata = const metadata =
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata) message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
@@ -153,15 +152,13 @@ export function scrubUserMessageImageAttachments(message) {
return null; return null;
} }
if (item?.type !== 'text' || typeof item.text !== 'string') return item; 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; if (nextText === item.text) return item;
contentChanged = true; contentChanged = true;
return nextText ? { ...item, text: nextText } : null; return nextText ? { ...item, text: nextText } : null;
}).filter(Boolean) }).filter(Boolean)
: message.content; : message.content;
const displayText =
typeof metadata.displayText === 'string' ? metadata.displayText : null;
const changed = hadImageMetadata || contentChanged; const changed = hadImageMetadata || contentChanged;
if (!changed) return { message, changed: false }; if (!changed) return { message, changed: false };
@@ -170,12 +167,20 @@ export function scrubUserMessageImageAttachments(message) {
...message, ...message,
content, content,
metadata, metadata,
...(displayText != null ? {} : {}),
}, },
changed: true, 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) { export function messageContentHasImageUrl(content) {
if (!Array.isArray(content)) return false; if (!Array.isArray(content)) return false;
return content.some((item) => item?.type === 'image_url' && item?.image_url?.url); return content.some((item) => item?.type === 'image_url' && item?.image_url?.url);
@@ -203,9 +208,8 @@ export function scrubConversationHistoricalImageAttachments(conversation, active
let changed = false; let changed = false;
const nextConversation = conversation.map((message) => { const nextConversation = conversation.map((message) => {
if (message?.role !== 'user') return message;
if (String(message?.id ?? '').trim() === activeId) return message; if (String(message?.id ?? '').trim() === activeId) return message;
const scrubbed = scrubUserMessageImageAttachments(message); const scrubbed = scrubPersistedImageAttachments(message);
if (scrubbed.changed) changed = true; if (scrubbed.changed) changed = true;
return scrubbed.message; 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', () => { test('buildCurrentTurnImageScopeNote states one independent topic per upload', () => {
const note = buildCurrentTurnImageScopeNote([ const note = buildCurrentTurnImageScopeNote([
{ {
+11
View File
@@ -2009,6 +2009,17 @@ export function createTkmindProxy({
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED'; error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
throw error; 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; let finalUserMessage = userMessage;
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) { 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 }, { 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); 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) { export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim(); const normalized = String(message ?? '').trim();
if (!normalized) return false; if (!normalized) return false;
@@ -3157,6 +3176,9 @@ export function createWechatMpService({
sessionId = route.sessionId; sessionId = route.sessionId;
await ensureSessionProvider(sessionId); await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
if (historicalImageError) {
prepareWechatIntentForHistoricalImageRetry(intent);
}
const retryId = crypto.randomUUID(); const retryId = crypto.randomUUID();
const retryStartedAt = Date.now(); const retryStartedAt = Date.now();
const retryPageContinuation = sessionPageContinuation && !historicalImageError; const retryPageContinuation = sessionPageContinuation && !historicalImageError;
@@ -3683,6 +3705,22 @@ export function createWechatMpService({
}; };
intent.agentText = `[图片1]: ${persisted.publicUrl}`; intent.agentText = `[图片1]: ${persisted.publicUrl}`;
rememberRecentMedia(inbound.fromUserName, intent); 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) { } catch (error) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return { return {
+59 -116
View File
@@ -14,6 +14,8 @@ import {
isRecoverableWechatAgentSessionError, isRecoverableWechatAgentSessionError,
isWechatAgentApiErrorText, isWechatAgentApiErrorText,
isWechatHistoricalImageSessionError, isWechatHistoricalImageSessionError,
WECHAT_IMAGE_STORED_ACK_TEXT,
prepareWechatIntentForHistoricalImageRetry,
sanitizeWechatAgentOutboundText, sanitizeWechatAgentOutboundText,
loadWechatMpConfig, loadWechatMpConfig,
maybeAttachPublishedHtmlLink, maybeAttachPublishedHtmlLink,
@@ -3498,6 +3500,19 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false); 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 () => { test('wechat mp rotates and retries when historical image isolation is unsupported', async () => {
const token = 'token'; const token = 'token';
const timestamp = '1710000000'; 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; const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-image'; crypto.randomUUID = () => 'req-image';
try { try {
const result = await service.handleInboundMessage( const imageResult = await service.handleInboundMessage(
inboundXml({ inboundXml({
msgType: 'image', msgType: 'image',
content: '', content: '',
@@ -4929,9 +4944,15 @@ test('wechat mp wildcard media access persists image and routes image url into a
signature: signatureFor(token, timestamp, nonce), signature: signatureFor(token, timestamp, nonce),
}, },
); );
assert.equal(result.status, 200); assert.equal(imageResult.status, 200);
assert.match(result.body, /<Content>/); assert.match(imageResult.body ?? '', /已收到图片/);
await result.task; 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 { } finally {
crypto.randomUUID = originalRandomUuid; crypto.randomUUID = originalRandomUuid;
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { 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.equal(prompts.length, 1);
assert.match(prompts[0], /【微信服务号图片消息】/); assert.match(prompts[0], /请分析刚才图片/);
assert.match(
prompts[0],
/\[图片1\]: https:\/\/example\.com\/MindSpace\/test-user-image\/public\/wechat-mp\//,
);
assert.equal(metadataCalls.length, 1); assert.equal(metadataCalls.length, 1);
assert.equal(metadataCalls[0].source, 'wechat_mp'); 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.equal(metadataCalls[0].imageUrls.length, 1);
assert.match(metadataCalls[0].imageUrls[0], /\/public\/wechat-mp\//); 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\//); assert.match(detailCalls[0].mediaPublicUrl, /\/wechat-mp\//);
}); });
@@ -5014,7 +5031,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
}); });
try { try {
const result = await service.handleInboundMessage( const imageResult = await service.handleInboundMessage(
inboundXml({ inboundXml({
msgType: 'image', msgType: 'image',
content: '', content: '',
@@ -5022,7 +5039,14 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
}), }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, { 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 { } finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true }); 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.length, 1);
assert.equal(submitCalls[0].userId, testUserId); assert.equal(submitCalls[0].userId, testUserId);
assert.equal(submitCalls[0].sessionId, 'session-1'); assert.equal(submitCalls[0].sessionId, 'session-1');
assert.equal(submitCalls[0].options?.requireHistoricalImageIsolation, true);
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1); assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//); 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 nonce = 'nonce';
const testUserId = 'test-user-image-followup'; const testUserId = 'test-user-image-followup';
const submitCalls = []; const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({ const service = createBoundWechatService({
token, token,
config: { config: {
@@ -5351,25 +5374,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
}, },
sessionApiFetch: async (_sessionId, pathname) => { sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') { 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( return new Response(
[ [
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已结合刚才图片分析主题。"}]}}\n\n', '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) }, { 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( const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '请根据刚才图片分析主题' }), inboundXml({ msgType: 'text', content: '请根据刚才图片分析主题' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, { 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; await followupResult.task;
} finally { } finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true }); fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
} }
assert.equal(submitCalls.length, 2); assert.equal(submitCalls.length, 1);
assert.deepEqual( assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
submitCalls[1].userMessage.metadata.imageUrls, assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
submitCalls[0].userMessage.metadata.imageUrls,
);
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
}); });
test('wechat mp reattaches recent image for report interpretation follow-up', async () => { 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 nonce = 'nonce';
const testUserId = 'test-user-report-followup'; const testUserId = 'test-user-report-followup';
const submitCalls = []; const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({ const service = createBoundWechatService({
token, token,
config: { config: {
@@ -5463,25 +5458,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
}, },
sessionApiFetch: async (_sessionId, pathname) => { sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') { 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( return new Response(
[ [
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n', '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) }, { 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( const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }), inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, { 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; await followupResult.task;
} finally { } finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true }); fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
} }
assert.equal(submitCalls.length, 2); assert.equal(submitCalls.length, 1);
assert.deepEqual( assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
submitCalls[1].userMessage.metadata.imageUrls, assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
submitCalls[0].userMessage.metadata.imageUrls, assert.equal(submitCalls[0].userMessage.metadata.displayText, '解读详细报告,做成页面');
);
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 () => { 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 nonce = 'nonce';
const testUserId = 'test-user-multi-image-followup'; const testUserId = 'test-user-multi-image-followup';
const submitCalls = []; const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({ const service = createBoundWechatService({
token, token,
config: { config: {
@@ -5576,25 +5543,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
}, },
sessionApiFetch: async (_sessionId, pathname) => { sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') { 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( return new Response(
[ [
'data: {"type":"Message","message":{"id":"assistant-ok","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"处理完成。"}]}}\n\n', '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) }, { 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( const secondImageResult = await service.handleInboundMessage(
inboundXml({ 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) }, { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
); );
assert.equal(submitCalls.length, 0);
assert.match(secondImageResult.body ?? '', /已收到图片/);
const followupResult = await service.handleInboundMessage( const followupResult = await service.handleInboundMessage(
inboundXml({ 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) }, { 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; await followupResult.task;
const laterResult = await service.handleInboundMessage( 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 }); fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
} }
assert.equal(submitCalls.length, 4); assert.equal(submitCalls.length, 2);
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1); assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 2);
assert.equal(submitCalls[1].userMessage.metadata.imageUrls.length, 1); assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /media-first/);
assert.equal(submitCalls[2].userMessage.metadata.imageUrls.length, 2); assert.match(submitCalls[0].userMessage.metadata.imageUrls[1], /media-second/);
assert.match(submitCalls[2].userMessage.metadata.imageUrls[0], /media-first/); assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
assert.match(submitCalls[2].userMessage.metadata.imageUrls[1], /media-second/); assert.equal(submitCalls[1].userMessage.metadata.imageUrls, undefined);
assert.equal(submitCalls[2].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[3].userMessage.metadata.imageUrls, undefined);
}); });
test('wechat mp service persists Word and Excel files in user public area and reuses H5 attachment metadata', async () => { test('wechat mp service persists Word and Excel files in user public area and reuses H5 attachment metadata', async () => {