From 249b0697cbc91c435c6c32dba4c3653382be7d10 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 13 Jul 2026 06:56:27 +0800 Subject: [PATCH] fix(page-data): scope finish guard to current delivery --- ...age-data-finish-guard.integration.test.mjs | 8 ++-- mindspace-page-data-finish-guard.mjs | 40 ++++++++++++---- mindspace-page-data-finish-guard.test.mjs | 48 +++++++++++++++++++ 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/mindspace-page-data-finish-guard.integration.test.mjs b/mindspace-page-data-finish-guard.integration.test.mjs index 598f40c..c27e575 100644 --- a/mindspace-page-data-finish-guard.integration.test.mjs +++ b/mindspace-page-data-finish-guard.integration.test.mjs @@ -326,7 +326,7 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm } }); -test('integration: finish guard repairs historical unbound Page Data html without a current write', async () => { +test('integration: finish guard does not repair historical Page Data html without a current write', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-historical-skip-')); try { await setupSurveyWorkspace(workspaceRoot); @@ -342,9 +342,9 @@ test('integration: finish guard repairs historical unbound Page Data html withou userText: '确认发布', tkmindProxy: { async submitSessionReplyForUser() { throw new Error('must not repair history'); } }, }); - assert.equal(result.structuralPageData, true); - assert.equal(result.relevantFiles.length, 1); - assert.equal(result.needsRepair, true); + assert.equal(result.structuralPageData, false); + assert.equal(result.relevantFiles.length, 0); + assert.equal(result.needsRepair, false); assert.equal(result.triggered, undefined); } 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 83292ec..edd78f3 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -229,7 +229,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; @@ -240,6 +241,26 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } = return [...targets]; } +export function extractRecentPageDataBindTargets(messages = [], { sinceMs = 0 } = {}) { + const targets = new Set(); + for (const message of Array.isArray(messages) ? messages : []) { + const createdAt = Number(message?.created ?? message?.createdAt ?? 0); + if (sinceMs > 0 && createdAt > 0 && createdAt < sinceMs) continue; + for (const item of message?.content ?? []) { + if (item?.type !== 'toolRequest') continue; + const toolCall = item.toolCall?.value; + const name = String(toolCall?.name ?? '').trim().split('__').at(-1); + if (name !== 'private_data_bind_workspace_page') continue; + const candidate = String(toolCall?.arguments?.relativePath ?? '') + .trim() + .replace(/\\/g, '/'); + if (!candidate.toLowerCase().endsWith('.html')) continue; + targets.add(candidate.startsWith('public/') ? candidate : `public/${path.posix.basename(candidate)}`); + } + } + return [...targets]; +} + function isStructuralPageDataHtmlFile(file) { return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi); } @@ -272,10 +293,14 @@ export function evaluatePageDataFinishGuard({ const pageDataIntent = isPageDataIntent(resolvedAgentText); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); - const structuralFiles = pageDataFiles.filter(isStructuralPageDataHtmlFile); - const relevantFiles = pageDataFiles.filter( - (file) => isStructuralPageDataHtmlFile(file) || recentWrites.includes(file.relativePath), - ); + const recentBinds = extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt }); + const relevantPaths = new Set([...recentWrites, ...recentBinds]); + // 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 + // incorrectly block every later, correctly bound survey. + const relevantFiles = pageDataFiles.filter((file) => relevantPaths.has(file.relativePath)); + const structuralFiles = relevantFiles.filter(isStructuralPageDataHtmlFile); const htmlIssues = relevantFiles.flatMap((file) => file.evaluation.issues.map((issue) => ({ @@ -295,11 +320,10 @@ export function evaluatePageDataFinishGuard({ ); const needsRepair = - structuralFiles.length > 0 && + pageDataIntent && (htmlIssues.length > 0 || unboundFiles.length > 0 || - (pageDataIntent && - relevantFiles.length === 0 && + (relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && usedPageDataCollectSkill(messages))); diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index f7a28e9..335b3ee 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -9,12 +9,15 @@ import { collectPageDataDeliveryArtifacts, evaluatePageDataFinishGuard, evaluatePageDataHtmlContent, + extractRecentPageDataBindTargets, + extractRecentPageDataHtmlWrites, inferPageDataBindAccessMode, maybeAutoBindPageDataHtmlPages, rewritePageDataDeliveryLinks, shouldRetryPageDataCollectReply, } from './mindspace-page-data-finish-guard.mjs'; import { writePageAccessPolicy } from './page-data-policy-store.mjs'; +import { createUserDataSpaceService } from './user-data-space-service.mjs'; const SURVEY_HTML = `问卷 @@ -67,6 +70,51 @@ test('evaluatePageDataFinishGuard detects unbound page data html', () => { } }); +test('finish guard ignores unrelated historical broken Page Data html', async () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-history-')); + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync(path.join(publishDir, 'public', 'historical-admin.html'), BAD_SURVEY_HTML, 'utf8'); + fs.writeFileSync(path.join(publishDir, 'public', 'current-survey.html'), SURVEY_HTML, 'utf8'); + const dataSpace = createUserDataSpaceService({ workspaceRoot: publishDir }); + await dataSpace.executeSql('CREATE TABLE diet_survey (id INTEGER PRIMARY KEY AUTOINCREMENT);'); + await dataSpace.upsertDataset({ + name: 'diet_survey', + table: 'diet_survey', + actions: ['read', 'insert'], + columns: { read: ['id'], insert: [] }, + }); + writePageAccessPolicy(publishDir, { + pageId: 'current-page', + ownerUserId: 'user-1', + accessMode: 'public', + datasets: { diet_survey: { insert: true, read: false, columns: { insert: [] } } }, + }); + const evaluation = evaluatePageDataFinishGuard({ + publishDir, + agentText: '调查问卷和后台', + messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { + name: 'sandbox-fs__write_file', + arguments: { path: 'public/current-survey.html' }, + } } }] }], + }); + assert.deepEqual(evaluation.relevantFiles.map((file) => file.relativePath), ['public/current-survey.html']); + assert.deepEqual(evaluation.htmlIssues, []); + assert.deepEqual(evaluation.unboundFiles, []); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('recent Page Data target extraction supports namespaced writes and bind-only delivery', () => { + const messages = [{ content: [ + { type: 'toolRequest', toolCall: { value: { name: 'sandbox-fs__write_file', arguments: { path: 'public/form.html' } } } }, + { type: 'toolRequest', toolCall: { value: { name: 'sandbox-fs__private_data_bind_workspace_page', arguments: { relativePath: 'public/form-admin.html' } } } }, + ] }]; + assert.deepEqual(extractRecentPageDataHtmlWrites(messages), ['public/form.html']); + assert.deepEqual(extractRecentPageDataBindTargets(messages), ['public/form-admin.html']); +}); + test('shouldRetryPageDataCollectReply retries when localStorage fallback exists', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-retry-')); try {