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:
parent
08fc20a22d
commit
7c77c2b15f
2 changed files with 286 additions and 38 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue