diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index e7b32b38..4ea4b499 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -1965,6 +1965,99 @@ describe("App <-> AppBridge integration", () => { errSpy.mockRestore(); }); + describe("late handler registration", () => { + const lateMsg = + /handler registered after connect\(\) completed the ui\/initialize handshake/; + + it("warns when ontoolresult is set after connect() resolves", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + app.ontoolresult = () => {}; + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(lateMsg); + expect(warnSpy.mock.calls[0][0]).toContain('"toolresult"'); + warnSpy.mockRestore(); + }); + + it("warns when addEventListener('toolinput', …) is called after connect()", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + app.addEventListener("toolinput", () => {}); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('"toolinput"'); + warnSpy.mockRestore(); + }); + + it("does not warn for handlers set before connect()", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + app.ontoolinput = () => {}; + app.addEventListener("toolresult", () => {}); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("does not warn for hostcontextchanged (repeating event)", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + app.onhostcontextchanged = () => {}; + app.addEventListener("hostcontextchanged", () => {}); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("does not warn when clearing a handler (set to undefined)", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + app.ontoolinput = () => {}; + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + app.ontoolinput = undefined; + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("throws instead of warning when strict: true", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + const [strictAppT, strictBridgeT] = + InMemoryTransport.createLinkedPair(); + const strictBridge = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + const strictApp = new App( + testAppInfo, + {}, + { autoResize: false, strict: true }, + ); + await strictBridge.connect(strictBridgeT); + await strictApp.connect(strictAppT); + + expect(() => { + strictApp.ontoolresult = () => {}; + }).toThrow(lateMsg); + expect(() => { + strictApp.addEventListener("toolinput", () => {}); + }).toThrow(lateMsg); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + it("AppBridge warns on tools/call from a View that skipped the handshake", async () => { const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); bridge.oncalltool = async () => ({ content: [] }); diff --git a/src/app.ts b/src/app.ts index d8571eed..970b6ce3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -371,6 +371,49 @@ export class App extends ProtocolWithEvents< hostcontextchanged: McpUiHostContextChangedNotificationSchema, }; + /** + * Events the host typically sends once, shortly after the handshake. + * Registering a handler for one of these *after* {@link connect `connect`} + * resolves risks missing the notification entirely. + */ + private static readonly ONE_SHOT_EVENTS: ReadonlySet = + new Set(["toolinput", "toolinputpartial", "toolresult", "toolcancelled"]); + + /** + * Warn (or throw under `strict`) when a one-shot event handler is registered + * after the `ui/initialize` → `ui/notifications/initialized` handshake has + * completed. The host may have already fired the notification by then. + * + * Mirrors {@link _assertInitialized `_assertInitialized`} (the outbound-side guard). + */ + private _assertHandlerTiming(event: keyof AppEventMap): void { + if (!this._initializedSent || !App.ONE_SHOT_EVENTS.has(event)) return; + const msg = + `[ext-apps] "${String(event)}" handler registered after connect() ` + + `completed the ui/initialize handshake. The host may have already sent ` + + `this notification. Register handlers before calling app.connect().`; + if (this.options?.strict) { + throw new Error(msg); + } + console.warn(msg); + } + + protected override setEventHandler( + event: K, + handler: ((params: AppEventMap[K]) => void) | undefined, + ): void { + if (handler) this._assertHandlerTiming(event); + super.setEventHandler(event, handler); + } + + override addEventListener( + event: K, + handler: (params: AppEventMap[K]) => void, + ): void { + this._assertHandlerTiming(event); + super.addEventListener(event, handler); + } + protected override onEventDispatch( event: K, params: AppEventMap[K],