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:
+64
-3
@@ -255,11 +255,13 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad
|
||||
if (!hasScopedAssistantUpdate || !assistant) {
|
||||
throw new Error('本轮未收到可发送的新回复,请稍后重试');
|
||||
}
|
||||
return {
|
||||
const reply = {
|
||||
text: messageVisibleText(assistant),
|
||||
tokenState: event.token_state ?? null,
|
||||
messages,
|
||||
};
|
||||
assertWechatAgentReplyIsSendable(reply);
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -960,6 +962,65 @@ export function isRecoverableWechatAgentSessionError(message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectWechatAgentReplyVisibleTexts(reply) {
|
||||
const texts = [];
|
||||
const seen = new Set();
|
||||
const append = (text) => {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
texts.push(normalized);
|
||||
};
|
||||
append(reply?.text);
|
||||
for (const message of reply?.messages ?? []) {
|
||||
if (message?.role !== 'assistant') continue;
|
||||
append(messageVisibleText(message));
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
export function findRecoverableWechatAgentErrorInReply(reply) {
|
||||
for (const text of collectWechatAgentReplyVisibleTexts(reply)) {
|
||||
if (isRecoverableWechatAgentSessionError(text)) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function assertWechatAgentReplyIsSendable(reply) {
|
||||
const recoverable = findRecoverableWechatAgentErrorInReply(reply);
|
||||
if (recoverable) throw new Error(recoverable);
|
||||
}
|
||||
|
||||
export function isWechatAgentApiErrorText(message) {
|
||||
const normalized = String(message ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
if (isRecoverableWechatAgentSessionError(normalized)) return true;
|
||||
if (/Ran into this error:/i.test(normalized)) return true;
|
||||
return /Request failed:\s*(Bad request|Internal Server Error)/i.test(normalized);
|
||||
}
|
||||
|
||||
export function sanitizeWechatAgentOutboundText(text) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) return normalized;
|
||||
const stripped = normalized
|
||||
.replace(/Ran into this error:[\s\S]*?(?=\n\n|$)/gi, '')
|
||||
.replace(/Request failed:\s*Bad request \(400\):[\s\S]*?(?=\n\n|$)/gi, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
if (!stripped || isWechatAgentApiErrorText(stripped)) {
|
||||
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function formatWechatAgentFailureMessage(err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
|
||||
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
|
||||
}
|
||||
return `这次转发到专属 Agent 失败了:${message.slice(0, 200)}`;
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const num = Number(value);
|
||||
@@ -1571,7 +1632,7 @@ export function createWechatMpService({
|
||||
user = null,
|
||||
{ verifiedHtmlUrls = [], linkExistsForRequest = linkExists } = {},
|
||||
) => {
|
||||
const formatted = formatWechatOutboundText(content, user);
|
||||
const formatted = formatWechatOutboundText(sanitizeWechatAgentOutboundText(content), user);
|
||||
const verifiedUrlSet = new Set(
|
||||
verifiedHtmlUrls
|
||||
.map((url) => String(url ?? '').trim())
|
||||
@@ -2504,7 +2565,7 @@ export function createWechatMpService({
|
||||
logger.error?.('WeChat MP background reply failed:', err);
|
||||
return sendCustomerServiceText(
|
||||
inbound.fromUserName,
|
||||
`这次转发到专属 Agent 失败了:${err instanceof Error ? err.message : String(err)}`,
|
||||
formatWechatAgentFailureMessage(err),
|
||||
boundUser,
|
||||
).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user