diff --git a/docs/QA-NOTES.md b/docs/QA-NOTES.md index 5411061..0601347 100644 --- a/docs/QA-NOTES.md +++ b/docs/QA-NOTES.md @@ -10,6 +10,13 @@ | Identical regions and strings could stack | Running the same `circle_cards` input twice created `region-4` and `region-5`. | Reject matching card-set/label groups and matching directional relation edges with the existing proposal ID. | The local workbench returned `That proposed group already exists` and `That proposed precedes connection already exists`; WIRE then reported that neither duplicate created anything new. | | Mobile Desk opened past its header | Chat `scrollIntoView` also scrolled the outer desk. | Scroll only the chat element and place suggestions before the audit block. | At 390×844, desk `scrollTop` remained `0`, chat stayed at its latest message, and page width remained 390px with no horizontal overflow. | +## 2026-08-31 · External-agent demo gate + +| Finding | Reproduction | Resolution | Verification | +| --- | --- | --- | --- | +| A board mutation invalidated the external agent's remaining WebMCP tool handles | On production, call `circle_cards` and then `propose_connection` through one fetched WebMCP catalog. The group succeeded, but the connection failed because the tool handle had become stale. | Register one stable delegating action facade. React can refresh the underlying handlers as board state changes without aborting and re-registering the browser-facing tools. | On the fixed local build, one fetched catalog completed `circle_cards` → `propose_connection` → `inspect_board`; both proposals appeared. After a human accepted a proposal, that same catalog could still inspect the updated board. | +| Mobile review flow remained contained after the fix | Open the fixed build at 390×844, enter the case, and switch from Board to Desk with pending suggestions. | No change required. | The page remained exactly 390px wide, the terminal header stayed visible, and the proposal approval controls remained in the Desk flow. | + ## Remaining pre-video checks - Deploy this branch and repeat the resident flow against the canonical HTTPS URL. diff --git a/src/App.tsx b/src/App.tsx index 15b17fb..ae3a293 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,7 +71,7 @@ import type { EvidenceThread, RelationKind, } from "./types"; -import { registerWebMCPTools, type RegisteredTools, type WebMCPActions } from "./webmcp/registerTools"; +import { createDelegatingWebMCPActions, registerWebMCPTools, type RegisteredTools, type WebMCPActions } from "./webmcp/registerTools"; const ENTERED_KEY = "conspiracy-entered-v3"; const PREVIOUS_ENTERED_KEY = "loose-thread-entered-v2"; @@ -548,7 +548,7 @@ export default function App() { if (open) setInspectorId(card.id); }, [viewport.zoom, writeCase]); - const actions = useMemo(() => ({ + const actionImplementations = useMemo(() => ({ getCase: () => cloneCase(caseRef.current), getSelectedIds: () => [...selectedRef.current], getCases: () => library.cases.map((item) => ({ id: item.id!, title: item.title, subtitle: item.subtitle, cardCount: item.cards.length, active: item.id === library.activeCaseId })), @@ -689,6 +689,13 @@ export default function App() { }, }), [commitCase, focusCard, library.activeCaseId, library.cases, visibleWorldCenter]); + const actionImplementationsRef = useRef(actionImplementations); + actionImplementationsRef.current = actionImplementations; + const actions = useMemo( + () => createDelegatingWebMCPActions(() => actionImplementationsRef.current), + [], + ); + useEffect(() => { let disposed = false; let registration: RegisteredTools | undefined; diff --git a/src/webmcp/registerTools.test.ts b/src/webmcp/registerTools.test.ts index 322a960..9e46ca6 100644 --- a/src/webmcp/registerTools.test.ts +++ b/src/webmcp/registerTools.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cloneCase, DEFAULT_CASE } from "../data/defaultCase"; import { auditBoard } from "../lib/board"; import type { BoardMutationResult, EvidenceThread } from "../types"; -import { createWebMCPTools, registerWebMCPTools, type WebMCPActions } from "./registerTools"; +import { createDelegatingWebMCPActions, createWebMCPTools, registerWebMCPTools, type WebMCPActions } from "./registerTools"; const originalDocument = globalThis.document; @@ -97,6 +97,27 @@ describe("WebMCP registration", () => { await expect(registerWebMCPTools(actions)).resolves.toMatchObject({ supported: false, state: "error", registeredCount: 2, error: expect.stringContaining("bridge refused tool") }); }); + it("keeps registered definitions live while their action implementations refresh", async () => { + const registered: WebMCPToolDefinition[] = []; + const registerTool = vi.fn(async (tool: WebMCPToolDefinition) => { registered.push(tool); }); + Object.defineProperty(globalThis, "document", { configurable: true, value: { modelContext: { registerTool, getTools: vi.fn() } } }); + + let current = actionMock(); + const stableActions = createDelegatingWebMCPActions(() => current); + const result = await registerWebMCPTools(stableActions); + const circle = registered.find((tool) => tool.name === "circle_cards")!; + await circle.execute({ cardIds: ["station-ledger", "violet-glove"], label: "First pass" }, { signal: new AbortController().signal }); + expect(current.circleCards).toHaveBeenCalledTimes(1); + + current = actionMock(); + const refreshedPropose = current.proposeThread; + const connect = registered.find((tool) => tool.name === "propose_connection")!; + await connect.execute({ fromCardId: "station-ledger", toCardId: "violet-glove", relation: "supports", rationale: "Same timestamp", confidence: 75 }, { signal: new AbortController().signal }); + + expect(refreshedPropose).toHaveBeenCalledTimes(1); + expect(registerTool).toHaveBeenCalledTimes(result.names.length); + }); + it("aborts an in-flight lifecycle before a replacement registration starts", async () => { const activeNames = new Set(); const registerTool = vi.fn(async (tool: WebMCPToolDefinition, options?: { signal?: AbortSignal }) => { diff --git a/src/webmcp/registerTools.ts b/src/webmcp/registerTools.ts index bd42331..50dc152 100644 --- a/src/webmcp/registerTools.ts +++ b/src/webmcp/registerTools.ts @@ -32,6 +32,29 @@ export interface WebMCPActions { undo: () => BoardMutationResult; } +export function createDelegatingWebMCPActions(current: () => WebMCPActions): WebMCPActions { + return { + getCase: () => current().getCase(), + getSelectedIds: () => current().getSelectedIds(), + getCases: () => current().getCases(), + createCase: (input) => current().createCase(input), + updateCase: (caseId, patch) => current().updateCase(caseId, patch), + switchCase: (caseId) => current().switchCase(caseId), + addCard: (input) => current().addCard(input), + populateCase: (caseId, input) => current().populateCase(caseId, input), + updateCard: (cardId, patch) => current().updateCard(cardId, patch), + moveCard: (cardId, xWorld, yWorld) => current().moveCard(cardId, xWorld, yWorld), + focusCard: (cardId) => current().focusCard(cardId), + removeCard: (cardId) => current().removeCard(cardId), + proposeThread: (input) => current().proposeThread(input), + circleCards: (input) => current().circleCards(input), + resolveProposal: (proposalId, decision) => current().resolveProposal(proposalId, decision), + getTrash: () => current().getTrash(), + restoreTrash: (trashId) => current().restoreTrash(trashId), + undo: () => current().undo(), + }; +} + export interface RegisteredTools { supported: boolean; state: "live" | "preview" | "error";