fix(wechat): rotate polluted image sessions before follow-up turns
Memind CI / Test, build, and release guards (push) Successful in 3m28s
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:
+91
-6
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user