fix(tasks): tie a receiver task's answer collection to its cancellation

`tasks/cancel` marked the task cancelled and rejected its payload, but left
whatever was collecting the answer running — the native pending-request entry,
and now an app-rendered elicitation's renderer and bridge. A modal therefore
outlived the task it belonged to, and an answer arriving afterwards re-settled
it, overwriting `cancelled` with `completed`: a task the server had been told
it cancelled.

Each receiver task now owns an `AbortController`, aborted by `tasks/cancel`
and by session teardown. The task-augmented elicitation branch passes its
signal to the app attempt and wires it to the native queue entry, so both
answer routes are torn down the same way. Both settle helpers return early on
a terminal status, so a late answer through either route cannot resurrect the
task.

The overwrite predates the app path — a native answer after a cancel did the
same thing — so the guard fixes both.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 22:29:18 -04:00
commit d97c9bd391

View file

@ -482,6 +482,41 @@ describe("app-rendered elicitation routing (#1854)", () => {
await client.disconnect();
});
it("tears the app down on tasks/cancel and keeps the task cancelled", async () => {
// `tasks/cancel` is the server changing its mind. Without an abort the
// modal stayed live, and a later answer overwrote `cancelled` with
// `completed` — a task the server was told it had cancelled.
const transport = new ElicitTransport();
let aborted = false;
let settle: ((r: ElicitResult) => void) | undefined;
const client = await connectWithTasks(
transport,
(request) =>
new Promise<ElicitResult>((resolve) => {
settle = resolve;
request.signal.addEventListener("abort", () => (aborted = true));
}),
);
const created = (await transport.elicit(52, taskParams())) as {
result?: { task?: { taskId?: string } };
};
const taskId = created.result?.task?.taskId;
expect(taskId).toBeDefined();
await vi.waitFor(() => expect(settle).toBeDefined());
await transport.sendRequest(53, "tasks/cancel", { taskId });
expect(aborted).toBe(true);
expect(await taskStatuses(transport, 54)).toContain("cancelled");
// An answer that lands after the cancel must not resurrect the task.
settle?.({ action: "accept", content: { choice: "too-late" } });
await vi.waitFor(async () =>
expect(await taskStatuses(transport, 55)).toEqual(["cancelled"]),
);
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 () => {