fix: require write_file for WeChat immediate-context page requests
Memind CI / Test, build, and release guards (push) Failing after 12m9s

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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-28 15:27:23 +08:00
parent e1ad094f93
commit 491a62aed6
3 changed files with 204 additions and 132 deletions
+162 -130
View File
@@ -40,6 +40,7 @@ import {
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
import { import {
buildPageGenerateAgentPrompt, buildPageGenerateAgentPrompt,
buildImmediateContextPageRepairPrompt,
buildPagePublishFailureText, buildPagePublishFailureText,
} from './wechat/prompts/page-generate.mjs'; } from './wechat/prompts/page-generate.mjs';
import { import {
@@ -2424,18 +2425,19 @@ export function createWechatMpService({
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0; const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
const resetCandidate = const resetCandidate =
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; 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 isPageDataRequest = isWechatPageDataTask(resetCandidate);
const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent); const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent);
const immediateContextFollowup = const immediateContextFollowup =
isWechatImmediateContextFollowup(wechatIntent, resetCandidate); 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); const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
let route = await ensureWechatAgentSession({ let route = await ensureWechatAgentSession({
userId: user.userId, userId: user.userId,
@@ -2486,135 +2488,165 @@ export function createWechatMpService({
preferImmediateContext: immediateContextFollowup, preferImmediateContext: immediateContextFollowup,
}) })
: buildWechatAgentPrompt(intent, { imagePolicy }); : buildWechatAgentPrompt(intent, { imagePolicy });
const reply = await executeSessionReply( const maxImmediateContextPageAttempts =
(pathname, init) => fetchForSession(sessionId, pathname, init), wechatIntent.kind === 'page.generate' && immediateContextFollowup ? 2 : 1;
sessionId, let reply;
requestId, let publishArtifacts = [];
agentPrompt, let confirmedArtifacts = [];
buildIntentMetadata(intent, { let verifiedArtifacts = [];
mediaAnalysisEnabled, let generatedImages = [];
imagePolicy, let linkExistsForRequest = linkExists;
pgRequired: isPageDataRequest, for (let pageAttempt = 1; pageAttempt <= maxImmediateContextPageAttempts; pageAttempt += 1) {
}), const activeAgentPrompt =
{ pageAttempt === 1 ? agentPrompt : buildImmediateContextPageRepairPrompt(intent);
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({ const activeRequestId = pageAttempt === 1 ? requestId : crypto.randomUUID();
userId: user.userId, reply = await executeSessionReply(
sessionId, (pathname, init) => fetchForSession(sessionId, pathname, init),
userMessage, sessionId,
preserveAgentPrompt: immediateContextFollowup, activeRequestId,
activeAgentPrompt,
buildIntentMetadata(intent, {
mediaAnalysisEnabled,
imagePolicy,
pgRequired: isPageDataRequest,
}), }),
submitReply: submitSessionReply {
? ({ requestId: replyRequestId, userMessage }) => prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
submitSessionReply({ userId: user.userId,
userId: user.userId, sessionId,
sessionId, userMessage,
requestId: replyRequestId, preserveAgentPrompt: immediateContextFollowup,
userMessage, }),
options: { requireHistoricalImageIsolation: true }, submitReply: submitSessionReply
}) ? ({ requestId: replyRequestId, userMessage }) =>
: null, submitSessionReply({
timeoutMs: agentReplyTimeoutMs, userId: user.userId,
}, sessionId,
); requestId: replyRequestId,
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply)); userMessage,
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) { options: { requireHistoricalImageIsolation: true },
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试'); })
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; : null,
throw error; timeoutMs: agentReplyTimeoutMs,
}
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,
}, },
fallback: linkExists,
});
const hasValidLinkInReply =
hasValidReplyLink ||
await hasAnyValidPublishedHtmlLink(
reply?.text,
linkExistsForRequest,
{ confirmedArtifacts },
); );
const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
const suspiciousPublishClaim = if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
expectedArtifacts.length === 0 && const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
recentArtifacts.length === 0 && error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })); throw error;
const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({ }
reply, const {
intent, publishedArtifacts,
confirmedArtifacts, expectedArtifacts,
hasValidLinkInReply, recentArtifacts,
}); confirmedArtifacts: resolvedConfirmedArtifacts,
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); verifiedArtifacts: resolvedVerifiedArtifacts,
let publishArtifacts = validReplyUrls,
htmlArtifactDeliveryExpected || publishedArtifacts.length > 0 hasValidReplyLink,
? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }) } = await resolveHtmlPublishArtifacts({
: [];
if (wechatIntent.kind === 'page.generate') {
const pageOutcome = resolvePageGenerateOutcome({
reply, reply,
confirmedArtifacts, intent,
verifiedArtifacts, requestStartedAt,
suspiciousPublishClaim, userId: user.userId,
bareCompletionReply, sessionId,
htmlGenerationNeedsRetry, onPageGenerated,
replyHasPublicLinks, 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'); throw new Error('stale_session_poisoned_completion');
} }
if (pageOutcome.action === 'fail') { break;
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');
} }
await reviewWechatPageDataArtifacts({ await reviewWechatPageDataArtifacts({
user, user,
+25 -1
View File
@@ -29,7 +29,8 @@ import {
WECHAT_CUSTOMER_TEXT_MAX_BYTES, WECHAT_CUSTOMER_TEXT_MAX_BYTES,
} from './wechat-mp.mjs'; } from './wechat-mp.mjs';
import { createChatIntentRouter } from './chat-intent-router.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 { import {
ensureWechatFreshPageThumbnailsAtWorkspace, ensureWechatFreshPageThumbnailsAtWorkspace,
prepareWechatHtmlDeliveryAtWorkspace, 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, /禁止用长期记忆、历史偏好或旧任务替换主题/); assert.match(guardedPrompt, /禁止用长期记忆、历史偏好或旧任务替换主题/);
assert.match(guardedPrompt, /write_file 硬门槛/);
assert.match(guardedPrompt, /禁止只调用 load_skill、todo_write 或 generate_image 就回复/);
assert.doesNotMatch(ordinaryPrompt, /即时上下文优先/); 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 () => { test('WeChat immediate-context page retains an unlinked route with adjacent snapshot', async () => {
+17 -1
View File
@@ -32,8 +32,11 @@ export function buildPageGenerateAgentPrompt(
const immediateContextBlock = preferImmediateContext 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') ].join('\n')
: ''; : '';
@@ -84,6 +87,19 @@ export function buildPageGenerateAgentPrompt(
.join('\n'); .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({ export function buildPagePublishFailureText({
missingSharePreview = false, missingSharePreview = false,
missingPlatformBrand = false, missingPlatformBrand = false,