fix(agent-readiness): WebMCP readiness wait + teardown on destroy (#3316)

Addresses three findings on PR #3361.

P1 — startup race. Early registration is required for scanner probes,
but a tool invoked during the window between register and Phase-4 UI
init threw "Search modal is not initialised yet." Both scanners and
agents that probe-and-invoke hit this. Bindings now await a uiReady
promise that resolves after searchManager.init and countryIntel.init.
A 10s timeout keeps a broken init from hanging the caller. After
readiness, a still-null target is a real failure and still throws.

Mechanics: App constructor builds uiReady as a Promise with its
resolve stored on the instance; Phase-4 end calls resolveUiReady;
waitForUiReady races uiReady against a timeout; both bindings await it.

P2 — AbortController was returned and dropped. registerWebMcpTools
returns a controller so callers can unregister on teardown, but App
discarded it. Stored on App now and aborted in destroy, so test
harnesses and SPA re-inits don't accumulate stale registrations.

P2 — test coverage. Added assertions for: bindings await
waitForUiReady before accessing state; resolveUiReady fires after
countryIntel.init; waitForUiReady uses Promise.race with a timeout;
destroy aborts the stored controller. Kept silent-success guard
assertions so bindings still throw when state is absent post-readiness.

Tests: 16 webmcp, 6682 full suite, all green.
This commit is contained in:
Elie Habib
2026-04-24 07:59:05 +04:00
parent 937a7cb3e2
commit f3bbd21706
3 changed files with 116 additions and 18 deletions

View File

@@ -113,6 +113,18 @@ export class App {
private modules: { destroy(): void }[] = [];
private unsubAiFlow: (() => void) | null = null;
private unsubFreeTier: (() => void) | null = null;
// Resolves once Phase-4 UI modules (searchManager, countryIntel) have
// initialised so WebMCP bindings can await readiness before touching
// the nullable UI targets. Avoids the startup race where an agent
// discovers a tool via early registerTool and invokes it before the
// target panel exists.
private uiReady!: Promise<void>;
private resolveUiReady!: () => void;
// Returned by registerWebMcpTools when running in a registerTool-capable
// browser — aborting it unregisters every tool. destroy() triggers it
// so that test harnesses / same-document re-inits don't accumulate
// duplicate registrations.
private webMcpController: AbortController | null = null;
private visiblePanelPrimed = new Set<string>();
private visiblePanelPrimeRaf: number | null = null;
private bootstrapHydrationState: BootstrapHydrationState = getBootstrapHydrationState();
@@ -418,6 +430,10 @@ export class App {
const el = document.getElementById(containerId);
if (!el) throw new Error(`Container ${containerId} not found`);
this.uiReady = new Promise<void>((resolve) => {
this.resolveUiReady = resolve;
});
const PANEL_ORDER_KEY = 'panel-order';
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
@@ -808,20 +824,24 @@ export class App {
// WebMCP — register synchronously before any init awaits so agent
// scanners (isitagentready.com, in-browser agents) find the tools on
// their first probe. No-op in browsers without navigator.modelContext.
// Bindings close over lazy refs; when a tool fires later and its UI
// target is still null they throw, and the invocation-logging shim in
// webmcp.ts surfaces that as isError:true instead of a silent success.
registerWebMcpTools({
// Bindings await `this.uiReady` (resolves after Phase-4 UI init) so
// a tool invoked during the startup window waits for the target
// panel to exist instead of throwing. A 10s timeout keeps a genuinely
// broken state from hanging the caller. Store the returned controller
// so destroy() can unregister every tool on teardown.
this.webMcpController = registerWebMcpTools({
openCountryBriefByCode: async (code, country) => {
await this.waitForUiReady();
if (!this.state.countryBriefPage) {
throw new Error('Country brief panel is not initialised yet');
throw new Error('Country brief panel is not initialised');
}
await this.countryIntel.openCountryBriefByCode(code, country);
},
resolveCountryName: (code) => CountryIntelManager.resolveCountryName(code),
openSearch: () => {
openSearch: async () => {
await this.waitForUiReady();
if (!this.state.searchModal) {
throw new Error('Search modal is not initialised yet');
throw new Error('Search modal is not initialised');
}
this.state.searchModal.open();
},
@@ -1056,6 +1076,8 @@ export class App {
this.searchManager.init();
this.eventHandlers.setupMapLayerHandlers();
this.countryIntel.init();
// Unblock any WebMCP tool invocations that arrived during startup.
this.resolveUiReady();
// Phase 5: Event listeners + URL sync
this.eventHandlers.init();
@@ -1208,6 +1230,31 @@ export class App {
this.cachedModeBannerEl = null;
this.state.map?.destroy();
disconnectAisStream();
// Unregister every WebMCP tool so a same-document re-init (tests,
// HMR, SPA harness) doesn't leave the browser with stale bindings
// pointing at a disposed App.
this.webMcpController?.abort();
this.webMcpController = null;
}
// Waits for Phase-4 UI modules (searchManager + countryIntel) to finish
// initialising. WebMCP bindings call this before touching nullable UI
// state so a tool invoked during startup waits rather than throwing;
// the timeout guards against a genuinely broken init path hanging the
// agent forever.
private async waitForUiReady(timeoutMs = 10_000): Promise<void> {
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`UI did not initialise within ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
await Promise.race([this.uiReady, timeout]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}
private handleDeepLinks(): void {

View File

@@ -55,7 +55,11 @@ interface NavigatorWithWebMcp extends Navigator {
export interface WebMcpAppBindings {
openCountryBriefByCode(code: string, country: string): Promise<void>;
resolveCountryName(code: string): string;
openSearch(): void;
// Returns a Promise because implementations may await a readiness signal
// (e.g. waiting for the search modal to exist during startup) before
// dispatching. Tool executes must `await` it so rejections surface to
// withInvocationLogging's catch path.
openSearch(): void | Promise<void>;
}
const ISO2 = /^[A-Z]{2}$/;
@@ -118,7 +122,7 @@ export function buildWebMcpTools(app: WebMcpAppBindings): WebMcpTool[] {
additionalProperties: false,
},
execute: withInvocationLogging('openSearch', async () => {
app.openSearch();
await app.openSearch();
return textResult('Opened search palette.');
}),
},

View File

@@ -87,11 +87,14 @@ describe('webmcp.ts: tool behaviour (source-level invariants)', () => {
});
});
// App.ts wiring — guards against silent-success bugs where a binding
// forwards to a nullable UI target whose no-op the tool then falsely
// reports as success. Bindings MUST throw when the target is absent
// so withInvocationLogging's catch path can return isError:true.
describe('webmcp App.ts binding: guard against silent success', () => {
// App.ts wiring — guards against two classes of bug:
// (1) Silent success when a binding forwards to a nullable UI target.
// (2) Startup race when a tool is invoked during the window between
// early registration (needed for scanners) and Phase-4 UI init.
// Bindings await a readiness signal before touching UI state and fall
// through to a throw if the signal never resolves; withInvocationLogging
// converts that throw into isError:true.
describe('webmcp App.ts binding: readiness + teardown', () => {
const appSrc = readFileSync(resolve(ROOT, 'src/App.ts'), 'utf-8');
const bindingBlock = appSrc.match(
/registerWebMcpTools\(\{[\s\S]+?\}\);/,
@@ -128,17 +131,61 @@ describe('webmcp App.ts binding: guard against silent success', () => {
);
});
it('openSearch binding throws when searchModal is absent', () => {
it('both bindings await the UI-readiness signal before touching state', () => {
// Before-fix regression: openSearch threw immediately on first
// invocation during startup. Both bindings must wait for Phase-4
// UI init to complete, then check the state, then dispatch.
assert.match(
bindingBlock[0],
/openSearch:[\s\S]+?await this\.waitForUiReady\(\)[\s\S]+?this\.state\.searchModal/,
'openSearch must await waitForUiReady() before accessing searchModal',
);
assert.match(
bindingBlock[0],
/openCountryBriefByCode:[\s\S]+?await this\.waitForUiReady\(\)[\s\S]+?this\.state\.countryBriefPage/,
'openCountryBriefByCode must await waitForUiReady() before accessing countryBriefPage',
);
});
it('bindings still throw (not silently succeed) when state is absent after readiness', () => {
// The silent-success guard from PR #3356 review must survive the
// readiness refactor. After awaiting readiness, a missing target is
// a real failure — throw so withInvocationLogging returns isError.
assert.match(
bindingBlock[0],
/openSearch:[\s\S]+?if \(!this\.state\.searchModal\)[\s\S]+?throw new Error/,
);
});
it('openCountryBriefByCode binding throws when countryBriefPage is absent', () => {
assert.match(
bindingBlock[0],
/openCountryBriefByCode:[\s\S]+?if \(!this\.state\.countryBriefPage\)[\s\S]+?throw new Error/,
);
});
it('uiReady is resolved after Phase-4 UI modules initialise', () => {
// waitForUiReady() hangs forever if nothing ever resolves uiReady.
// The resolve must live right after countryIntel.init() so that all
// Phase-4 modules are ready by the time waiters unblock.
assert.match(
appSrc,
/this\.countryIntel\.init\(\);[\s\S]{0,200}this\.resolveUiReady\(\)/,
'resolveUiReady() must fire after countryIntel.init() in Phase 4',
);
});
it('waitForUiReady enforces a timeout so a broken init cannot hang the agent', () => {
assert.match(
appSrc,
/private async waitForUiReady\(timeoutMs = [\d_]+\)[\s\S]+?Promise\.race\(\[this\.uiReady/,
);
});
it('destroy() aborts the WebMCP controller so re-inits do not duplicate registrations', () => {
const destroyBody = appSrc.match(/public destroy\(\): void \{([\s\S]+?)\n \}/);
assert.ok(destroyBody, 'could not locate destroy() body');
assert.match(
destroyBody[1],
/this\.webMcpController\?\.abort\(\)/,
'destroy() must abort the stored WebMCP AbortController',
);
});
});