From 5ead1abf7f1a91f86490100962a2d297d095bff0 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 2 Aug 2026 21:41:00 +0800 Subject: [PATCH] fix(page-data): scope WeChat survey delivery when tool calls are hidden Collect request-scoped tool messages in the WeChat SSE stream, infer this turn Page Data HTML via mtime when messages lack writes, and limit bind and delivery smoke to those paths so historical already_bound pages cannot block new surveys. Co-authored-by: Cursor --- mindspace-page-data-finish-guard.mjs | 67 ++++++++++++++++++----- mindspace-page-data-finish-guard.test.mjs | 60 ++++++++++++++++++++ mindspace-public-finish-service.mjs | 28 ++++++++-- wechat-mp.mjs | 12 ++-- 4 files changed, 142 insertions(+), 25 deletions(-) diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index 6ab9f3e..5ca7bca 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -110,18 +110,20 @@ export function rewritePageDataDeliveryLinks(text, artifacts = []) { function collectPageDataDeliveryRelativePaths(autoBind, relevantRelativePaths = null) { const deliveryPaths = new Set(); - for (const item of autoBind?.bound ?? []) { - if (item?.relativePath) deliveryPaths.add(item.relativePath); - } + const scope = Array.isArray(relevantRelativePaths) && relevantRelativePaths.length > 0 + ? new Set(relevantRelativePaths) + : null; + const include = (relativePath) => { + if (!relativePath) return; + if (scope && !scope.has(relativePath)) return; + deliveryPaths.add(relativePath); + }; + for (const item of autoBind?.bound ?? []) include(item.relativePath); for (const item of autoBind?.skipped ?? []) { - if (item?.reason === 'already_bound' && item?.relativePath) { - deliveryPaths.add(item.relativePath); - } + if (item?.reason === 'already_bound') include(item.relativePath); } - if (Array.isArray(relevantRelativePaths)) { - for (const relativePath of relevantRelativePaths) { - if (relativePath) deliveryPaths.add(relativePath); - } + if (scope) { + for (const relativePath of scope) include(relativePath); } return deliveryPaths; } @@ -339,6 +341,43 @@ export function extractRecentPageDataBindTargets(messages = [], { sinceMs = 0 } return [...targets]; } +const PAGE_DATA_WORKSPACE_MTIME_LOOKBACK_MS = 2000; + +export function resolvePageDataRequestRelativePaths({ + publishDir, + messages = [], + requestStartedAt = 0, +} = {}) { + const relevantPaths = new Set([ + ...extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }), + ...extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt }), + ]); + if (relevantPaths.size > 0 || requestStartedAt <= 0) { + return [...relevantPaths]; + } + const publicDir = path.join(path.resolve(String(publishDir ?? '')), 'public'); + if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) { + return []; + } + for (const name of fs.readdirSync(publicDir)) { + if (!name.toLowerCase().endsWith('.html')) continue; + const relativePath = `public/${name}`; + const absolutePath = path.join(publicDir, name); + let mtimeMs = 0; + try { + mtimeMs = fs.statSync(absolutePath).mtimeMs; + } catch { + continue; + } + if (mtimeMs < requestStartedAt - PAGE_DATA_WORKSPACE_MTIME_LOOKBACK_MS) continue; + const content = fs.readFileSync(absolutePath, 'utf8'); + if (htmlUsesPageDataApi(content)) { + relevantPaths.add(relativePath); + } + } + return [...relevantPaths]; +} + function isStructuralPageDataHtmlFile(file) { return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi); } @@ -371,9 +410,11 @@ export function evaluatePageDataFinishGuard({ const pgRequired = isPgRequiredByMessage(messages); const pageDataIntent = pgRequired || isPageDataIntent(resolvedAgentText); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); - const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); - const recentBinds = extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt }); - const relevantPaths = new Set([...recentWrites, ...recentBinds]); + const relevantPaths = new Set(resolvePageDataRequestRelativePaths({ + publishDir, + messages, + requestStartedAt, + })); // A reused session/workspace can contain many historical Page Data pages. // Only files touched or explicitly bound in this request belong to this // delivery. Scanning the whole workspace lets one stale localStorage page diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index 2b1a9f2..ae6188b 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -15,6 +15,7 @@ import { extractRecentPageDataHtmlWrites, inferPageDataBindAccessMode, maybeAutoBindPageDataHtmlPages, + resolvePageDataRequestRelativePaths, rewritePageDataDeliveryLinks, shouldRetryPageDataCollectReply, } from './mindspace-page-data-finish-guard.mjs'; @@ -381,3 +382,62 @@ test('rewritePageDataDeliveryLinks rewrites publication route urls to MindSpace fs.rmSync(publishDir, { recursive: true, force: true }); } }); + +test('resolvePageDataRequestRelativePaths falls back to recent workspace html when tool calls are missing', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-mtime-')); + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + const requestStartedAt = Date.now(); + const historicalPath = path.join(publishDir, 'public', 'historical-survey.html'); + const freshPath = path.join(publishDir, 'public', 'fresh-survey.html'); + fs.writeFileSync(historicalPath, SURVEY_HTML, 'utf8'); + fs.utimesSync( + historicalPath, + new Date(requestStartedAt - 60_000), + new Date(requestStartedAt - 60_000), + ); + fs.writeFileSync(freshPath, SURVEY_HTML, 'utf8'); + assert.deepEqual( + resolvePageDataRequestRelativePaths({ + publishDir, + messages: [], + requestStartedAt, + }), + ['public/fresh-survey.html'], + ); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('buildPageDataDeliveryArtifactsFromBindResult scopes already_bound pages to this request', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-scope-')); + const previousBase = process.env.H5_PUBLIC_BASE_URL; + process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn'; + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync(path.join(publishDir, 'public', 'current-survey.html'), SURVEY_HTML, 'utf8'); + fs.writeFileSync(path.join(publishDir, 'public', 'historical-survey.html'), SURVEY_HTML, 'utf8'); + const autoBind = { + bound: [], + skipped: [ + { relativePath: 'public/current-survey.html', reason: 'already_bound' }, + { relativePath: 'public/historical-survey.html', reason: 'already_bound' }, + ], + }; + const artifacts = buildPageDataDeliveryArtifactsFromBindResult( + autoBind, + publishDir, + {}, + ['public/current-survey.html'], + ); + assert.deepEqual( + artifacts.map((artifact) => artifact.relativePath), + ['public/current-survey.html'], + ); + } finally { + if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL; + else process.env.H5_PUBLIC_BASE_URL = previousBase; + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); diff --git a/mindspace-public-finish-service.mjs b/mindspace-public-finish-service.mjs index 0f7407c..045b155 100644 --- a/mindspace-public-finish-service.mjs +++ b/mindspace-public-finish-service.mjs @@ -16,6 +16,7 @@ import { maybeAutoBindPageDataHtmlPages, preparePageDataAfterFinish, resolvePageDataCollectOutcomeAsync, + resolvePageDataRequestRelativePaths, rewritePageDataDeliveryLinks, } from './mindspace-page-data-finish-guard.mjs'; import { @@ -253,6 +254,11 @@ export function createMindSpacePublicFinishService({ intent?.displayText ?? '', ), }; + const scopedRelativePaths = resolvePageDataRequestRelativePaths({ + publishDir, + messages: normalizedReply.messages, + requestStartedAt, + }); let outcome = await resolvePageDataCollectOutcomeAsyncFn({ reply: normalizedReply, @@ -283,9 +289,14 @@ export function createMindSpacePublicFinishService({ pageService.findPageByRelativePath.bind( pageService, ); - const relevantRelativePaths = (outcome?.evaluation?.relevantFiles ?? []) - .map((file) => file?.relativePath) - .filter(Boolean); + const relevantRelativePaths = [ + ...new Set([ + ...scopedRelativePaths, + ...(outcome?.evaluation?.relevantFiles ?? []) + .map((file) => file?.relativePath) + .filter(Boolean), + ]), + ]; autoBind = await maybeAutoBindPageDataHtmlPagesFn({ pool, @@ -315,9 +326,14 @@ export function createMindSpacePublicFinishService({ let deliveryCheck = null; let rewrittenText = normalizedReply.text; if (outcome?.action === 'send') { - const deliveryRelativePaths = (outcome?.evaluation?.relevantFiles ?? []) - .map((file) => file?.relativePath) - .filter(Boolean); + const deliveryRelativePaths = [ + ...new Set([ + ...scopedRelativePaths, + ...(outcome?.evaluation?.relevantFiles ?? []) + .map((file) => file?.relativePath) + .filter(Boolean), + ]), + ]; deliveryArtifacts = buildPageDataDeliveryArtifactsFromBindResultFn( autoBind, diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 503d33e..843349b 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -345,14 +345,16 @@ export async function executeSessionReply( const routingId = event.chat_request_id ?? event.request_id; if (routingId && routingId !== requestId) continue; - if (event.type === 'Message' && event.message?.metadata?.userVisible) { + if (event.type === 'Message' && event.message) { const hasActionRequired = event.message.content?.some((item) => item.type === 'actionRequired'); if (hasActionRequired) { throw new Error('当前回复需要人工确认,公众号通道暂不支持'); } - if (event.message.role === 'assistant') hasScopedAssistantUpdate = true; - messages = pushMessage(messages, event.message); requestMessages = pushMessage(requestMessages, event.message); + if (event.message?.metadata?.userVisible) { + if (event.message.role === 'assistant') hasScopedAssistantUpdate = true; + messages = pushMessage(messages, event.message); + } } else if (event.type === 'UpdateConversation') { // Ignore unscoped snapshots until this request has yielded an assistant update. // Otherwise a stale session snapshot can overwrite the current reply with a @@ -1213,9 +1215,7 @@ async function enforcePageDataCollectDelivery({ userId, reply: { text: String(reply?.text ?? ''), - messages: Array.isArray(reply?.messages) - ? reply.messages - : [], + messages: replyRequestMessages(reply), }, intent: { agentText: String(intent?.agentText ?? ''),