fix(page-data): scope WeChat survey delivery when tool calls are hidden
Memind CI / Test, build, and release guards (push) Failing after 3s

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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-02 21:41:00 +08:00
parent 762453852f
commit 5ead1abf7f
4 changed files with 142 additions and 25 deletions
+54 -13
View File
@@ -110,18 +110,20 @@ export function rewritePageDataDeliveryLinks(text, artifacts = []) {
function collectPageDataDeliveryRelativePaths(autoBind, relevantRelativePaths = null) { function collectPageDataDeliveryRelativePaths(autoBind, relevantRelativePaths = null) {
const deliveryPaths = new Set(); const deliveryPaths = new Set();
for (const item of autoBind?.bound ?? []) { const scope = Array.isArray(relevantRelativePaths) && relevantRelativePaths.length > 0
if (item?.relativePath) deliveryPaths.add(item.relativePath); ? 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 ?? []) { for (const item of autoBind?.skipped ?? []) {
if (item?.reason === 'already_bound' && item?.relativePath) { if (item?.reason === 'already_bound') include(item.relativePath);
deliveryPaths.add(item.relativePath);
}
} }
if (Array.isArray(relevantRelativePaths)) { if (scope) {
for (const relativePath of relevantRelativePaths) { for (const relativePath of scope) include(relativePath);
if (relativePath) deliveryPaths.add(relativePath);
}
} }
return deliveryPaths; return deliveryPaths;
} }
@@ -339,6 +341,43 @@ export function extractRecentPageDataBindTargets(messages = [], { sinceMs = 0 }
return [...targets]; 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) { function isStructuralPageDataHtmlFile(file) {
return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi); return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi);
} }
@@ -371,9 +410,11 @@ export function evaluatePageDataFinishGuard({
const pgRequired = isPgRequiredByMessage(messages); const pgRequired = isPgRequiredByMessage(messages);
const pageDataIntent = pgRequired || isPageDataIntent(resolvedAgentText); const pageDataIntent = pgRequired || isPageDataIntent(resolvedAgentText);
const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir);
const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); const relevantPaths = new Set(resolvePageDataRequestRelativePaths({
const recentBinds = extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt }); publishDir,
const relevantPaths = new Set([...recentWrites, ...recentBinds]); messages,
requestStartedAt,
}));
// A reused session/workspace can contain many historical Page Data pages. // A reused session/workspace can contain many historical Page Data pages.
// Only files touched or explicitly bound in this request belong to this // Only files touched or explicitly bound in this request belong to this
// delivery. Scanning the whole workspace lets one stale localStorage page // delivery. Scanning the whole workspace lets one stale localStorage page
+60
View File
@@ -15,6 +15,7 @@ import {
extractRecentPageDataHtmlWrites, extractRecentPageDataHtmlWrites,
inferPageDataBindAccessMode, inferPageDataBindAccessMode,
maybeAutoBindPageDataHtmlPages, maybeAutoBindPageDataHtmlPages,
resolvePageDataRequestRelativePaths,
rewritePageDataDeliveryLinks, rewritePageDataDeliveryLinks,
shouldRetryPageDataCollectReply, shouldRetryPageDataCollectReply,
} from './mindspace-page-data-finish-guard.mjs'; } 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 }); 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 });
}
});
+22 -6
View File
@@ -16,6 +16,7 @@ import {
maybeAutoBindPageDataHtmlPages, maybeAutoBindPageDataHtmlPages,
preparePageDataAfterFinish, preparePageDataAfterFinish,
resolvePageDataCollectOutcomeAsync, resolvePageDataCollectOutcomeAsync,
resolvePageDataRequestRelativePaths,
rewritePageDataDeliveryLinks, rewritePageDataDeliveryLinks,
} from './mindspace-page-data-finish-guard.mjs'; } from './mindspace-page-data-finish-guard.mjs';
import { import {
@@ -253,6 +254,11 @@ export function createMindSpacePublicFinishService({
intent?.displayText ?? '', intent?.displayText ?? '',
), ),
}; };
const scopedRelativePaths = resolvePageDataRequestRelativePaths({
publishDir,
messages: normalizedReply.messages,
requestStartedAt,
});
let outcome = let outcome =
await resolvePageDataCollectOutcomeAsyncFn({ await resolvePageDataCollectOutcomeAsyncFn({
reply: normalizedReply, reply: normalizedReply,
@@ -283,9 +289,14 @@ export function createMindSpacePublicFinishService({
pageService.findPageByRelativePath.bind( pageService.findPageByRelativePath.bind(
pageService, pageService,
); );
const relevantRelativePaths = (outcome?.evaluation?.relevantFiles ?? []) const relevantRelativePaths = [
.map((file) => file?.relativePath) ...new Set([
.filter(Boolean); ...scopedRelativePaths,
...(outcome?.evaluation?.relevantFiles ?? [])
.map((file) => file?.relativePath)
.filter(Boolean),
]),
];
autoBind = autoBind =
await maybeAutoBindPageDataHtmlPagesFn({ await maybeAutoBindPageDataHtmlPagesFn({
pool, pool,
@@ -315,9 +326,14 @@ export function createMindSpacePublicFinishService({
let deliveryCheck = null; let deliveryCheck = null;
let rewrittenText = normalizedReply.text; let rewrittenText = normalizedReply.text;
if (outcome?.action === 'send') { if (outcome?.action === 'send') {
const deliveryRelativePaths = (outcome?.evaluation?.relevantFiles ?? []) const deliveryRelativePaths = [
.map((file) => file?.relativePath) ...new Set([
.filter(Boolean); ...scopedRelativePaths,
...(outcome?.evaluation?.relevantFiles ?? [])
.map((file) => file?.relativePath)
.filter(Boolean),
]),
];
deliveryArtifacts = deliveryArtifacts =
buildPageDataDeliveryArtifactsFromBindResultFn( buildPageDataDeliveryArtifactsFromBindResultFn(
autoBind, autoBind,
+6 -6
View File
@@ -345,14 +345,16 @@ export async function executeSessionReply(
const routingId = event.chat_request_id ?? event.request_id; const routingId = event.chat_request_id ?? event.request_id;
if (routingId && routingId !== requestId) continue; 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'); const hasActionRequired = event.message.content?.some((item) => item.type === 'actionRequired');
if (hasActionRequired) { if (hasActionRequired) {
throw new Error('当前回复需要人工确认,公众号通道暂不支持'); throw new Error('当前回复需要人工确认,公众号通道暂不支持');
} }
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
messages = pushMessage(messages, event.message);
requestMessages = pushMessage(requestMessages, 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') { } else if (event.type === 'UpdateConversation') {
// Ignore unscoped snapshots until this request has yielded an assistant update. // Ignore unscoped snapshots until this request has yielded an assistant update.
// Otherwise a stale session snapshot can overwrite the current reply with a // Otherwise a stale session snapshot can overwrite the current reply with a
@@ -1213,9 +1215,7 @@ async function enforcePageDataCollectDelivery({
userId, userId,
reply: { reply: {
text: String(reply?.text ?? ''), text: String(reply?.text ?? ''),
messages: Array.isArray(reply?.messages) messages: replyRequestMessages(reply),
? reply.messages
: [],
}, },
intent: { intent: {
agentText: String(intent?.agentText ?? ''), agentText: String(intent?.agentText ?? ''),