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
93 changes: 93 additions & 0 deletions src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] });
Expand Down
43 changes: 43 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof AppEventMap> =
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<K extends keyof AppEventMap>(
event: K,
handler: ((params: AppEventMap[K]) => void) | undefined,
): void {
if (handler) this._assertHandlerTiming(event);
super.setEventHandler(event, handler);
}

override addEventListener<K extends keyof AppEventMap>(
event: K,
handler: (params: AppEventMap[K]) => void,
): void {
this._assertHandlerTiming(event);
super.addEventListener(event, handler);
}

protected override onEventDispatch<K extends keyof AppEventMap>(
event: K,
params: AppEventMap[K],
Expand Down
Loading