fix(apps): close the three gaps the review flagged as previously missed

**The task-augmented inbound branch bypassed the routing.** A server→client
`elicitation/create` carrying `params.task` answers immediately with a
`CreateTaskResult` and settles the TASK when the user answers, so it cannot
ride `enqueuePendingElicitation` — and it was therefore never offered to an
app, even with a valid `_meta.ui.resourceUri`. It now makes the same attempt,
completing the task with the app's result and falling back to the native queue
otherwise; an abort fails the task rather than reopening it natively. The two
task-settling closures are extracted so both answer routes settle it
identically.

**The web client advertised the capability without a sandbox.**
`useInitialConfig` leaves `sandboxUrl` undefined while `/api/config` is in
flight and when the sandbox controller could not start, but the renderer was
supplied unconditionally — so the client claimed it could host an app in
exactly the window where it could not. The option is now gated on a resolved
sandbox URL: such a connection behaves like the CLI/TUI, native queue and no
claim made.

**Concurrent modals each owned the keyboard.** Every open `Modal.Root`
installed its own focus trap, Escape handler and overlay, so the traps fought
and one Escape could dismiss more than one pending request. Only the topmost
owns them now; the rest stay mounted, keeping their bridges and handshakes,
but inert to the keyboard.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 22:05:15 -04:00
commit d90c51a04c

View file

@ -94,11 +94,24 @@ class ElicitTransport implements Transport {
id: number,
params: Record<string, unknown>,
timeoutMs = 2000,
): Promise<JSONRPCMessage> {
return this.sendRequest(id, "elicitation/create", params, timeoutMs);
}
/**
* Deliver any server→client request and resolve with the client's reply.
* NOT named `send` — that is the Transport method the client calls.
*/
sendRequest(
id: number,
method: string,
params?: Record<string, unknown>,
timeoutMs = 2000,
): Promise<JSONRPCMessage> {
const reply = new Promise<JSONRPCMessage>((resolve) => {
this.waiters.set(id, resolve);
});
this.deliver({ jsonrpc: "2.0", id, method: "elicitation/create", params });
this.deliver({ jsonrpc: "2.0", id, method, ...(params && { params }) });
let timer: ReturnType<typeof setTimeout>;
return Promise.race([
reply,
@ -125,6 +138,17 @@ function appParams(overrides: Record<string, unknown> = {}) {
};
}
/** The receiver tasks the client reports over `tasks/list`, by status. */
async function taskStatuses(
transport: ElicitTransport,
id: number,
): Promise<string[]> {
const reply = (await transport.sendRequest(id, "tasks/list")) as {
result?: { tasks?: { status?: string }[] };
};
return (reply.result?.tasks ?? []).map((t) => t.status ?? "");
}
async function connectClient(options: {
transport: ElicitTransport;
appElicitation?: AppElicitationRenderer;
@ -405,6 +429,78 @@ describe("app-rendered elicitation routing (#1854)", () => {
});
});
describe("task-augmented inbound requests", () => {
/**
* A `params.task` elicitation answers the server immediately with a
* `CreateTaskResult` and settles the TASK when the user answers, so it
* cannot ride `enqueuePendingElicitation`. It is still an
* `elicitation/create`, so the app-rendering contract applies to it too.
*/
async function connectWithTasks(
transport: ElicitTransport,
renderer?: AppElicitationRenderer,
) {
const client = new InspectorClient(
{ type: "stdio", command: "noop", args: [] },
{
environment: { transport: () => ({ transport }) },
elicit: { form: true },
receiverTasks: true,
...(renderer && { appElicitation: renderer }),
},
);
await client.connect();
return client;
}
const taskParams = () => appParams({ task: { ttl: 60_000 } });
it("renders the app and completes the task with its result", async () => {
const transport = new ElicitTransport();
const seen: AppElicitationRequest[] = [];
const client = await connectWithTasks(transport, async (request) => {
seen.push(request);
return { action: "accept", content: { choice: "option-a" } };
});
// The immediate response is the task handle, not the answer.
const reply = await transport.elicit(50, taskParams());
expect(reply).toMatchObject({
id: 50,
result: { task: { taskId: expect.any(String) } },
});
await vi.waitFor(() => expect(seen).toHaveLength(1));
expect(seen[0].resourceUri).toBe(APP_URI);
// The app answered, so the native queue never opened and the task
// completed on its own — read back the way a server reads it, over
// `tasks/list`.
expect(client.getPendingElicitations()).toHaveLength(0);
await vi.waitFor(async () =>
expect(await taskStatuses(transport, 60)).toContain("completed"),
);
await client.disconnect();
});
it("falls back to the native queue when the app cannot answer", async () => {
const transport = new ElicitTransport();
const client = await connectWithTasks(transport, async () => {
throw new Error("sandbox unavailable");
});
await transport.elicit(51, taskParams());
await vi.waitFor(() =>
expect(client.getPendingElicitations()).toHaveLength(1),
);
await client
.getPendingElicitations()[0]
.respond({ action: "accept", content: { choice: "native" } });
await vi.waitFor(async () =>
expect(await taskStatuses(transport, 61)).toContain("completed"),
);
await client.disconnect();
});
});
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