diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 8037a79..83a4943 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -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(() => {}); }); diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index e4b5c23..02ce065 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -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';