Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/QA-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -548,7 +548,7 @@ export default function App() {
if (open) setInspectorId(card.id);
}, [viewport.zoom, writeCase]);

const actions = useMemo<WebMCPActions>(() => ({
const actionImplementations = useMemo<WebMCPActions>(() => ({
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 })),
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 22 additions & 1 deletion src/webmcp/registerTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string>();
const registerTool = vi.fn(async (tool: WebMCPToolDefinition, options?: { signal?: AbortSignal }) => {
Expand Down
23 changes: 23 additions & 0 deletions src/webmcp/registerTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down