fix(wechat): rotate image_url-poisoned sessions for report follow-ups
Memind CI / Test, build, and release guards (push) Has been cancelled
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:
@@ -75,6 +75,52 @@ function stripAgentImageText(text) {
|
|||||||
.trim();
|
.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.
|
* Remove image attachments from a persisted user message so later turns cannot reuse them.
|
||||||
* UI displayText / previewImageUrls are preserved for chat history rendering.
|
* UI displayText / previewImageUrls are preserved for chat history rendering.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
extractCurrentTurnImageUrls,
|
extractCurrentTurnImageUrls,
|
||||||
scrubConversationHistoricalImageAttachments,
|
scrubConversationHistoricalImageAttachments,
|
||||||
scrubUserMessageImageAttachments,
|
scrubUserMessageImageAttachments,
|
||||||
|
detachCurrentTurnImagesForTextProvider,
|
||||||
} from './chat-image-turn-scope.mjs';
|
} from './chat-image-turn-scope.mjs';
|
||||||
|
|
||||||
test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', () => {
|
test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', () => {
|
||||||
@@ -157,3 +158,40 @@ test('conversationHasImageUrlContent detects historical poison and ignores activ
|
|||||||
false,
|
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\]:/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -111,12 +111,14 @@ test('buildVisionPayload sends all current-turn images to Qwen in order', async
|
|||||||
|
|
||||||
assert.equal(analyzedImages.length, 2);
|
assert.equal(analyzedImages.length, 2);
|
||||||
assert.deepEqual(analyzedImages.map((item) => item.rawUrl), imageUrls);
|
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/first.jpg',
|
||||||
'/MindSpace/user-1/public/wechat-mp/second.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.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 () => {
|
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,
|
false,
|
||||||
);
|
);
|
||||||
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /蓝色方块/);
|
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
@@ -35,6 +35,7 @@ import { extractAttachmentText } from './mindspace-attachment-text.mjs';
|
|||||||
import {
|
import {
|
||||||
buildCurrentTurnImageScopeNote,
|
buildCurrentTurnImageScopeNote,
|
||||||
conversationHasImageUrlContent,
|
conversationHasImageUrlContent,
|
||||||
|
detachCurrentTurnImagesForTextProvider,
|
||||||
extractCurrentTurnImageUrls,
|
extractCurrentTurnImageUrls,
|
||||||
scrubConversationHistoricalImageAttachments,
|
scrubConversationHistoricalImageAttachments,
|
||||||
} from './chat-image-turn-scope.mjs';
|
} from './chat-image-turn-scope.mjs';
|
||||||
@@ -1004,15 +1005,18 @@ export async function buildVisionPayload({
|
|||||||
.filter((url) => typeof url === 'string' && url.trim());
|
.filter((url) => typeof url === 'string' && url.trim());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userMessage: {
|
userMessage: detachCurrentTurnImagesForTextProvider(
|
||||||
...userMessage,
|
{
|
||||||
content: updatedContent,
|
...userMessage,
|
||||||
metadata: {
|
content: updatedContent,
|
||||||
...(userMessage.metadata ?? {}),
|
metadata: {
|
||||||
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
|
...(userMessage.metadata ?? {}),
|
||||||
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
|
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
|
||||||
|
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
canonicalImageUrls,
|
||||||
|
),
|
||||||
billableImageCount: visionDescription ? 1 : 0,
|
billableImageCount: visionDescription ? 1 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1707,6 +1707,8 @@ test('submitSessionReplyForUser applies the shared Qwen vision preprocessing pat
|
|||||||
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
|
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
|
||||||
assert.match(forwardedText, /Qwen VL 图片描述/);
|
assert.match(forwardedText, /Qwen VL 图片描述/);
|
||||||
assert.match(forwardedText, /一件蓝色产品/);
|
assert.match(forwardedText, /一件蓝色产品/);
|
||||||
|
assert.equal(replyBodies[0]?.user_message?.metadata?.imageUrls, undefined);
|
||||||
|
assert.ok(Array.isArray(replyBodies[0]?.user_message?.metadata?.archivedImageUrls));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+26
-10
@@ -1088,6 +1088,12 @@ export function isWechatPageDataTask(text) {
|
|||||||
return isPageDataIntent(text) || isPageDataDevIntent(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) {
|
export function isRecoverableWechatAgentSessionError(message) {
|
||||||
const normalized = String(message ?? '').trim();
|
const normalized = String(message ?? '').trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
@@ -1095,9 +1101,7 @@ export function isRecoverableWechatAgentSessionError(message) {
|
|||||||
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
|
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
|
||||||
if (/403|404|not found|无权访问/i.test(normalized)) return true;
|
if (/403|404|not found|无权访问/i.test(normalized)) return true;
|
||||||
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
|
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
|
||||||
if (/historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(normalized)) {
|
if (isWechatHistoricalImageSessionError(normalized)) return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
||||||
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
|
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
|
||||||
return false;
|
return false;
|
||||||
@@ -1712,7 +1716,13 @@ export function createWechatMpService({
|
|||||||
const attachRecentMediaForFollowup = (openid, intent, mediaAnalysisEnabled) => {
|
const attachRecentMediaForFollowup = (openid, intent, mediaAnalysisEnabled) => {
|
||||||
if (!mediaAnalysisEnabled || intent?.msgType !== 'text' || intent?.media?.publicUrl) return;
|
if (!mediaAnalysisEnabled || intent?.msgType !== 'text' || intent?.media?.publicUrl) return;
|
||||||
const text = String(intent?.agentText ?? '');
|
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 key = String(openid ?? '').trim();
|
||||||
const recent = recentMediaByOpenid.get(key);
|
const recent = recentMediaByOpenid.get(key);
|
||||||
if (
|
if (
|
||||||
@@ -3099,8 +3109,13 @@ export function createWechatMpService({
|
|||||||
const mayBeStaleSession =
|
const mayBeStaleSession =
|
||||||
sessionId
|
sessionId
|
||||||
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
|
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
|
||||||
|
const historicalImageError = isWechatHistoricalImageSessionError(message);
|
||||||
if (mayBeStaleSession) {
|
if (mayBeStaleSession) {
|
||||||
if (sessionPageContinuation && isWechatPageContinuationRepairableError(message)) {
|
if (
|
||||||
|
sessionPageContinuation
|
||||||
|
&& !historicalImageError
|
||||||
|
&& isWechatPageContinuationRepairableError(message)
|
||||||
|
) {
|
||||||
const text = buildPagePublishFailureText();
|
const text = buildPagePublishFailureText();
|
||||||
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||||
logger.warn?.('WeChat MP page continuation repair route clear failed:', clearErr);
|
logger.warn?.('WeChat MP page continuation repair route clear failed:', clearErr);
|
||||||
@@ -3116,7 +3131,7 @@ export function createWechatMpService({
|
|||||||
repairError.wechatAgentSessionId = sessionId;
|
repairError.wechatAgentSessionId = sessionId;
|
||||||
throw repairError;
|
throw repairError;
|
||||||
}
|
}
|
||||||
if (sessionPageContinuation) {
|
if (sessionPageContinuation && !historicalImageError) {
|
||||||
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||||
logger.warn?.('WeChat MP contextual follow-up route clear failed:', 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 });
|
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
||||||
const retryId = crypto.randomUUID();
|
const retryId = crypto.randomUUID();
|
||||||
const retryStartedAt = Date.now();
|
const retryStartedAt = Date.now();
|
||||||
|
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
|
||||||
const retryPrompt =
|
const retryPrompt =
|
||||||
wechatIntent.kind === 'page.generate'
|
wechatIntent.kind === 'page.generate'
|
||||||
? buildPageGenerateAgentPrompt(intent, {
|
? buildPageGenerateAgentPrompt(intent, {
|
||||||
wantsDocx: wechatIntent.wantsDocx,
|
wantsDocx: wechatIntent.wantsDocx,
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
preferImmediateContext: sessionPageContinuation,
|
preferImmediateContext: retryPageContinuation,
|
||||||
carriedSessionContent,
|
carriedSessionContent: retryPageContinuation ? carriedSessionContent : '',
|
||||||
})
|
})
|
||||||
: buildWechatAgentPrompt(intent, {
|
: buildWechatAgentPrompt(intent, {
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
preferSessionPageContinuation: sessionPageContinuation,
|
preferSessionPageContinuation: retryPageContinuation,
|
||||||
});
|
});
|
||||||
const reply = await executeSessionReply(
|
const reply = await executeSessionReply(
|
||||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||||
@@ -3171,7 +3187,7 @@ export function createWechatMpService({
|
|||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
sessionId,
|
sessionId,
|
||||||
userMessage,
|
userMessage,
|
||||||
preserveAgentPrompt: sessionPageContinuation,
|
preserveAgentPrompt: retryPageContinuation,
|
||||||
}),
|
}),
|
||||||
submitReply: submitSessionReply
|
submitReply: submitSessionReply
|
||||||
? ({ requestId: replyRequestId, userMessage }) =>
|
? ({ requestId: replyRequestId, userMessage }) =>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
findRecoverableWechatAgentErrorInReply,
|
findRecoverableWechatAgentErrorInReply,
|
||||||
isRecoverableWechatAgentSessionError,
|
isRecoverableWechatAgentSessionError,
|
||||||
isWechatAgentApiErrorText,
|
isWechatAgentApiErrorText,
|
||||||
|
isWechatHistoricalImageSessionError,
|
||||||
sanitizeWechatAgentOutboundText,
|
sanitizeWechatAgentOutboundText,
|
||||||
loadWechatMpConfig,
|
loadWechatMpConfig,
|
||||||
maybeAttachPublishedHtmlLink,
|
maybeAttachPublishedHtmlLink,
|
||||||
@@ -385,6 +386,13 @@ test('WeChat session page continuation covers retry, edit, and poem edits', () =
|
|||||||
shouldDeliverWechatHtmlArtifacts(poemEdit, { agentText: '把诗里第三段改长一点' }),
|
shouldDeliverWechatHtmlArtifacts(poemEdit, { agentText: '把诗里第三段改长一点' }),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
isWechatSessionPageContinuation(
|
||||||
|
classifyWechatIntent({ msgType: 'text', agentText: '解读详细报告,做成页面' }),
|
||||||
|
'解读详细报告,做成页面',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WeChat immediate-context page skips fresh thumbnail requirement', () => {
|
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'),
|
isRecoverableWechatAgentSessionError('historical_image_session_update_unsupported:405'),
|
||||||
true,
|
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);
|
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, '新会话已恢复,可以继续。');
|
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', () => {
|
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
|
||||||
const toolCallsError =
|
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).";
|
"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');
|
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 () => {
|
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
|
|||||||
Reference in New Issue
Block a user