diff --git a/package.json b/package.json index 80f274c..d9a682e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "validate": "node scripts/validate-content.mjs && node scripts/verify-generated-routes.mjs && node scripts/verify-article-canon.mjs", "build": "npm run validate && eleventy && pagefind --site _site && node scripts/write-build-info.mjs", "pretest": "node scripts/prepare-tests.mjs", - "test": "node --test tests/aicontext.test.mjs tests/edcm-mathematics.test.mjs tests/gonol-relationships.test.mjs tests/org-msdmd.test.mjs tests/post-merge-reconciliation.test.mjs tests/llms-build.test.mjs tests/canon-parser.test.mjs tests/canon-integrity.test.mjs tests/textbook-integrity.test.mjs tests/math-rendering.test.mjs tests/narratives.test.mjs tests/offline-project-snapshot.test.mjs tests/repo-coverage.test.mjs tests/research-ledger.test.mjs tests/works-registry.test.mjs tests/site-contract.test.mjs tests/sitrep.test.mjs tests/webmcp.test.mjs tests/mcp-server.test.mjs", + "test": "node --test tests/aicontext.test.mjs tests/edcm-mathematics.test.mjs tests/gonol-relationships.test.mjs tests/org-msdmd.test.mjs tests/post-merge-reconciliation.test.mjs tests/llms-build.test.mjs tests/canon-parser.test.mjs tests/canon-integrity.test.mjs tests/textbook-integrity.test.mjs tests/math-rendering.test.mjs tests/narratives.test.mjs tests/offline-project-snapshot.test.mjs tests/repo-coverage.test.mjs tests/research-ledger.test.mjs tests/works-registry.test.mjs tests/site-contract.test.mjs tests/sitrep.test.mjs tests/webmcp.test.mjs tests/webmcp-repository-output.test.mjs tests/mcp-server.test.mjs", "test:mcp": "node --test tests/mcp-server.test.mjs", "start:mcp": "node server/mcp-server.mjs", "test:generated": "node --test tests/generated-site.test.mjs tests/human-ui-generated.test.mjs tests/textbook-generated.test.mjs tests/math-generated.test.mjs && node tests/links.test.mjs", diff --git a/src/assets/js/webmcp.js b/src/assets/js/webmcp.js index 973ea99..34169ad 100644 --- a/src/assets/js/webmcp.js +++ b/src/assets/js/webmcp.js @@ -4,7 +4,7 @@ import { createSkillRegistry } from './webmcp-registry.js'; // id: interdependency_webmcp_surface // purpose: Register website-owned read-only WebMCP skill tools and bind exact human-selected repository + skill + request into one ephemeral browser-agent handoff. // entrypoint: /webmcp/ -// tests: tests/webmcp.test.mjs +// tests: tests/webmcp.test.mjs; tests/webmcp-repository-output.test.mjs // === END MODULE_BUILD === // === BOUNDARIES === // id: interdependency_webmcp_surface_boundary @@ -22,7 +22,7 @@ import { createSkillRegistry } from './webmcp-registry.js'; // // id: webmcp_repository_context_precedes_agent_work_selection // given: no repository is selected or the selected repository changes -// then: skill controls remain disabled or the prior skill selection is invalidated so agent work is always chosen within the current repository context +// then: skill controls remain disabled or the prior skill selection and visible output are invalidated so agent work is always chosen within the current repository context // class: human_in_loop // // id: webmcp_human_handoff_requires_explicit_send @@ -136,6 +136,7 @@ async function publishHandoffTool(handoff) { annotations: { readOnlyHint: true, untrustedContentHint: true }, execute: async () => jsonResult(currentHandoff || { ready: false, hmmm: 'human handoff was invalidated before invocation' }) }, { signal: controller.signal }); + if (controller.signal.aborted || handoffController !== controller || currentHandoff !== handoff) return false; setText(handoffStatusElement(), `Sent to browser agent context · ${handoff.target_repository.name} → ${handoff.skill.human_title || handoff.skill.name}.`, 'implemented'); return true; } catch (error) { @@ -160,9 +161,15 @@ function bindHumanCatalogue(registry) { const sendButton = document.querySelector('[data-human-handoff-send]'); let selectedName = ''; let freshIntentIsAutomatic = false; + let handoffRevision = 0; filterForm?.addEventListener('submit', event => event.preventDefault()); + const invalidatePublishedHandoff = reason => { + handoffRevision += 1; + clearPublishedHandoff(reason); + }; + const applyFilter = () => { const query = String(filterInput?.value || '').trim().toLowerCase(); let visible = 0; @@ -238,7 +245,7 @@ function bindHumanCatalogue(registry) { if (!card) return false; if (selectedName && selectedName !== name && currentHandoff) { - clearPublishedHandoff('Skill selection changed. Review and press Send again before the agent receives a new handoff.'); + invalidatePublishedHandoff('Skill selection changed. Review and press Send again before the agent receives a new handoff.'); } const previousName = selectedName; @@ -281,10 +288,15 @@ function bindHumanCatalogue(registry) { } repositorySelect?.addEventListener('change', () => { - if (currentHandoff) clearPublishedHandoff('Repository selection changed. Press Send again before the agent receives a new handoff.'); + if (currentHandoff) invalidatePublishedHandoff('Repository selection changed. Press Send again before the agent receives a new handoff.'); const skillWasCleared = clearSelectedSkill(); updateRepositoryDisplay(); const repository = selectedRepository(repositorySelect); + showHumanMessage( + repository + ? `${repository.name} selected. Previous skill or handoff output cleared. Choose how the agent should work in it.` + : 'Repository selection cleared. Previous skill or handoff output cleared.' + ); setSkillStageEnabled(repository); updateSendEnabled(); const url = new URL(globalThis.location.href); @@ -315,7 +327,7 @@ function bindHumanCatalogue(registry) { intentInput?.addEventListener('input', () => { freshIntentIsAutomatic = false; - if (currentHandoff) clearPublishedHandoff('Request text changed. Press Send again before the agent receives the revision.'); + if (currentHandoff) invalidatePublishedHandoff('Request text changed. Press Send again before the agent receives the revision.'); updateSendEnabled(); }); @@ -329,6 +341,7 @@ function bindHumanCatalogue(registry) { return; } + const submissionRevision = ++handoffRevision; const skill = registry.inspectSkill({ name: selectedName }); const handoff = { ready: true, @@ -348,6 +361,9 @@ function bindHumanCatalogue(registry) { }; const published = await publishHandoffTool(handoff); + if (handoffRevision !== submissionRevision) return; + const activeRepository = selectedRepository(repositorySelect); + if (activeRepository?.name !== repository.name || selectedName !== skill.name) return; if (published) { showHumanMessage(`Handoff sent to the browser agent: repository ${repository.name} · skill ${skill.human_title || skill.name}. The agent now holds the exact repository head, required skill set, registry provenance, and your request.`); } else { @@ -469,4 +485,4 @@ export async function registerInterdependencyWebMCP() { registerInterdependencyWebMCP().catch(error => { console.error('Interdependency WebMCP registration failed', error); setText(statusElement(), `WebMCP registration failed: ${error.message}`, 'hmmm'); -}); +}); \ No newline at end of file diff --git a/tests/webmcp-repository-output.test.mjs b/tests/webmcp-repository-output.test.mjs new file mode 100644 index 0000000..d1e270f --- /dev/null +++ b/tests/webmcp-repository-output.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + + +test('repository changes invalidate stale human-visible output', async () => { + const source = await readFile('src/assets/js/webmcp.js', 'utf8'); + const handlerStart = source.indexOf("repositorySelect?.addEventListener('change'"); + const handlerEnd = source.indexOf("document.querySelector('[data-selected-action=\"inspect\"]')", handlerStart); + assert.ok(handlerStart >= 0 && handlerEnd > handlerStart); + + const handler = source.slice(handlerStart, handlerEnd); + assert.match(handler, /clearSelectedSkill\(\)/); + assert.match(handler, /showHumanMessage\(/); + assert.match(handler, /Previous skill or handoff output cleared/); + assert.ok( + handler.indexOf('showHumanMessage(') < handler.indexOf('setSkillStageEnabled(repository)'), + 'stale output must be replaced before the new repository skill stage is presented' + ); +}); + + +test('invalidated async submits cannot overwrite current output or status', async () => { + const source = await readFile('src/assets/js/webmcp.js', 'utf8'); + + assert.match(source, /let handoffRevision = 0;/); + assert.match(source, /const invalidatePublishedHandoff = reason => \{\s*handoffRevision \+= 1;\s*clearPublishedHandoff\(reason\);/s); + assert.match(source, /const submissionRevision = \+\+handoffRevision;/); + assert.match(source, /const published = await publishHandoffTool\(handoff\);\s*if \(handoffRevision !== submissionRevision\) return;/s); + assert.match( + source, + /if \(controller\.signal\.aborted \|\| handoffController !== controller \|\| currentHandoff !== handoff\) return false;/ + ); +});