fix: require write_file for WeChat immediate-context page requests
Memind CI / Test, build, and release guards (push) Failing after 12m9s
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:
+162
-130
@@ -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,
|
||||
|
||||
+25
-1
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user