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>
This commit is contained in:
john
2026-08-15 09:06:00 +08:00
parent fbd68a36e9
commit 9f7633be24
2 changed files with 224 additions and 6 deletions
+91 -6
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 };
@@ -1097,9 +1098,14 @@ export function isWechatHistoricalImageSessionError(message) {
export const WECHAT_IMAGE_STORED_ACK_TEXT =
'已收到图片。请直接告诉我接下来要做什么,例如:解读报告并生成页面。';
export function prepareWechatIntentForHistoricalImageRetry(intent) {
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 ?? '').trim();
const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim();
const agentText = String(intent.agentText ?? '').trim();
if (imageUrl && !agentText.includes(imageUrl)) {
intent.agentText = agentText
@@ -2185,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();
@@ -2405,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 {
@@ -2712,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
@@ -3177,7 +3258,11 @@ export function createWechatMpService({
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
if (historicalImageError) {
prepareWechatIntentForHistoricalImageRetry(intent);
prepareWechatIntentForHistoricalImageRetry(intent, {
fallbackImageUrl: resolveWechatRecentMediaPublicUrl(
recentMediaByOpenid.get(String(inbound.fromUserName ?? '').trim()),
),
});
}
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
+133
View File
@@ -16,6 +16,7 @@ import {
isWechatHistoricalImageSessionError,
WECHAT_IMAGE_STORED_ACK_TEXT,
prepareWechatIntentForHistoricalImageRetry,
resolveWechatRecentMediaPublicUrl,
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
@@ -3513,6 +3514,27 @@ test('prepareWechatIntentForHistoricalImageRetry keeps image url in text only',
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';
@@ -5525,6 +5547,117 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
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 groups consecutive images for one follow-up and clears the consumed batch', async () => {
const token = 'token';
const timestamp = '1710000000';