diff --git a/mindspace-page-data-finish-guard.integration.test.mjs b/mindspace-page-data-finish-guard.integration.test.mjs index e3fe537..eae2592 100644 --- a/mindspace-page-data-finish-guard.integration.test.mjs +++ b/mindspace-page-data-finish-guard.integration.test.mjs @@ -234,7 +234,13 @@ test('integration: finish guard auto-bind clears unbound state for valid survey const before = evaluatePageDataFinishGuard({ publishDir: workspaceRoot, agentText: SURVEY_USER_TEXT, - messages: [], + messages: [{ + createdAt: Date.now(), + content: [{ + type: 'toolRequest', + toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } }, + }], + }], }); assert.equal(before.unboundFiles.length, 1); @@ -264,7 +270,13 @@ test('integration: finish guard auto-bind clears unbound state for valid survey const after = evaluatePageDataFinishGuard({ publishDir: workspaceRoot, agentText: SURVEY_USER_TEXT, - messages: [], + messages: [{ + createdAt: Date.now(), + content: [{ + type: 'toolRequest', + toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } }, + }], + }], }); assert.equal(after.unboundFiles.length, 0); assert.equal(after.htmlIssues.length, 0); @@ -285,7 +297,13 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm sessionId: 'session-h5-page-data', userId: 'user-page-data', publishDir: workspaceRoot, - messages: [], + messages: [{ + createdAt: Date.now(), + content: [{ + type: 'toolRequest', + toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } }, + }], + }], pool: null, h5Root: workspaceRoot, storageRoot: workspaceRoot, @@ -307,3 +325,27 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); + +test('integration: finish guard never scans historical Page Data files without a current HTML write', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-historical-skip-')); + try { + await setupSurveyWorkspace(workspaceRoot); + fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8'); + const result = await maybeRepairPageDataAfterFinish({ + sessionId: 'session-with-history', + userId: 'user-page-data', + publishDir: workspaceRoot, + messages: [], + pool: null, + h5Root: workspaceRoot, + storageRoot: workspaceRoot, + userText: SURVEY_USER_TEXT, + tkmindProxy: { async submitSessionReplyForUser() { throw new Error('must not repair history'); } }, + }); + assert.equal(result.repaired, false); + assert.equal(result.triggered, undefined); + assert.equal(result.relevantFiles.length, 0); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } +}); diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index 678f622..7bd42ad 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -228,7 +228,8 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } = if (item?.type !== 'toolRequest') continue; const toolCall = item.toolCall?.value; const name = String(toolCall?.name ?? '').trim(); - if (!['write_file', 'edit_file', 'write', 'edit'].includes(name)) continue; + const normalizedName = name.split('__').at(-1); + if (!['write_file', 'edit_file', 'write', 'edit'].includes(normalizedName)) continue; const args = toolCall?.arguments ?? {}; const candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/'); if (!candidate.toLowerCase().endsWith('.html')) continue; @@ -248,9 +249,11 @@ export function evaluatePageDataFinishGuard({ const pageDataIntent = isPageDataIntent(agentText); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); - const relevantFiles = pageDataFiles.filter((file) => - pageDataIntent || recentWrites.includes(file.relativePath), - ); + // A session may be reused long after unrelated Page Data files were written. + // Only the HTML paths written in this finish window are safe to bind or + // repair; scanning every historical Page Data file turns one new request + // into a quota-exhausting batch publish attempt. + const relevantFiles = pageDataFiles.filter((file) => recentWrites.includes(file.relativePath)); const htmlIssues = relevantFiles.flatMap((file) => file.evaluation.issues.map((issue) => ({ diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index 5205b92..1df8d1b 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -57,7 +57,7 @@ test('evaluatePageDataFinishGuard detects unbound page data html', () => { const evaluation = evaluatePageDataFinishGuard({ publishDir, agentText: '帮我设计一个调查问卷,要加一个后台', - messages: [], + messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } } }] }], }); assert.equal(evaluation.pageDataIntent, true); assert.equal(evaluation.unboundFiles.length, 1); @@ -74,7 +74,7 @@ test('shouldRetryPageDataCollectReply retries when localStorage fallback exists' fs.writeFileSync(path.join(publishDir, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8'); assert.equal( shouldRetryPageDataCollectReply({ - reply: { text: '问卷已发布', messages: [] }, + reply: { text: '问卷已发布', messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } } }] }] }, intent: { agentText: '帮我设计一个调查问卷,要加一个后台' }, publishDir, }), @@ -134,7 +134,7 @@ test('evaluatePageDataFinishGuard flags html when dataset is not registered', () const evaluation = evaluatePageDataFinishGuard({ publishDir, agentText: '调查问卷和后台', - messages: [], + messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } } }] }], }); assert.equal(evaluation.unboundFiles.length, 1); assert.equal(evaluation.htmlIssues.length, 0); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index fe066ce..d761ac9 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -21,6 +21,7 @@ import { } from './wechat/handlers/sync-replies.mjs'; import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs'; +import { isPageDataIntent } from './chat-skills.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { @@ -973,6 +974,14 @@ function isTopicResetIntent(text) { return isTopicResetText(text); } +export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) { + return ( + wechatIntent?.kind === 'session.reset' || + isTopicResetIntent(resetCandidate) || + isPageDataIntent(resetCandidate) + ); +} + export function isRecoverableWechatAgentSessionError(message) { const normalized = String(message ?? '').trim(); if (!normalized) return false; @@ -1905,7 +1914,11 @@ export function createWechatMpService({ const wechatIntent = classifyWechatIntent(intent); const resetCandidate = intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; - const forceNew = wechatIntent.kind === 'session.reset' || isTopicResetIntent(resetCandidate); + // 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 = isPageDataIntent(resetCandidate); + const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate); let route = await ensureWechatAgentSession({ userId: user.userId, openid: inbound.fromUserName, @@ -2064,6 +2077,24 @@ export function createWechatMpService({ return { sessionId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); + // A Page Data request must fail closed. Retrying a poisoned completion in + // another session while the finish guard is also active can turn one + // request into repairs against historical pages. Drop only this user's + // route so the next explicit request starts cleanly. + if (isPageDataRequest) { + await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => { + logger.warn?.('WeChat MP page data route clear failed:', clearErr); + }); + if (!err?.wechatUserNotified) { + const text = buildPageDataCollectFailureText(); + try { + await sendCustomerServiceText(inbound.fromUserName, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP page data failure notice failed:', sendErr); + } + } + throw markWechatUserNotified(err instanceof Error ? err : new Error(message)); + } const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message); if (mayBeStaleSession) { route = await ensureWechatAgentSession({ diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index f56aeac..e3b6fe0 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -16,6 +16,7 @@ import { loadWechatMpConfig, maybeAttachPublishedHtmlLink, shouldRetryHtmlGenerationReply, + shouldForceNewWechatAgentSession, splitWechatText, verifyWechatMpSignature, verifyWechatMpUrlChallenge, @@ -144,6 +145,15 @@ test('splitWechatText respects WeChat 2048-byte customer service limit', () => { assert.equal(chunks.join(''), longChinese); }); +test('Page Data requests always rotate away from an existing WeChat route', () => { + assert.equal( + shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '做一个问卷,后台可以查看提交数据'), + true, + ); + assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '继续聊德川家康'), false); + assert.equal(shouldForceNewWechatAgentSession({ kind: 'session.reset' }, '继续聊德川家康'), true); +}); + test('buildWechatAgentPrompt requires docx generation before html when Word download is requested', () => { const prompt = buildWechatAgentPrompt({ msgType: 'text',