Compare commits

...

6 Commits

Author SHA1 Message Date
john 2b58cdc2c8 test(wechat): simulate tang image report and follow-up edit flow
Memind CI / Test, build, and release guards (push) Failing after 3m27s
Add a four-step webhook simulation covering reset, image ack, report page
turn, and polluted-session rotation on content-edit follow-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 09:19:24 +08:00
john 9f7633be24 fix(wechat): rotate polluted image sessions before follow-up turns
Memind CI / Test, build, and release guards (push) Successful in 3m28s
After a successful image report flow, Goose may keep image_url parts that
DeepSeek rejects on later turns while PUT scrub returns 405. Detect polluted
sessions up front, rotate to a fresh agent route with carried assistant
context, and recover image URLs from recent media on historical retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 09:06:00 +08:00
john fbd68a36e9 fix(release): stop circular dry-run in fast release gate unit test
Memind CI / Test, build, and release guards (push) Successful in 3m36s
Replace the integration spawn that re-entered run_fast_release_guards with
static script checks so fast release guards can finish reliably.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 08:34:53 +08:00
john be8ce867ca 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.
2026-08-15 08:21:39 +08:00
john 67b9478155 fix(wechat): avoid image_url session poison on report follow-ups
Store image-only WeChat messages without running Agent, scrub assistant image_url history, and retry historical-image failures with text-only prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 08:21:28 +08:00
john 4c2bc95462 Merge branch 'feature/portal-fast-release'
Memind CI / Test, build, and release guards (push) Successful in 3m0s
Add standard and fast 103 portal release modes.
2026-08-15 08:06:15 +08:00
7 changed files with 554 additions and 144 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([
{
+9 -15
View File
@@ -129,22 +129,16 @@ test('production release rejects --skip-tests before repository or network prefl
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /103 只读预检/);
});
test('fast production release allows --skip-tests but still requires check-release-ready', () => {
const result = spawnSync(
'bash',
[
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
'--mode',
'fast',
'--skip-tests',
'--skip-build',
'--dry-run',
],
{ cwd: ROOT, encoding: 'utf8' },
test('fast production release allows --skip-tests but still requires check-release-ready', async () => {
const source = await fs.readFile(
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
'utf8',
);
assert.notEqual(result.status, 0);
assert.doesNotMatch(result.stderr, /标准发布禁止 --skip-tests/);
assert.match(`${result.stdout}\n${result.stderr}`, /快速发布:跳过额外本地测试/);
assert.match(source, /ALLOW_MAIN_RELEASE=1 bash "\$\{ROOT\}\/scripts\/check-release-ready\.sh"/);
assert.match(source, /快速发布:跳过额外本地测试/);
const skipTestsBlockEnd = source.indexOf('fi\n\nif [[ "${DRY_RUN}" -eq 0 ]]; then');
const checkReleaseIdx = source.indexOf('check-release-ready.sh');
assert.ok(skipTestsBlockEnd >= 0 && checkReleaseIdx > skipTestsBlockEnd);
});
test('fast release wrapper delegates to runtime prod script with --mode fast', async () => {
+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);
}, {
+126 -3
View File
@@ -85,6 +85,7 @@ import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs';
import {
buildPageDataCollectFailureText,
} from './mindspace-page-data-finish-guard.mjs';
import { conversationHasImageUrlContent } from './chat-image-turn-scope.mjs';
export { buildWechatAgentPrompt };
@@ -1094,6 +1095,30 @@ export function isWechatHistoricalImageSessionError(message) {
);
}
export const WECHAT_IMAGE_STORED_ACK_TEXT =
'已收到图片。请直接告诉我接下来要做什么,例如:解读报告并生成页面。';
export function resolveWechatRecentMediaPublicUrl(recentMediaEntry) {
if (!recentMediaEntry?.items?.length) return '';
return String(recentMediaEntry.items.at(-1)?.media?.publicUrl ?? '').trim();
}
export function prepareWechatIntentForHistoricalImageRetry(intent, { fallbackImageUrl = '' } = {}) {
if (!intent || typeof intent !== 'object') return intent;
const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').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;
@@ -2166,15 +2191,23 @@ export function createWechatMpService({
retainUnlinkedRoute = false,
userContext = null,
}) => {
if (forceNew) {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const workingDir = await userAuth.resolveWorkingDir(userId);
let sessionPolicy =
await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const addressName = resolveWechatAddressName(userContext);
let carriedSessionContentForNewSession = '';
if (forceNew) {
const routeBeforeForceNew = await userAuth.getWechatAgentRoute(config.appId, openid);
if (routeBeforeForceNew?.agentSessionId) {
carriedSessionContentForNewSession = await fetchLastSubstantiveAssistantFromSession(
fetchForSession,
routeBeforeForceNew.agentSessionId,
);
rememberedWechatContexts.delete(routeBeforeForceNew.agentSessionId);
}
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
if (existingRoute?.agentSessionId) {
const now = Date.now();
@@ -2386,6 +2419,53 @@ export function createWechatMpService({
};
};
const rotateWechatSessionIfImagePolluted = async ({
userId,
openid,
sessionId,
user,
carriedSessionContent = '',
}) => {
if (!sessionId) {
return { sessionId, carriedSessionContent, rotated: false };
}
try {
const response = await fetchForSession(
sessionId,
`/sessions/${encodeURIComponent(sessionId)}`,
);
if (!response.ok) {
return { sessionId, carriedSessionContent, rotated: false };
}
const payload = await readJsonResponse(response);
const conversation = Array.isArray(payload?.conversation) ? payload.conversation : [];
if (!conversationHasImageUrlContent(conversation)) {
return { sessionId, carriedSessionContent, rotated: false };
}
logger.warn?.('WeChat MP rotating image-polluted agent session before reply:', {
agentSessionId: sessionId,
conversationLength: conversation.length,
});
const nextRoute = await ensureWechatAgentSession({
userId,
openid,
forceNew: true,
userContext: user,
});
const nextCarried = String(carriedSessionContent ?? '').trim()
|| String(nextRoute.carriedSessionContent ?? '').trim();
return {
sessionId: nextRoute.sessionId,
carriedSessionContent: nextCarried,
rotated: true,
isNewSession: nextRoute.isNewSession,
};
} catch (err) {
logger.warn?.('WeChat MP image-polluted session rotate skipped:', err);
return { sessionId, carriedSessionContent, rotated: false };
}
};
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return;
try {
@@ -2693,6 +2773,26 @@ export function createWechatMpService({
return { sessionId };
}
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
const pollutionRotation = await rotateWechatSessionIfImagePolluted({
userId: user.userId,
openid: inbound.fromUserName,
sessionId,
user,
carriedSessionContent,
});
if (pollutionRotation.rotated) {
sessionId = pollutionRotation.sessionId;
route = {
...route,
sessionId,
isNewSession: pollutionRotation.isNewSession ?? true,
};
if (pollutionRotation.carriedSessionContent) {
carriedSessionContent = pollutionRotation.carriedSessionContent;
}
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: true });
}
if (
wechatIntent.kind === 'page.generate'
&& sessionPageContinuation
@@ -3157,6 +3257,13 @@ export function createWechatMpService({
sessionId = route.sessionId;
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
if (historicalImageError) {
prepareWechatIntentForHistoricalImageRetry(intent, {
fallbackImageUrl: resolveWechatRecentMediaPublicUrl(
recentMediaByOpenid.get(String(inbound.fromUserName ?? '').trim()),
),
});
}
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
@@ -3683,6 +3790,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 {
+369 -115
View File
@@ -14,6 +14,9 @@ import {
isRecoverableWechatAgentSessionError,
isWechatAgentApiErrorText,
isWechatHistoricalImageSessionError,
WECHAT_IMAGE_STORED_ACK_TEXT,
prepareWechatIntentForHistoricalImageRetry,
resolveWechatRecentMediaPublicUrl,
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
@@ -3498,6 +3501,40 @@ 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('prepareWechatIntentForHistoricalImageRetry can recover image url from recent media cache', () => {
const intent = prepareWechatIntentForHistoricalImageRetry(
{ agentText: '帮我把报告再详细点' },
{ fallbackImageUrl: 'https://example.com/report.png' },
);
assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/);
assert.equal(intent.media, undefined);
});
test('resolveWechatRecentMediaPublicUrl reads the latest remembered image', () => {
assert.equal(
resolveWechatRecentMediaPublicUrl({
items: [
{ media: { publicUrl: 'https://example.com/first.png' } },
{ media: { publicUrl: 'https://example.com/second.png' } },
],
}),
'https://example.com/second.png',
);
});
test('wechat mp rotates and retries when historical image isolation is unsupported', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -4917,7 +4954,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 +4966,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 +4984,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 +5053,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 +5061,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 +5076,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 +5384,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 +5396,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 +5445,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 +5468,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 +5480,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 +5529,311 @@ 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.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 rotates polluted image session before a content-edit follow-up', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const testUserId = 'test-user-image-followup-rotate';
const submitCalls = [];
let activeSessionId = 'session-1';
let nextSessionId = 2;
let routeCleared = false;
const pollutedConversation = [
{
id: 'assistant-report',
role: 'assistant',
metadata: { userVisible: true },
content: [{ type: 'text', text: '报告页面已生成。' }],
},
{
id: 'assistant-image',
role: 'assistant',
metadata: { userVisible: false },
content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }],
},
];
const service = createBoundWechatService({
token,
config: {
mediaAnalysisGrayUsers: [testUserId],
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: testUserId, status: 'active', nickname: '唐' };
},
async getWechatAgentRoute() {
if (routeCleared || !activeSessionId) return null;
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
async clearWechatAgentRoute() {
routeCleared = true;
},
},
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}`) {
const body = sessionId === 'session-1'
? { conversation: pollutedConversation }
: { conversation: [] };
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/events`) {
return new Response(
[
'data: {"type":"Message","request_id":"req-followup","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已按你的要求补充报告细节。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-followup","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}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
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')) {
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-followup'];
return () => ids.shift() ?? 'req-followup';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '帮我把报告再详细一点' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.equal(submitCalls.length, 1);
assert.notEqual(submitCalls[0].sessionId, 'session-1');
assert.equal(activeSessionId, submitCalls[0].sessionId);
});
test('wechat mp simulates tang image report page and desensitized follow-up', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const testUserId = 'test-user-tang-report-followup';
const submitCalls = [];
const customerReplies = [];
let activeSessionId = 'session-1';
let nextSessionId = 2;
let routeCleared = false;
let reportPageCompleted = false;
let reportSessionId = null;
const pollutedConversation = [
{
id: 'assistant-report',
role: 'assistant',
metadata: { userVisible: true },
content: [{ type: 'text', text: '已解读化验报告并生成页面链接。' }],
},
{
id: 'assistant-image',
role: 'assistant',
metadata: { userVisible: false },
content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }],
},
];
const service = createBoundWechatService({
token,
config: {
mediaAnalysisGrayUsers: [testUserId],
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: testUserId, status: 'active', nickname: '唐' };
},
async getWechatAgentRoute() {
if (routeCleared || !activeSessionId) return null;
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
async clearWechatAgentRoute() {
routeCleared = true;
},
},
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}`) {
const conversation =
reportSessionId && sessionId === reportSessionId && reportPageCompleted
? pollutedConversation
: [];
return new Response(JSON.stringify({ conversation }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/events`) {
const isReportStream = submitCalls.length === 0;
const text = isReportStream
? '报告页面已生成:https://m.tkmind.cn/MindSpace/test/public/report.html'
: '已按你的要求脱敏并补充报告细节。';
return new Response(
[
`data: {"type":"Message","message":{"id":"assistant-${isReportStream ? 'report' : 'edit'}","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"${text}"}]}}\n\n`,
'data: {"type":"Finish","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}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
if (!reportSessionId) {
reportSessionId = input.sessionId;
reportPageCompleted = true;
}
assert.equal(input.options?.requireHistoricalImageIsolation, true);
return { ok: true };
},
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/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')) {
customerReplies.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}`);
},
});
try {
const resetResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '换新会话', extraFields: { MsgId: 'tang-1' } }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
if (resetResult.task) await resetResult.task;
assert.equal(submitCalls.length, 0);
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
extraFields: {
MsgId: 'tang-2',
MediaId: 'media-report',
PicUrl: 'https://wx.example.com/report.png',
},
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(submitCalls.length, 0);
assert.match(imageResult.body ?? '', /已收到图片/);
const reportResult = await service.handleInboundMessage(
inboundXml({
msgType: 'text',
content: '解读报告并生成页面',
extraFields: { MsgId: 'tang-3' },
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await reportResult.task;
const editResult = await service.handleInboundMessage(
inboundXml({
msgType: 'text',
content: '帮我把真名脱敏,报告再详细点',
extraFields: { MsgId: 'tang-4' },
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await editResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.ok(submitCalls.length >= 2, `submitCalls=${submitCalls.length}`);
const firstReportSubmit = submitCalls.find(
(call) => Array.isArray(call.userMessage.metadata?.imageUrls) && call.userMessage.metadata.imageUrls.length > 0,
);
assert.ok(firstReportSubmit, 'missing report submit with imageUrls');
assert.equal(firstReportSubmit.sessionId, reportSessionId);
assert.notEqual(submitCalls.at(-1).sessionId, reportSessionId);
assert.ok(customerReplies.length >= 2);
const replyTexts = customerReplies.map((payload) => String(payload?.text?.content ?? ''));
for (const text of replyTexts) {
assert.doesNotMatch(text, /unknown variant [`']?image_url/i);
assert.doesNotMatch(text, /Ran into this error:/i);
}
assert.ok(replyTexts.some((text) => /脱敏/.test(text)), replyTexts.join(' | '));
assert.ok(
replyTexts.some((text) => /report\.html/.test(text))
|| submitCalls.some((call) => Array.isArray(call.userMessage.metadata?.imageUrls)),
replyTexts.join(' | '),
);
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 () => {
@@ -5562,8 +5842,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 +5854,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 +5907,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 +5922,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 +5932,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 +5946,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 () => {