From c7dcc5d0026b3602c6eceadb726f3c74e7726328 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 2 Aug 2026 18:49:42 +0800 Subject: [PATCH] fix(wechat): guard bare page-continuation follow-ups from session loss and memory drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent chat-to-page flows like writing a poem then saying "做成页面吧" from opening a fresh session without carried content, failing with a vague error, or letting Memory page-template preferences replace the immediate topic. Co-authored-by: Cursor --- wechat-mp.mjs | 113 ++++++++++++++++- wechat-mp.test.mjs | 118 +++++++++++++++++- wechat/intent/page-continuation-context.mjs | 128 ++++++++++++++++++++ wechat/page-continuation-context.test.mjs | 78 ++++++++++++ wechat/prompts/page-generate.mjs | 9 ++ 5 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 wechat/intent/page-continuation-context.mjs create mode 100644 wechat/page-continuation-context.test.mjs diff --git a/wechat-mp.mjs b/wechat-mp.mjs index e25cd67..503d33e 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -44,6 +44,13 @@ import { isWechatSessionPageContinuation, isWechatContentEditFollowup, } from './wechat/intent/page-continuation.mjs'; +import { + extractLastSubstantiveAssistantFromConversation, + filterMemoryForImmediatePageContext, + isWechatPageContinuationRepairableError, + shouldFailFastWechatPageContinuation, + WECHAT_PAGE_CONTINUATION_MISSING_SOURCE_TEXT, +} from './wechat/intent/page-continuation-context.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; import { MEMORY_V2_PRODUCT_EVENT_TYPES, @@ -231,6 +238,18 @@ function messageVisibleText(message) { .join(''); } +async function fetchLastSubstantiveAssistantFromSession(fetchForSession, sessionId) { + if (!sessionId || typeof fetchForSession !== 'function') return ''; + try { + const payload = await readJsonResponse( + await fetchForSession(sessionId, `/sessions/${encodeURIComponent(sessionId)}`), + ); + return extractLastSubstantiveAssistantFromConversation(payload?.conversation ?? []); + } catch { + return ''; + } +} + function pushMessage(messages, incoming) { const last = messages[messages.length - 1]; if (last?.id && incoming?.id && last.id === incoming.id) { @@ -2118,6 +2137,7 @@ export function createWechatMpService({ await userAuth.getAgentSessionPolicy(userId); const publishLayout = await userAuth.getUserPublishLayout(userId); const addressName = resolveWechatAddressName(userContext); + let carriedSessionContentForNewSession = ''; const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid); if (existingRoute?.agentSessionId) { const now = Date.now(); @@ -2223,7 +2243,25 @@ export function createWechatMpService({ logger.warn?.('WeChat MP route touch failed:', err); }); } - return { sessionId: existingRoute.agentSessionId, isNewSession: false }; + return { + sessionId: existingRoute.agentSessionId, + isNewSession: false, + carriedSessionContent: '', + }; + } + let carriedSessionContent = ''; + if (retainUnlinkedRoute) { + carriedSessionContent = await fetchLastSubstantiveAssistantFromSession( + fetchForSession, + existingRoute.agentSessionId, + ); + if (carriedSessionContent) { + logger.warn?.('WeChat MP retaining prior assistant content after route tool mismatch:', { + agentSessionId: existingRoute.agentSessionId, + contentLength: carriedSessionContent.length, + }); + carriedSessionContentForNewSession = carriedSessionContent; + } } await userAuth.clearWechatAgentRoute(config.appId, openid); rememberedWechatContexts.delete(existingRoute.agentSessionId); @@ -2304,7 +2342,11 @@ export function createWechatMpService({ openid, agentSessionId: sessionId, }); - return { sessionId, isNewSession: true }; + return { + sessionId, + isNewSession: true, + carriedSessionContent: carriedSessionContentForNewSession, + }; }; const refreshWechatSessionSnapshot = async (sessionId, userId) => { @@ -2452,12 +2494,13 @@ export function createWechatMpService({ ?? messageVisibleText(userMessage), ).trim(); try { - const memoryContext = await chatIntentRouter.resolveAgentMemoryContext({ + const memoryContextRaw = await chatIntentRouter.resolveAgentMemoryContext({ userId, sessionId, text: displayText, forceDeepReasoning: false, }); + let memoryContext = memoryContextRaw; if ( !memoryContext?.injectionEnabled || !Array.isArray(memoryContext.memories) @@ -2465,6 +2508,15 @@ export function createWechatMpService({ ) { return userMessage; } + if (preserveAgentPrompt) { + memoryContext = { + ...memoryContext, + memories: filterMemoryForImmediatePageContext(memoryContext.memories), + }; + if (memoryContext.memories.length === 0) { + return userMessage; + } + } const memoryCount = memoryContext.memories.length; if (mysqlPool?.query) { void recordMemoryV2ProductEvent(mysqlPool, { @@ -2597,6 +2649,7 @@ export function createWechatMpService({ retainUnlinkedRoute: sessionPageContinuation, }); let sessionId = route.sessionId; + let carriedSessionContent = String(route.carriedSessionContent ?? '').trim(); await ensureSessionProvider(sessionId); if (wechatIntent.kind === 'session.reset') { await sendCustomerServiceText( @@ -2607,6 +2660,34 @@ export function createWechatMpService({ return { sessionId }; } await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); + if ( + wechatIntent.kind === 'page.generate' + && sessionPageContinuation + ) { + const sessionAssistantContent = carriedSessionContent + || await fetchLastSubstantiveAssistantFromSession(fetchForSession, sessionId); + if ( + shouldFailFastWechatPageContinuation({ + sessionPageContinuation, + userText: resetCandidate, + carriedSessionContent, + sessionAssistantContent, + }) + ) { + const text = WECHAT_PAGE_CONTINUATION_MISSING_SOURCE_TEXT; + try { + await sendWechatFailureNotice(inbound.fromUserName, text, user, { + sourceMsgId: intent.msgId, + }); + } catch (sendErr) { + logger.error?.('WeChat MP page continuation missing-source notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); + } + if (!carriedSessionContent && sessionAssistantContent) { + carriedSessionContent = sessionAssistantContent; + } + } let finished = false; let progressTimer = null; const skipProgressReply = @@ -2641,6 +2722,7 @@ export function createWechatMpService({ wantsDocx: wechatIntent.wantsDocx, imagePolicy, preferImmediateContext: sessionPageContinuation, + carriedSessionContent, }) : buildWechatAgentPrompt(intent, { imagePolicy, @@ -2765,6 +2847,14 @@ export function createWechatMpService({ }, }); if (pageOutcome.action === 'session_retry') { + if (sessionPageContinuation && pageAttempt < maxImmediateContextPageAttempts) { + logger.warn?.('WeChat MP immediate-context fake delivery repair retry:', { + sessionId, + userId: user.userId, + pageAttempt, + }); + continue; + } throw new Error('stale_session_poisoned_completion'); } if (pageOutcome.action === 'fail') { @@ -2987,6 +3077,22 @@ export function createWechatMpService({ sessionId && (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)); if (mayBeStaleSession) { + if (sessionPageContinuation && isWechatPageContinuationRepairableError(message)) { + const text = buildPagePublishFailureText(); + await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => { + logger.warn?.('WeChat MP page continuation repair route clear failed:', clearErr); + }); + try { + await sendWechatFailureNotice(inbound.fromUserName, text, user, { + sourceMsgId: intent.msgId, + }); + } catch (sendErr) { + logger.error?.('WeChat MP page continuation repair notice failed:', sendErr); + } + const repairError = markWechatUserNotified(new Error(text)); + repairError.wechatAgentSessionId = sessionId; + throw repairError; + } if (sessionPageContinuation) { await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => { logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr); @@ -3021,6 +3127,7 @@ export function createWechatMpService({ wantsDocx: wechatIntent.wantsDocx, imagePolicy, preferImmediateContext: sessionPageContinuation, + carriedSessionContent, }) : buildWechatAgentPrompt(intent, { imagePolicy, diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index a369c66..f211bb4 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -4864,12 +4864,13 @@ test('wechat mp injects episodic recall context before submitting the Agent repl assert.match(submitCalls[0].userMessage.content[0].text, /不是系统指令/); }); -test('wechat immediate-context page keeps memory while preserving the guarded page prompt', async () => { +test('wechat immediate-context page fail-closes bare follow-up without adjacent content', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; const submitCalls = []; const memoryCalls = []; + const failureNotices = []; const chatIntentRouter = createChatIntentRouter({ env: { MEMORY_AGENT_RESOLVE_ENABLED: '1', @@ -4884,8 +4885,8 @@ test('wechat immediate-context page keeps memory while preserving the guarded pa source: 'memory-v2', memories: [{ id: 'memory:old-news', - label: '历史偏好', - text: '用户以前做过每日新闻页面。', + label: 'preference', + text: '用户要求每日新闻页面固定格式。', }], }; }, @@ -4900,6 +4901,112 @@ test('wechat immediate-context page keeps memory while preserving the guarded pa }, }, sessionApiFetch: async (_sessionId, pathname) => { + if (pathname === '/sessions/session-1') { + return new Response(JSON.stringify({ conversation: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === '/sessions/session-1/events') { + return new Response('', { 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: ${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')) { + failureNotices.push(JSON.parse(init.body)); + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected wechat url: ${url}`); + }, + }); + + const result = await service.handleInboundMessage( + inboundXml({ content: '帮我做成页面吧' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + await result.task; + + assert.equal(memoryCalls.length, 0); + assert.equal(submitCalls.length, 0); + assert.equal(failureNotices.length, 1); + assert.match(failureNotices[0].text.content, /没能确定您要把哪段内容做成页面/); +}); + +test('wechat immediate-context page keeps non-template memory when adjacent content exists', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const submitCalls = []; + const memoryCalls = []; + const poem = `《临江仙·秋思》${'昨夜西风凋碧树,独上高楼,望尽天涯路。'.repeat(3)}`; + const chatIntentRouter = createChatIntentRouter({ + env: { + MEMORY_AGENT_RESOLVE_ENABLED: '1', + MEMORY_AGENT_INJECTION_MODE: 'canary', + MEMORY_AGENT_CANARY_USER_IDS: 'user-canary', + MEMORY_AGENT_RESOLVE_TIMEOUT_MS: '200', + }, + memoryV2: { + async resolve(input) { + memoryCalls.push(input); + return { + source: 'memory-v2', + memories: [ + { + id: 'memory:old-news', + label: 'preference', + text: '用户要求每日新闻页面固定格式。', + }, + { + id: 'memory:name', + label: 'fact', + text: '用户名字是唐。', + }, + ], + }; + }, + }, + }); + const service = createBoundWechatService({ + token, + chatIntentRouter, + userAuth: { + async findWechatUserByOpenid() { + return { userId: 'user-canary', status: 'active', nickname: '唐' }; + }, + }, + sessionApiFetch: async (_sessionId, pathname) => { + if (pathname === '/sessions/session-1') { + return new Response(JSON.stringify({ + conversation: [{ + role: 'assistant', + content: [{ type: 'text', text: poem }], + }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } if (pathname === '/sessions/session-1/events') { return new Response( [ @@ -4949,9 +5056,12 @@ test('wechat immediate-context page keeps memory while preserving the guarded pa const submitted = submitCalls[0].userMessage; assert.equal(submitted.metadata.displayText, '帮我做成页面吧'); assert.match(submitted.content[0].text, /\[Memory Context\]/); - assert.match(submitted.content[0].text, /用户以前做过每日新闻页面/); + assert.doesNotMatch(submitted.content[0].text, /每日新闻页面固定格式/); + assert.match(submitted.content[0].text, /用户名字是唐/); assert.match(submitted.content[0].text, /微信服务号 · 页面生成任务/); assert.match(submitted.content[0].text, /即时上下文优先/); + assert.match(submitted.content[0].text, /待排版正文/); + assert.match(submitted.content[0].text, /临江仙/); assert.match(submitted.content[0].text, /用户需求:帮我做成页面吧/); }); diff --git a/wechat/intent/page-continuation-context.mjs b/wechat/intent/page-continuation-context.mjs new file mode 100644 index 0000000..58b02c3 --- /dev/null +++ b/wechat/intent/page-continuation-context.mjs @@ -0,0 +1,128 @@ +/** Guards for WeChat “做成页面” follow-ups that must bind to in-session content. */ + +const PAGE_SOURCE_REFERENCE_PATTERN = + /(?:这个|刚才|上面|前面|上一条|它|这首|这篇|这段|以下内容|以上内容|刚才生成|上面生成)/iu; + +const PAGE_TEMPLATE_MEMORY_PATTERN = + /(?:做成页面|新闻页|新闻页面|固定格式|daily[- ]?news|国际国内热门新闻)/iu; + +const ORCHESTRATION_PREFIX_PATTERN = + /^【Memind 任务编排】[\s\S]*?用户任务:\s*/u; + +const MIN_SUBSTANTIVE_ASSISTANT_CHARS = 48; + +export function hasExplicitPageSourceReference(text) { + return PAGE_SOURCE_REFERENCE_PATTERN.test(String(text ?? '').trim()); +} + +export function isBareImmediatePageCreateRequest(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + if (!/(?:吧|呀|呢|一下)[。.!!\s]*$/u.test(normalized)) return false; + return ( + /^(?:(?:请|麻烦|可以|能不能)\s*)?(?:(?:帮我|帮忙|给我)\s*)?(?:做|弄|改|转|写|排版|生成|制作)(?:成|为)?\s*(?:一个|个)?\s*(?:页面|网页)/iu.test( + normalized, + ) + && !hasExplicitPageSourceReference(normalized) + ); +} + +export function stripOrchestrationFromAssistantText(text) { + return String(text ?? '') + .replace(ORCHESTRATION_PREFIX_PATTERN, '') + .trim(); +} + +export function extractSubstantiveAssistantText(text) { + const normalized = stripOrchestrationFromAssistantText(text); + if (normalized.length < MIN_SUBSTANTIVE_ASSISTANT_CHARS) return ''; + if (/^(?:好的|收到|我先|让我|ok|let me)\b/iu.test(normalized) && normalized.length < 120) { + return ''; + } + return normalized; +} + +export function filterMemoryForImmediatePageContext(memories) { + if (!Array.isArray(memories) || memories.length === 0) return memories; + return memories.filter((entry) => { + const label = String(entry?.label ?? entry?.type ?? '').trim().toLowerCase(); + const body = String(entry?.text ?? entry?.content ?? entry?.body ?? '').trim(); + if (label === 'preference' && PAGE_TEMPLATE_MEMORY_PATTERN.test(body)) return false; + return true; + }); +} + +export function resolveWechatPageContinuationSource({ + userText = '', + carriedSessionContent = '', + sessionAssistantContent = '', +} = {}) { + const carried = extractSubstantiveAssistantText(carriedSessionContent); + if (carried) { + return { ok: true, sourceText: carried, source: 'carried_session' }; + } + const inSession = extractSubstantiveAssistantText(sessionAssistantContent); + if (inSession) { + return { ok: true, sourceText: inSession, source: 'session_assistant' }; + } + const normalized = String(userText ?? '').trim(); + if (hasExplicitPageSourceReference(normalized) && normalized.length >= MIN_SUBSTANTIVE_ASSISTANT_CHARS) { + return { ok: true, sourceText: normalized, source: 'inline_user' }; + } + return { ok: false, sourceText: '', source: 'missing' }; +} + +export function shouldFailFastWechatPageContinuation({ + sessionPageContinuation = false, + userText = '', + carriedSessionContent = '', + sessionAssistantContent = '', +} = {}) { + if (!sessionPageContinuation) return false; + if (!isBareImmediatePageCreateRequest(userText) && hasExplicitPageSourceReference(userText)) { + return !resolveWechatPageContinuationSource({ + userText, + carriedSessionContent, + sessionAssistantContent, + }).ok; + } + if (!isBareImmediatePageCreateRequest(userText)) return false; + return !resolveWechatPageContinuationSource({ + userText, + carriedSessionContent, + sessionAssistantContent, + }).ok; +} + +export const WECHAT_PAGE_CONTINUATION_MISSING_SOURCE_TEXT = + '我没能确定您要把哪段内容做成页面。请一条消息发完整主题,或直接粘贴要做成页面的正文(例如诗词、文章全文)。'; + +export function isWechatPageContinuationRepairableError(message) { + const normalized = String(message ?? '').trim(); + if (!normalized) return false; + return ( + /stale_session_poisoned_completion/i.test(normalized) + || /wechat_page_fresh_thumbnail_required:/i.test(normalized) + || /wechat_agent_incomplete_reply/i.test(normalized) + || /missing_page_artifact/i.test(normalized) + || /poisoned_or_fake_claim/i.test(normalized) + || /skill_or_stub/i.test(normalized) + ); +} + +export function extractLastSubstantiveAssistantFromConversation(messages = []) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== 'assistant') continue; + const text = extractSubstantiveAssistantText( + Array.isArray(message?.content) + ? message.content + .filter((item) => item?.type === 'text' && typeof item.text === 'string') + .map((item) => item.text) + .join('') + : String(message?.text ?? ''), + ); + if (text) return text; + } + return ''; +} diff --git a/wechat/page-continuation-context.test.mjs b/wechat/page-continuation-context.test.mjs new file mode 100644 index 0000000..aa7fda2 --- /dev/null +++ b/wechat/page-continuation-context.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + extractLastSubstantiveAssistantFromConversation, + extractSubstantiveAssistantText, + filterMemoryForImmediatePageContext, + hasExplicitPageSourceReference, + isBareImmediatePageCreateRequest, + isWechatPageContinuationRepairableError, + resolveWechatPageContinuationSource, + shouldFailFastWechatPageContinuation, +} from './intent/page-continuation-context.mjs'; + +test('detects bare vs explicit page continuation phrasing', () => { + assert.equal(hasExplicitPageSourceReference('帮我把这首词做成页面'), true); + assert.equal(hasExplicitPageSourceReference('帮我吧以上内容做成页面'), true); + assert.equal(isBareImmediatePageCreateRequest('帮我做成页面吧'), true); + assert.equal(isBareImmediatePageCreateRequest('帮我把这首词做成页面'), false); + assert.equal(isBareImmediatePageCreateRequest('帮我做个页面'), false); +}); + +test('resolveWechatPageContinuationSource prefers carried session content', () => { + const poem = '《临江仙·秋思》'.padEnd(60, '词'); + const resolved = resolveWechatPageContinuationSource({ + userText: '帮我做成页面吧', + carriedSessionContent: poem, + }); + assert.equal(resolved.ok, true); + assert.equal(resolved.source, 'carried_session'); +}); + +test('shouldFailFastWechatPageContinuation blocks bare follow-up without source', () => { + assert.equal( + shouldFailFastWechatPageContinuation({ + sessionPageContinuation: true, + userText: '帮我做成页面吧', + }), + true, + ); + assert.equal( + shouldFailFastWechatPageContinuation({ + sessionPageContinuation: true, + userText: '帮我做成页面吧', + sessionAssistantContent: '《临江仙·秋思》'.padEnd(60, '。'), + }), + false, + ); +}); + +test('filterMemoryForImmediatePageContext removes page template preferences', () => { + const filtered = filterMemoryForImmediatePageContext([ + { label: 'preference', text: '用户要求每日新闻页面固定格式' }, + { label: 'fact', text: '用户名字是唐' }, + ]); + assert.equal(filtered.length, 1); + assert.match(filtered[0].text, /唐/); +}); + +test('extractLastSubstantiveAssistantFromConversation skips short acknowledgements', () => { + const text = extractLastSubstantiveAssistantFromConversation([ + { role: 'user', content: [{ type: 'text', text: '写首词' }] }, + { role: 'assistant', content: [{ type: 'text', text: '好的,我来写。' }] }, + { + role: 'assistant', + content: [{ + type: 'text', + text: `《临江仙·秋思》${'昨夜西风凋碧树,独上高楼,望尽天涯路。'.repeat(3)}`, + }], + }, + ]); + assert.match(text, /临江仙/); +}); + +test('isWechatPageContinuationRepairableError covers fake delivery markers', () => { + assert.equal(isWechatPageContinuationRepairableError('stale_session_poisoned_completion'), true); + assert.equal(isWechatPageContinuationRepairableError('unknown variant `image_url`'), false); +}); diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs index 2ee5e86..b84bb0c 100644 --- a/wechat/prompts/page-generate.mjs +++ b/wechat/prompts/page-generate.mjs @@ -14,6 +14,7 @@ export function buildPageGenerateAgentPrompt( wantsDocx = false, imagePolicy = null, preferImmediateContext = false, + carriedSessionContent = '', } = {}, ) { const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim(); @@ -41,6 +42,13 @@ export function buildPageGenerateAgentPrompt( '', ].join('\n') : ''; + const carriedSessionBlock = String(carriedSessionContent ?? '').trim() + ? [ + '【待排版正文(来自上一轮会话,必须以此为主题,禁止改用 Memory 或其他任务)】', + String(carriedSessionContent).trim(), + '', + ].join('\n') + : ''; const coverExample = imageBlock ? '<本轮 generate_image 返回的 asset.htmlSrc>' : 'assets/hero.jpg'; @@ -62,6 +70,7 @@ export function buildPageGenerateAgentPrompt( docxBlock, imageBlock, immediateContextBlock, + carriedSessionBlock, pageDataBlock, '步骤(必须全部完成):', pageDataTask