From 491a62aed6e2c7574cdbf4c83446d0e444de8365 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 28 Jul 2026 15:27:23 +0800 Subject: [PATCH] fix: require write_file for WeChat immediate-context page requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip fresh thumbnail forcing and retry in-session when load_skill completes without HTML落盘, so「把刚才的诗做成页面」can deliver the prior poem as a static page. Co-authored-by: Cursor --- wechat-mp.mjs | 292 +++++++++++++++++-------------- wechat-mp.test.mjs | 26 ++- wechat/prompts/page-generate.mjs | 18 +- 3 files changed, 204 insertions(+), 132 deletions(-) diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 529b409..eab7270 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -40,6 +40,7 @@ import { import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { buildPageGenerateAgentPrompt, + buildImmediateContextPageRepairPrompt, buildPagePublishFailureText, } from './wechat/prompts/page-generate.mjs'; import { @@ -2424,18 +2425,19 @@ export function createWechatMpService({ const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0; const resetCandidate = intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; - const imagePolicy = resolveWechatImageGenerationPolicy({ - text: resetCandidate, - isPageGenerate: wechatIntent.kind === 'page.generate', - requireFreshPageThumbnail: config.requireFreshPageThumbnail, - }); - // Page Data delivery owns persistent files, datasets and two publication - // policies. Reusing a conversational route here can make a new request - // inspect/retry unrelated historical pages from that session. const isPageDataRequest = isWechatPageDataTask(resetCandidate); const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent); const immediateContextFollowup = isWechatImmediateContextFollowup(wechatIntent, resetCandidate); + const imagePolicy = resolveWechatImageGenerationPolicy({ + text: resetCandidate, + isPageGenerate: wechatIntent.kind === 'page.generate', + requireFreshPageThumbnail: + config.requireFreshPageThumbnail && !immediateContextFollowup, + }); + // Page Data delivery owns persistent files, datasets and two publication + // policies. Reusing a conversational route here can make a new request + // inspect/retry unrelated historical pages from that session. const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate); let route = await ensureWechatAgentSession({ userId: user.userId, @@ -2486,135 +2488,165 @@ export function createWechatMpService({ preferImmediateContext: immediateContextFollowup, }) : buildWechatAgentPrompt(intent, { imagePolicy }); - const reply = await executeSessionReply( - (pathname, init) => fetchForSession(sessionId, pathname, init), - sessionId, - requestId, - agentPrompt, - buildIntentMetadata(intent, { - mediaAnalysisEnabled, - imagePolicy, - pgRequired: isPageDataRequest, - }), - { - prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({ - userId: user.userId, - sessionId, - userMessage, - preserveAgentPrompt: immediateContextFollowup, + const maxImmediateContextPageAttempts = + wechatIntent.kind === 'page.generate' && immediateContextFollowup ? 2 : 1; + let reply; + let publishArtifacts = []; + let confirmedArtifacts = []; + let verifiedArtifacts = []; + let generatedImages = []; + let linkExistsForRequest = linkExists; + for (let pageAttempt = 1; pageAttempt <= maxImmediateContextPageAttempts; pageAttempt += 1) { + const activeAgentPrompt = + pageAttempt === 1 ? agentPrompt : buildImmediateContextPageRepairPrompt(intent); + const activeRequestId = pageAttempt === 1 ? requestId : crypto.randomUUID(); + reply = await executeSessionReply( + (pathname, init) => fetchForSession(sessionId, pathname, init), + sessionId, + activeRequestId, + activeAgentPrompt, + buildIntentMetadata(intent, { + mediaAnalysisEnabled, + imagePolicy, + pgRequired: isPageDataRequest, }), - submitReply: submitSessionReply - ? ({ requestId: replyRequestId, userMessage }) => - submitSessionReply({ - userId: user.userId, - sessionId, - requestId: replyRequestId, - userMessage, - options: { requireHistoricalImageIsolation: true }, - }) - : null, - timeoutMs: agentReplyTimeoutMs, - }, - ); - const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply)); - if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) { - const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试'); - error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; - throw error; - } - const { - publishedArtifacts, - verifiedArtifacts, - expectedArtifacts, - recentArtifacts, - confirmedArtifacts, - validReplyUrls, - hasValidReplyLink, - } = await resolveHtmlPublishArtifacts({ - reply, - intent, - requestStartedAt, - userId: user.userId, - sessionId, - onPageGenerated, - allowRecentArtifacts: htmlArtifactDeliveryExpected, - htmlDeliveryAuthority, - }); - const linkExistsForRequest = - createPreparedPublicHtmlLinkExists({ - userId: user.userId, - prepared: { - validReplyUrls, - confirmedArtifacts, + { + prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({ + userId: user.userId, + sessionId, + userMessage, + preserveAgentPrompt: immediateContextFollowup, + }), + submitReply: submitSessionReply + ? ({ requestId: replyRequestId, userMessage }) => + submitSessionReply({ + userId: user.userId, + sessionId, + requestId: replyRequestId, + userMessage, + options: { requireHistoricalImageIsolation: true }, + }) + : null, + timeoutMs: agentReplyTimeoutMs, }, - fallback: linkExists, - }); - const hasValidLinkInReply = - hasValidReplyLink || - await hasAnyValidPublishedHtmlLink( - reply?.text, - linkExistsForRequest, - { confirmedArtifacts }, ); - const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); - const suspiciousPublishClaim = - expectedArtifacts.length === 0 && - recentArtifacts.length === 0 && - (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })); - const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({ - reply, - intent, - confirmedArtifacts, - hasValidLinkInReply, - }); - const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); - let publishArtifacts = - htmlArtifactDeliveryExpected || publishedArtifacts.length > 0 - ? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }) - : []; - - if (wechatIntent.kind === 'page.generate') { - const pageOutcome = resolvePageGenerateOutcome({ + generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply)); + if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) { + const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试'); + error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; + throw error; + } + const { + publishedArtifacts, + expectedArtifacts, + recentArtifacts, + confirmedArtifacts: resolvedConfirmedArtifacts, + verifiedArtifacts: resolvedVerifiedArtifacts, + validReplyUrls, + hasValidReplyLink, + } = await resolveHtmlPublishArtifacts({ reply, - confirmedArtifacts, - verifiedArtifacts, - suspiciousPublishClaim, - bareCompletionReply, - htmlGenerationNeedsRetry, - replyHasPublicLinks, + intent, + requestStartedAt, + userId: user.userId, + sessionId, + onPageGenerated, + allowRecentArtifacts: htmlArtifactDeliveryExpected, + htmlDeliveryAuthority, }); - if (pageOutcome.action === 'session_retry') { + confirmedArtifacts = resolvedConfirmedArtifacts; + verifiedArtifacts = resolvedVerifiedArtifacts; + linkExistsForRequest = + createPreparedPublicHtmlLinkExists({ + userId: user.userId, + prepared: { + validReplyUrls, + confirmedArtifacts, + }, + fallback: linkExists, + }); + const hasValidLinkInReply = + hasValidReplyLink || + await hasAnyValidPublishedHtmlLink( + reply?.text, + linkExistsForRequest, + { confirmedArtifacts }, + ); + const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); + const suspiciousPublishClaim = + expectedArtifacts.length === 0 && + recentArtifacts.length === 0 && + (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })); + const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts, + hasValidLinkInReply, + }); + const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); + publishArtifacts = + htmlArtifactDeliveryExpected || publishedArtifacts.length > 0 + ? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }) + : []; + + if (wechatIntent.kind === 'page.generate') { + const pageOutcome = resolvePageGenerateOutcome({ + reply, + confirmedArtifacts, + verifiedArtifacts, + suspiciousPublishClaim, + bareCompletionReply, + htmlGenerationNeedsRetry, + replyHasPublicLinks, + }); + if (pageOutcome.action === 'session_retry') { + throw new Error('stale_session_poisoned_completion'); + } + if (pageOutcome.action === 'fail') { + if ( + pageAttempt < maxImmediateContextPageAttempts && + pageOutcome.reason === 'skill_or_stub' + ) { + logger.warn?.('WeChat MP immediate-context page repair retry:', { + sessionId, + userId: user.userId, + pageAttempt, + }); + continue; + } + const text = pageOutcome.failureText ?? buildPagePublishFailureText(); + try { + await sendCustomerServiceText(inbound.fromUserName, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP page generate failure notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); + } + publishArtifacts = pageOutcome.artifacts ?? publishArtifacts; + break; + } + + if (looksLikeHtmlGenerationIntent(intent?.agentText)) { + if ( + suspiciousPublishClaim || + bareCompletionReply || + (htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0) + ) { + throw new Error('stale_session_poisoned_completion'); + } + if (htmlGenerationNeedsRetry) { + const text = buildHtmlPublishFailureText(); + try { + await sendCustomerServiceText(inbound.fromUserName, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP html publish failure notice failed:', sendErr); + } + throw markWechatUserNotified(new Error(text)); + } + } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error('stale_session_poisoned_completion'); } - if (pageOutcome.action === 'fail') { - const text = pageOutcome.failureText ?? buildPagePublishFailureText(); - try { - await sendCustomerServiceText(inbound.fromUserName, text, user); - } catch (sendErr) { - logger.error?.('WeChat MP page generate failure notice failed:', sendErr); - } - throw markWechatUserNotified(new Error(text)); - } - publishArtifacts = pageOutcome.artifacts ?? publishArtifacts; - } else if (looksLikeHtmlGenerationIntent(intent?.agentText)) { - if ( - suspiciousPublishClaim || - bareCompletionReply || - (htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0) - ) { - throw new Error('stale_session_poisoned_completion'); - } - if (htmlGenerationNeedsRetry) { - const text = buildHtmlPublishFailureText(); - try { - await sendCustomerServiceText(inbound.fromUserName, text, user); - } catch (sendErr) { - logger.error?.('WeChat MP html publish failure notice failed:', sendErr); - } - throw markWechatUserNotified(new Error(text)); - } - } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { - throw new Error('stale_session_poisoned_completion'); + break; } await reviewWechatPageDataArtifacts({ user, diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index c6278df..a93890e 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -29,7 +29,8 @@ import { WECHAT_CUSTOMER_TEXT_MAX_BYTES, } from './wechat-mp.mjs'; import { createChatIntentRouter } from './chat-intent-router.mjs'; -import { buildPageGenerateAgentPrompt } from './wechat/prompts/page-generate.mjs'; +import { buildPageGenerateAgentPrompt, buildImmediateContextPageRepairPrompt } from './wechat/prompts/page-generate.mjs'; +import { resolveWechatImageGenerationPolicy, WECHAT_PAGE_THUMBNAIL_MODE } from './wechat/image-generation-policy.mjs'; import { ensureWechatFreshPageThumbnailsAtWorkspace, prepareWechatHtmlDeliveryAtWorkspace, @@ -355,7 +356,30 @@ test('WeChat immediate-context page guard does not match complete page requests' }); assert.match(guardedPrompt, /即时上下文优先/); assert.match(guardedPrompt, /禁止用长期记忆、历史偏好或旧任务替换主题/); + assert.match(guardedPrompt, /write_file 硬门槛/); + assert.match(guardedPrompt, /禁止只调用 load_skill、todo_write 或 generate_image 就回复/); assert.doesNotMatch(ordinaryPrompt, /即时上下文优先/); + assert.doesNotMatch(ordinaryPrompt, /write_file 硬门槛/); +}); + +test('WeChat immediate-context page skips fresh thumbnail requirement', () => { + const policy = resolveWechatImageGenerationPolicy({ + text: '把刚才的诗做成页面', + isPageGenerate: true, + requireFreshPageThumbnail: false, + }); + assert.equal(policy.pageThumbnailMode, WECHAT_PAGE_THUMBNAIL_MODE.AUTO); + const prompt = buildPageGenerateAgentPrompt( + { agentText: '把刚才的诗做成页面' }, + { preferImmediateContext: true, imagePolicy: policy }, + ); + assert.doesNotMatch(prompt, /服务号页面新缩略图硬要求/); +}); + +test('WeChat immediate-context page repair prompt requires write_file', () => { + const repairPrompt = buildImmediateContextPageRepairPrompt({ agentText: '把刚才的诗做成页面' }); + assert.match(repairPrompt, /未 write_file 落盘 HTML/); + assert.match(repairPrompt, /禁止再次只 generate_image/); }); test('WeChat immediate-context page retains an unlinked route with adjacent snapshot', async () => { diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs index 9329c2c..1cc01d7 100644 --- a/wechat/prompts/page-generate.mjs +++ b/wechat/prompts/page-generate.mjs @@ -32,8 +32,11 @@ export function buildPageGenerateAgentPrompt( const immediateContextBlock = preferImmediateContext ? [ '【即时上下文优先】当前需求是对本会话紧邻内容的续作。', - '“这个/刚才/做成页面”等指代只能解析为当前会话最近一轮内容,禁止用长期记忆、历史偏好或旧任务替换主题。', + '“这个/刚才/做成页面”等指代只能解析为当前会话最近一条 assistant 回复中的完整正文(如诗全文、文章全文),禁止用长期记忆、历史偏好或旧任务替换主题。', '如果当前会话没有可辨识的紧邻内容,必须请用户补充主题,禁止猜测并生成其他页面。', + '【write_file 硬门槛】必须先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 把紧邻正文完整写入 `public/*.html` 后才能结束。', + '禁止只调用 load_skill、todo_write 或 generate_image 就回复;没有 write_file 落盘 HTML 视为未完成。', + '缩略图/配图可选:优先完成 HTML 落盘与 mindspace-cover;没有 hero 图时 cover 可用 CSS 渐变或 omit,不要卡在 generate_image。', '', ].join('\n') : ''; @@ -84,6 +87,19 @@ export function buildPageGenerateAgentPrompt( .join('\n'); } +export function buildImmediateContextPageRepairPrompt(intent) { + const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim(); + return [ + '【即时上下文页面补写】上一轮页面生成未完成:已 load_skill 但未 write_file 落盘 HTML。', + '请立即读取本会话最近一条 assistant 回复中的完整正文,将其排版为完整静态页。', + '步骤:确认 static-page-publish 已加载 → 用 write_file 写入 public/*.html(含 mindspace-cover、platform-brand 页脚)→ 确认落盘后再回复唯一正式链接。', + '禁止再次只 generate_image 或只回复文字;本轮必须完成 write_file。', + topic ? `用户原话:${topic}` : '', + ] + .filter(Boolean) + .join('\n'); +} + export function buildPagePublishFailureText({ missingSharePreview = false, missingPlatformBrand = false,