fix(apps): address review of app-rendered elicitations

Eight findings from the Copilot review, all accepted.

Ownership across connections was the substantive one, in three parts. A
mid-session transport close reaches teardown only through
`clearAndAnnouncePendingPeerRequests`, whose emptiness check looked at the
native queues alone — so a lone app elicitation survived a dropped connection
with its modal still open; the check now counts the active set too. The web
bridge factory resolves its client at call time, so an entry queued by a
replaced `InspectorClient` could rebuild against the *next* one and answer
through a different server; the host now drops every entry synchronously on
the swap (`AppElicitationController.failAll`) rather than racing core's
awaited teardown. And the request id was only monotonic within one client, so
each replacement client's first request was `app-elicitation-1` — settling
that id could resolve the wrong server's request. It now carries a
per-instance prefix.

Result validation now parses the whole value with `ElicitResultSchema` before
the requested-schema check, instead of inspecting `action` by hand: a
`{ action: "decline", content: { x: {} } }` used to pass, though the standard
result permits only primitives and string arrays in `content`.

The dialog's accessible name now includes the request id and the prompt — two
concurrent elicitations can be for the same app URI, and the previous name
made those indistinguishable, which is what the comment beside it claimed to
prevent.

`observeAppCapabilities` takes a minimal structural `onmessage` contract
(generic over the message type, since a handler typed for a narrower message
is not assignable to one typed for `unknown`), removing the double cast at the
call site.

`smoke:web:elicit` now fails on an uncaught page error after a successful
drive, and captures the async half of that class off the console channel, as
its two sibling smokes do — it could otherwise print OK over a broken bundle.

Adds the tests each fix needs, including the two the review asked for
directly: that `advertiseElicitation` reaches the `AppBridge` constructor, and
that it is absent by default.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 19:43:44 -04:00
commit 7c77c2b15f
2 changed files with 286 additions and 38 deletions

View file

@ -108,6 +108,33 @@ describe("AppElicitationController (#1854)", () => {
expect(controller.getEntries()).toHaveLength(0);
});
it("failAll drops every entry so a renderer cannot outlive its connection", async () => {
// The host calls this when the InspectorClient is replaced: the bridge
// factory resolves its client at call time, so an entry left queued could
// otherwise rebuild against the NEXT connection and answer through a
// different server.
const controller = new AppElicitationController();
const listener = vi.fn();
const first = request(controller, "a");
const second = request(controller, "b");
controller.subscribe(listener);
controller.failAll(new Error("connection replaced"));
await expect(first).rejects.toThrow(/connection replaced/);
await expect(second).rejects.toThrow(/connection replaced/);
expect(controller.getEntries()).toHaveLength(0);
expect(listener).toHaveBeenCalledTimes(1);
});
it("failAll on an empty queue notifies nobody", () => {
const controller = new AppElicitationController();
const listener = vi.fn();
controller.subscribe(listener);
controller.failAll(new Error("nothing to drop"));
expect(listener).not.toHaveBeenCalled();
});
it("stops notifying an unsubscribed listener", () => {
const controller = new AppElicitationController();
const listener = vi.fn();

View file

@ -96,4 +96,20 @@ export class AppElicitationController {
fail(requestId: string, error: Error): void {
this.take(requestId)?.reject(error);
}
/**
* Drop every queued request at once.
*
* For the host's "these entries no longer belong to the live connection"
* moment (a replaced `InspectorClient`): a renderer that outlived its client
* would rebuild against the *next* one and could read or answer through a
* different server.
*/
failAll(error: Error): void {
const dropped = this.entries;
if (dropped.length === 0) return;
this.entries = [];
this.emit();
for (const entry of dropped) entry.reject(error);
}
}