fix(wechat): detect tool_calls errors in Finish reply and sanitize outbound text

When Goose surfaces session poison errors as assistant text instead of Error events,
assertWechatAgentReplyIsSendable now throws to trigger session recreation. Outbound
sanitization prevents raw API errors from reaching WeChat users.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-04 12:38:00 +08:00
parent f3a55dce29
commit 5ed655b6c3
2 changed files with 276 additions and 3 deletions
+212
View File
@@ -8,7 +8,11 @@ import {
buildWechatTextReply,
createWechatMpService,
guardMissingPublicHtmlLinks,
assertWechatAgentReplyIsSendable,
findRecoverableWechatAgentErrorInReply,
isRecoverableWechatAgentSessionError,
isWechatAgentApiErrorText,
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
shouldRetryHtmlGenerationReply,
@@ -2693,6 +2697,214 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
});
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
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).";
const reply = {
text: '唐,好的,请稍等片刻。',
messages: [
{
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: '唐,好的,请稍等片刻。' }],
},
{
id: 'assistant-2',
role: 'assistant',
content: [{ type: 'text', text: `Ran into this error:\n${toolCallsError}` }],
},
],
};
assert.match(findRecoverableWechatAgentErrorInReply(reply), /tool_calls/);
assert.throws(() => assertWechatAgentReplyIsSendable(reply), /tool_calls/);
});
test('sanitizeWechatAgentOutboundText replaces raw api errors with friendly text', () => {
const raw =
"Ran into this error:\nRequest failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.";
assert.match(sanitizeWechatAgentOutboundText(raw), /切换到新会话/);
assert.equal(isWechatAgentApiErrorText(raw), true);
assert.match(
sanitizeWechatAgentOutboundText('唐,好的。\n\nRan into this error:\nRequest failed: Bad request (400): tool_calls'),
/唐,好的/,
);
assert.doesNotMatch(
sanitizeWechatAgentOutboundText('唐,好的。\n\nRan into this error:\nRequest failed: Bad request (400): tool_calls'),
/Bad request/,
);
});
test('wechat mp service recreates dedicated session when tool_calls error arrives via Finish assistant text', 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-finish-');
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).";
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":"Message","request_id":"req-tool-finish","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"唐,好的,请稍等片刻。"}]}}\n\n',
`data: {"type":"Message","request_id":"req-tool-finish","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"Ran into this error:\\n${toolCallsError.replace(/"/g, '\\"')}"}]}}\n\n`,
'data: {"type":"Finish","request_id":"req-tool-finish","token_state":{"inputTokens":1,"outputTokens":1}}\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-3","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-finish', '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, /已恢复,可以继续对话/);
assert.doesNotMatch(payload.text.content, /Bad request/);
assert.doesNotMatch(payload.text.content, /tool_calls/);
});
test('wechat mp service recreates dedicated session after poisoned tool_calls history', async () => {
const token = 'token';
const timestamp = '1710000000';