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

@ -215,7 +215,7 @@ describe("appElicitation negotiation (#1854)", () => {
params,
null as unknown as ElicitResult,
),
).toMatch(/non-object/);
).toMatch(/invalid elicitation result/);
});
it("rejects an unknown action", () => {
@ -223,7 +223,19 @@ describe("appElicitation negotiation (#1854)", () => {
validateAppElicitResult(provider, params, {
action: "maybe",
} as unknown as ElicitResult),
).toMatch(/unknown elicitation action/);
).toMatch(/invalid elicitation result/);
});
it("rejects a decline whose content the standard result forbids", () => {
// Checking `action` alone would wave this through: `ElicitResult` permits
// only primitives and string arrays in `content`, so a nested object
// would reach the server as a result it can legitimately reject.
expect(
validateAppElicitResult(provider, params, {
action: "decline",
content: { x: {} },
} as unknown as ElicitResult),
).toMatch(/invalid elicitation result/);
});
it("rejects an accept with no usable content", () => {
@ -237,7 +249,7 @@ describe("appElicitation negotiation (#1854)", () => {
action: "accept",
content: [] as unknown as Record<string, never>,
} as ElicitResult),
).toMatch(/without a content object/);
).toMatch(/invalid elicitation result|without a content object/);
});
it("rejects content that does not match the requested schema", () => {

View file

@ -405,6 +405,38 @@ describe("app-rendered elicitation routing (#1854)", () => {
});
});
describe("request-scoped ids", () => {
it("mints ids that are unique across client instances, not just within one", async () => {
// A host may hold ONE renderer across replacement clients (the web client
// rebuilds its InspectorClient on a settings change). A per-client
// counter alone would give each new client's first request the same id,
// and settling it would resolve the wrong server's request.
const ids: string[] = [];
const renderer: AppElicitationRenderer = async (request) => {
ids.push(request.requestId);
return { action: "cancel" };
};
const first = new ElicitTransport();
const clientA = await connectClient({
transport: first,
appElicitation: renderer,
});
await first.elicit(40, appParams());
await clientA.disconnect();
const second = new ElicitTransport();
const clientB = await connectClient({
transport: second,
appElicitation: renderer,
});
await second.elicit(41, appParams());
await clientB.disconnect();
expect(ids).toHaveLength(2);
expect(ids[0]).not.toBe(ids[1]);
});
});
describe("teardown", () => {
it("aborts a pending app elicitation on disconnect", async () => {
const transport = new ElicitTransport();
@ -427,5 +459,31 @@ describe("app-rendered elicitation routing (#1854)", () => {
// abandoned it, and the connection it belonged to is gone.
expect(client.getPendingElicitations()).toHaveLength(0);
});
it("aborts a pending app elicitation on a mid-session transport close", async () => {
// The `onclose` route reaches teardown only through
// `clearAndAnnouncePendingPeerRequests`, whose emptiness check used to
// look at the native queues alone — so a lone app elicitation survived a
// dropped connection and left its modal on screen.
const transport = new ElicitTransport();
let aborted = false;
const client = await connectClient({
transport,
appElicitation: (request) =>
new Promise<ElicitResult>((_resolve, reject) => {
request.signal.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
}),
});
void transport.elicit(31, appParams()).catch(() => {});
await vi.waitFor(() => expect(aborted).toBe(false));
transport.onclose?.();
await vi.waitFor(() => expect(aborted).toBe(true));
expect(client.getPendingElicitations()).toHaveLength(0);
});
});
});