Skip to content

Commit 6930643

Browse files
committed
fix: address Copilot comments
1 parent b7a221e commit 6930643

8 files changed

Lines changed: 203 additions & 21 deletions

File tree

packages/ext-tasks/src/client/execution.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,16 @@ export class TaskExecution<
578578

579579
cancel(signal?: AbortSignal): Promise<void> {
580580
throwIfAborted(signal);
581-
this.cancelPromise ??= this.cancelTask(this.cancellationController.signal);
581+
// The server's cancel ack ends the local lifetime because V2 cancellation
582+
// is cooperative: the server may never reach `cancelled`, so waiting for
583+
// a terminal snapshot could leave result() pending forever.
584+
this.cancelPromise ??= this.cancelTask(
585+
this.cancellationController.signal,
586+
).then(() => {
587+
this.releaseLifecycleListener?.();
588+
this.inputController.abort(this.cancelledError);
589+
this.controller.abort(this.cancelledError);
590+
});
582591
return signal === undefined
583592
? this.cancelPromise
584593
: withAbort(this.cancelPromise, signal);

packages/ext-tasks/src/client/task-lifecycle-races.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest";
33
import {
44
DispatchError,
55
JsonRpcResponseError,
6+
TaskCancelledError,
67
TaskExecutionClosedError,
7-
TaskFailedError,
88
TaskUpdatesAlreadyAcquiredError,
99
toolDeclaration,
1010
withTasks,
@@ -178,8 +178,9 @@ describe("task lifecycle and races", () => {
178178
},
179179
});
180180
await execution.close();
181+
// Cancelled, not closed: the awaited cancel settled the outcome first.
181182
await expect(legacyResult(execution)).rejects.toBeInstanceOf(
182-
TaskExecutionClosedError,
183+
TaskCancelledError,
183184
);
184185
await session.close();
185186
});
@@ -237,11 +238,9 @@ describe("task lifecycle and races", () => {
237238
await new Promise<void>((resolve) => setImmediate(resolve));
238239
expect(unhandled).toEqual([]);
239240
const outcome = await result;
240-
expect(outcome.status).toBe("failed");
241-
if (outcome.status !== "failed")
242-
throw new Error("Expected failed outcome");
243-
expect(outcome.error).toBeInstanceOf(TaskFailedError);
244-
expect(outcome.error.cause).toBeInstanceOf(TaskExecutionClosedError);
241+
// Cancelled, not failed-as-closed: the cancel ack settled the outcome
242+
// before close ran.
243+
expect(outcome.status).toBe("cancelled");
245244
} finally {
246245
process.off("unhandledRejection", onUnhandledRejection);
247246
}
@@ -406,8 +405,10 @@ describe("task lifecycle and races", () => {
406405
else await expect(execution.cancel()).rejects.toThrow("cancel failed");
407406
expect(cancelCalls).toBe(retryable ? 2 : 1);
408407
await execution.close();
408+
// A successful cancel settles the outcome as cancelled; only a
409+
// failed one leaves close to decide it.
409410
await expect(legacyResult(execution)).rejects.toBeInstanceOf(
410-
TaskExecutionClosedError,
411+
retryable ? TaskCancelledError : TaskExecutionClosedError,
411412
);
412413
await session.close();
413414
}),

packages/ext-tasks/src/client/task-protocol-v2.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,11 @@ async function resolveInputRequest<TApplicationContext>(args: {
306306
request,
307307
);
308308
if (acquisition.kind !== "new") {
309+
// Throwing fails the execution because reusing a key with a different
310+
// request is a protocol violation: the client already answered this key,
311+
// and polling on would act on input the application never saw.
309312
if (acquisition.kind === "incompatible")
310-
inputContext.reportError(
311-
new Error(`V2 task input key ${inputKey} was reused incompatibly`),
312-
);
313+
throw new Error(`V2 task input key ${inputKey} was reused incompatibly`);
313314
return undefined;
314315
}
315316

packages/ext-tasks/src/client/v2-input-task.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,73 @@ describe("V2 input and task behavior", () => {
441441
await session.close();
442442
});
443443

444+
it("fails the execution when a committed V2 input key is reused incompatibly", async () => {
445+
const port = new FakePort({ generation: "v2", capabilities: {} });
446+
let getCalls = 0;
447+
port.dispatchHandler = async (request) => {
448+
await Promise.resolve();
449+
const method = expectRecord(request).method;
450+
if (method === "tools/call")
451+
return {
452+
kind: "result",
453+
result: asJson({
454+
resultType: "task",
455+
taskId: "reuse-incompatible",
456+
status: "working",
457+
createdAt: "a",
458+
lastUpdatedAt: "a",
459+
ttlMs: null,
460+
}),
461+
};
462+
if (method === "tasks/get") {
463+
getCalls += 1;
464+
return {
465+
kind: "result",
466+
result: asJson({
467+
resultType: "complete",
468+
taskId: "reuse-incompatible",
469+
status: "input_required",
470+
createdAt: "a",
471+
lastUpdatedAt: String(getCalls),
472+
ttlMs: null,
473+
inputRequests: {
474+
same:
475+
getCalls === 1
476+
? { method: "roots/list" }
477+
: { method: "elicitation/create", params: {} },
478+
},
479+
}),
480+
};
481+
}
482+
if (method === "tasks/update")
483+
return { kind: "result", result: { resultType: "complete" } };
484+
throw new Error(`unexpected method ${formatJson(method)}`);
485+
};
486+
const session = withTasks(port, {
487+
tools: {
488+
currentTool: () =>
489+
toolDeclaration({ name: "x", inputSchema: { type: "object" } }),
490+
},
491+
onInputRequest: async (request) => {
492+
await Promise.resolve();
493+
// `as never`, because the generic signature cannot relate a runtime
494+
// kind branch to TRequest (the suite-wide fake-handler pattern).
495+
return (
496+
request.kind === "roots"
497+
? { roots: [{ uri: "file:///workspace" }] }
498+
: { action: "cancel" }
499+
) as never;
500+
},
501+
});
502+
const execution = await session.callTool("x");
503+
// Key "same" returns with a different request shape — a protocol
504+
// violation that must fail the execution, not keep it polling.
505+
await expect(legacyResult(execution)).rejects.toThrow(
506+
"reused incompatibly",
507+
);
508+
await session.close();
509+
});
510+
444511
it("declines keyed V2 elicitation while withholding sampling and roots", async () => {
445512
const port = new FakePort({ generation: "v2", capabilities: {} });
446513
let getCalls = 0;

packages/ext-tasks/src/core/v2/index.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,4 +902,26 @@ describe("V2 runtime wire contracts", () => {
902902
).toBe(true);
903903
expect(hasTaskServerCapabilityV2({ extensions: {} })).toBe(false);
904904
});
905+
906+
it("merges the tasks capability into existing client capabilities", () => {
907+
const wire = withTaskCapabilityV2({
908+
_meta: {
909+
"io.modelcontextprotocol/clientCapabilities": {
910+
sampling: {},
911+
extensions: { "example.com/other": { enabled: true } },
912+
},
913+
},
914+
});
915+
expect(wire).toEqual({
916+
_meta: {
917+
"io.modelcontextprotocol/clientCapabilities": {
918+
sampling: {},
919+
extensions: {
920+
"example.com/other": { enabled: true },
921+
"io.modelcontextprotocol/tasks": {},
922+
},
923+
},
924+
},
925+
});
926+
});
905927
});

packages/ext-tasks/src/core/v2/integration.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,16 @@ export function withTaskCapabilityV2<
117117
T extends Readonly<Record<string, JsonValue>>,
118118
>(params: T): T & Readonly<Record<string, JsonValue>> {
119119
const existingMetadata = asObjectRecord(params._meta) ?? {};
120-
const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } };
120+
// Merging (like frameV2TaskRequest) because replacing the entry would drop
121+
// any capabilities and extensions the caller already declared.
122+
const existingCapability =
123+
asObjectRecord(existingMetadata[CLIENT_CAPABILITIES_META_KEY_V2]) ?? {};
124+
const existingExtensions =
125+
asObjectRecord(existingCapability.extensions) ?? {};
126+
const capability = {
127+
...existingCapability,
128+
extensions: { ...existingExtensions, [TASKS_EXTENSION_ID_V2]: {} },
129+
};
121130
return {
122131
...params,
123132
_meta: {

packages/ext-tasks/src/receiver/index.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,30 @@ function hasTaskAugmentation(request: unknown): boolean {
160160
);
161161
}
162162

163+
function asTaskAugmentationRecord(
164+
value: JsonValue,
165+
): Record<string, JsonValue> | undefined {
166+
return value !== null && typeof value === "object" && !Array.isArray(value)
167+
? (value as Record<string, JsonValue>)
168+
: undefined;
169+
}
170+
171+
function validateTaskAugmentation(request: unknown): void {
172+
const params = paramsOf(request);
173+
// Absent is fine: without a prior handler, a plain request runs as a task.
174+
// Object.hasOwn, because indexing types absent keys as JsonValue.
175+
if (!Object.hasOwn(params, "task")) return;
176+
// A present augmentation must be an object (the 2025-11-25 shape);
177+
// `task: true` would leave us guessing at what the requester meant.
178+
const augmentation = asTaskAugmentationRecord(params.task);
179+
if (augmentation === undefined)
180+
throw new Error("Task augmentation must be a JSON object");
181+
if (!Object.hasOwn(augmentation, "ttl") || augmentation.ttl === null) return;
182+
const ttl = augmentation.ttl;
183+
if (typeof ttl !== "number" || !Number.isInteger(ttl) || ttl < 0)
184+
throw new RangeError("task.ttl must be a non-negative integer or null");
185+
}
186+
163187
function taskIdOf(request: unknown): string {
164188
const taskId = paramsOf(request).taskId;
165189
if (typeof taskId !== "string")
@@ -328,6 +352,7 @@ export function bindTaskReceiver(
328352
method,
329353
(raw) => {
330354
expire();
355+
validateTaskAugmentation(raw);
331356
const params = paramsOf(raw);
332357
if (tasks.size >= maxTasks)
333358
throw new Error(
@@ -439,15 +464,11 @@ export function bindTaskReceiver(
439464
install("tasks/get", (request) =>
440465
Promise.resolve(snapshot(get(taskIdOf(request)))),
441466
);
442-
install("tasks/result", async (request) => {
467+
install("tasks/result", (request) => {
443468
const record = get(taskIdOf(request));
444-
if (
445-
record.task.status === "working" ||
446-
record.task.status === "input_required"
447-
)
448-
throw new Error("Task is not terminal");
449-
if (record.task.status === "cancelled")
450-
throw new Error("Task was cancelled");
469+
// Returning the still-pending promise blocks the response until the task
470+
// settles, because tasks/result is a blocking call per 2025-11-25; the
471+
// promise already rejects on failure, cancellation, expiry, and close.
451472
return record.result;
452473
});
453474
install("tasks/cancel", (request) => {

packages/ext-tasks/src/receiver/receiver.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,58 @@ describe("bindTaskReceiver", () => {
161161
).rejects.toThrow("expired");
162162
});
163163

164+
it("rejects malformed task augmentations and invalid augmentation TTLs", async () => {
165+
const host = new Host();
166+
bindTaskReceiver(asClient(host), {
167+
methods: { "sampling/createMessage": true },
168+
sampling: () => Promise.resolve({ ok: true }),
169+
createTaskId: () => "augmented",
170+
});
171+
// `task: true` is not a spec shape: the augmentation is an object.
172+
await expect(
173+
host.call("sampling/createMessage", { task: true }),
174+
).rejects.toThrow("Task augmentation must be a JSON object");
175+
await expect(
176+
host.call("sampling/createMessage", { task: [1] }),
177+
).rejects.toThrow("Task augmentation must be a JSON object");
178+
await expect(
179+
host.call("sampling/createMessage", { task: { ttl: -1 } }),
180+
).rejects.toThrow("task.ttl must be a non-negative integer or null");
181+
await expect(
182+
host.call("sampling/createMessage", { task: { ttl: 1.5 } }),
183+
).rejects.toThrow("task.ttl must be a non-negative integer or null");
184+
// A well-formed augmentation still creates the task.
185+
await expect(
186+
host.call("sampling/createMessage", { task: { ttl: null } }),
187+
).resolves.toMatchObject({ task: { taskId: "augmented" } });
188+
});
189+
190+
it("blocks tasks/result until the task settles instead of rejecting while pending", async () => {
191+
const host = new Host();
192+
const work = deferred<Record<string, never>>();
193+
bindTaskReceiver(asClient(host), {
194+
methods: { "sampling/createMessage": true },
195+
sampling: () => work.promise,
196+
createTaskId: () => "blocking",
197+
});
198+
await host.call("sampling/createMessage");
199+
// Issued mid-work: it must block (per 2025-11-25), not reject.
200+
let settled = false;
201+
const pending = host.call("tasks/result", { taskId: "blocking" });
202+
void pending.then(
203+
() => {
204+
settled = true;
205+
},
206+
() => {
207+
settled = true;
208+
},
209+
);
210+
await flush();
211+
expect(settled).toBe(false);
212+
work.resolve({});
213+
await expect(pending).resolves.toEqual({});
214+
});
215+
164216
it("advertises and installs only enabled request methods", () => {
165217
const host = new Host();
166218
const binding = bindTaskReceiver(asClient(host), {

0 commit comments

Comments
 (0)