fix: auto-recover WeChat agent sessions poisoned by dangling tool_calls
When a dedicated WeChat session history leaves unmatched tool_calls, the next reply fails with a 400 LLM validation error; detect that pattern and recreate the session before retrying. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
-2
@@ -950,6 +950,15 @@ function isTopicResetIntent(text) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isRecoverableWechatAgentSessionError(message) {
|
||||||
|
const normalized = String(message ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (/stale_session_poisoned_completion/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;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNumber(value) {
|
function normalizeNumber(value) {
|
||||||
if (value === '' || value === null || value === undefined) return null;
|
if (value === '' || value === null || value === undefined) return null;
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
@@ -1932,8 +1941,7 @@ export function createWechatMpService({
|
|||||||
return { sessionId };
|
return { sessionId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const mayBeStaleSession =
|
const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message);
|
||||||
/403|404|not found|无权访问|session|stale_session_poisoned_completion/i.test(message) && sessionId;
|
|
||||||
if (mayBeStaleSession) {
|
if (mayBeStaleSession) {
|
||||||
sessionId = await ensureWechatAgentSession({
|
sessionId = await ensureWechatAgentSession({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
buildWechatTextReply,
|
buildWechatTextReply,
|
||||||
createWechatMpService,
|
createWechatMpService,
|
||||||
guardMissingPublicHtmlLinks,
|
guardMissingPublicHtmlLinks,
|
||||||
|
isRecoverableWechatAgentSessionError,
|
||||||
loadWechatMpConfig,
|
loadWechatMpConfig,
|
||||||
maybeAttachPublishedHtmlLink,
|
maybeAttachPublishedHtmlLink,
|
||||||
shouldRetryHtmlGenerationReply,
|
shouldRetryHtmlGenerationReply,
|
||||||
@@ -2360,6 +2361,183 @@ test('wechat mp service forwards H5 agent text when page generation produced no
|
|||||||
assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
|
assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history', () => {
|
||||||
|
assert.equal(
|
||||||
|
isRecoverableWechatAgentSessionError(
|
||||||
|
"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).",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(isRecoverableWechatAgentSessionError('stale_session_poisoned_completion'), true);
|
||||||
|
assert.equal(isRecoverableWechatAgentSessionError('无权访问该会话'), true);
|
||||||
|
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wechat mp service recreates dedicated session after poisoned tool_calls history', async () => {
|
||||||
|
const token = 'token';
|
||||||
|
const timestamp = '1710000000';
|
||||||
|
const nonce = 'nonce';
|
||||||
|
const wechatCalls = [];
|
||||||
|
let routeCleared = false;
|
||||||
|
let started = false;
|
||||||
|
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-tool-calls-poison-');
|
||||||
|
|
||||||
|
const service = createWechatMpService({
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
appId: 'wx123',
|
||||||
|
appSecret: 'secret',
|
||||||
|
token,
|
||||||
|
publicBaseUrl: 'https://m.tkmind.cn',
|
||||||
|
bindPath: '/auth/wechat/authorize?intent=login',
|
||||||
|
ackText: 'ack',
|
||||||
|
unsupportedText: 'unsupported',
|
||||||
|
unboundTextPrefix: '请先绑定',
|
||||||
|
progressDelayMs: 0,
|
||||||
|
},
|
||||||
|
userAuth: {
|
||||||
|
async findWechatUserByOpenid() {
|
||||||
|
return { userId: 'user-1', status: 'active', nickname: '唐' };
|
||||||
|
},
|
||||||
|
async getWechatAgentRoute() {
|
||||||
|
return routeCleared ? null : { agentSessionId: 'session-1' };
|
||||||
|
},
|
||||||
|
async clearWechatAgentRoute() {
|
||||||
|
routeCleared = true;
|
||||||
|
},
|
||||||
|
async canUseChat() {
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
async resolveWorkingDir() {
|
||||||
|
return workspaceRoot;
|
||||||
|
},
|
||||||
|
async getAgentSessionPolicy() {
|
||||||
|
return {
|
||||||
|
enableContextMemory: false,
|
||||||
|
extensionOverrides: [
|
||||||
|
{
|
||||||
|
type: 'platform',
|
||||||
|
name: 'developer',
|
||||||
|
available_tools: ['write'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unrestricted: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getUserPublishLayout() {
|
||||||
|
return { publishDir: workspaceRoot, displayName: '唐', username: 'wx_ul610et8', slug: 'wx_ul610et8', constraints: null };
|
||||||
|
},
|
||||||
|
async registerAgentSession() {},
|
||||||
|
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||||
|
assert.equal(agentSessionId, 'session-2');
|
||||||
|
},
|
||||||
|
async billSessionUsage() {},
|
||||||
|
async recordWechatMpMessage() {
|
||||||
|
return { inserted: true };
|
||||||
|
},
|
||||||
|
async finishWechatMpMessage() {},
|
||||||
|
async insertWechatMpMessageDetail() {},
|
||||||
|
},
|
||||||
|
apiFetch: async (pathname, init = {}) => {
|
||||||
|
if (pathname === '/agent/start') {
|
||||||
|
started = true;
|
||||||
|
return new Response(JSON.stringify({ id: 'session-2' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected api path: ${pathname}`);
|
||||||
|
},
|
||||||
|
sessionApiFetch: async (sessionId, pathname, init = {}) => {
|
||||||
|
if (pathname === `/sessions/${sessionId}/extensions`) {
|
||||||
|
return new Response(JSON.stringify({ extensions: [{ name: 'developer', available_tools: ['write'] }] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pathname === '/agent/update_working_dir' ||
|
||||||
|
pathname === '/agent/update_session' ||
|
||||||
|
pathname === '/agent/restart' ||
|
||||||
|
pathname === '/agent/add_extension' ||
|
||||||
|
pathname === '/agent/harness_remember' ||
|
||||||
|
pathname === '/agent/harness_bootstrap'
|
||||||
|
) {
|
||||||
|
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
if (pathname === `/sessions/${sessionId}`) {
|
||||||
|
return new Response(JSON.stringify({ working_dir: workspaceRoot }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pathname === `/sessions/${sessionId}/reply`) {
|
||||||
|
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
if (pathname === `/sessions/${sessionId}/events`) {
|
||||||
|
if (sessionId === 'session-1') {
|
||||||
|
return new Response(
|
||||||
|
[
|
||||||
|
'data: {"type":"Error","request_id":"req-tool-poison","error":"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)."}\n\n',
|
||||||
|
].join(''),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
[
|
||||||
|
'data: {"type":"Message","request_id":"req-tool-retry","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已恢复,可以继续对话。"}]}}\n\n',
|
||||||
|
'data: {"type":"Finish","request_id":"req-tool-retry","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
|
||||||
|
].join(''),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
|
||||||
|
},
|
||||||
|
wechatFetch: async (url, init = {}) => {
|
||||||
|
wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
|
||||||
|
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-tool-poison', 'req-tool-retry'];
|
||||||
|
return () => ids.shift() ?? 'req-tool-retry';
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
const result = await service.handleInboundMessage(
|
||||||
|
inboundXml({ content: '帮我查一下今天的待办' }),
|
||||||
|
{
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
signature: signatureFor(token, timestamp, nonce),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 200);
|
||||||
|
await result.task;
|
||||||
|
} finally {
|
||||||
|
crypto.randomUUID = originalRandomUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(routeCleared, true);
|
||||||
|
assert.equal(started, true);
|
||||||
|
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||||
|
const payload = JSON.parse(sendCall[2]);
|
||||||
|
assert.match(payload.text.content, /已恢复,可以继续对话/);
|
||||||
|
});
|
||||||
|
|
||||||
test('wechat mp service retries poisoned publish claims before forwarding H5 retry text', async () => {
|
test('wechat mp service retries poisoned publish claims before forwarding H5 retry text', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
|
|||||||
Reference in New Issue
Block a user