From b8b1e021371e148c1595dc92d343341932ff5e4e Mon Sep 17 00:00:00 2001 From: 1fanwang <1fannnw@gmail.com> Date: Thu, 3 Sep 2026 11:41:01 -0400 Subject: [PATCH] fix(tasks): validate cancelled terminal status Signed-off-by: 1fanwang <1fannnw@gmail.com> --- src/scenarios/server/tasks/lifecycle.test.ts | 76 ++++++++++++++++++++ src/scenarios/server/tasks/lifecycle.ts | 18 ++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/scenarios/server/tasks/lifecycle.test.ts b/src/scenarios/server/tasks/lifecycle.test.ts index 8926538b..ef6caf69 100644 --- a/src/scenarios/server/tasks/lifecycle.test.ts +++ b/src/scenarios/server/tasks/lifecycle.test.ts @@ -3,6 +3,7 @@ import { testContext } from '../../../connection/testing'; import { TasksLifecycleScenario } from './lifecycle'; import { DRAFT_PROTOCOL_VERSION } from '../../../types'; import type { ConformanceCheck } from '../../../types'; +import type { RunContext } from '../../../connection'; /** * Pins the untestable-failure policy (issue #248) for the SEP-2663 lifecycle @@ -61,6 +62,61 @@ function mockServer() { return 'http://mock-taskless-server.local'; } +function cancellationContext(): RunContext { + const request = async (method: string, params: any) => { + if (method === 'tools/call') { + if (params.name === 'greet') { + return { + resultType: 'complete', + content: [{ type: 'text', text: 'Hello, World!' }] + }; + } + return { + resultType: 'task', + taskId: + params.name === 'failing_job' + ? 'failing' + : params.name === 'protocol_error_job' + ? 'protocol-error' + : params.arguments.label, + status: 'working', + createdAt: '2026-09-02T00:00:00Z', + lastUpdatedAt: '2026-09-02T00:00:00Z', + ttlMs: 60_000 + }; + } + if (method === 'tasks/cancel') return { resultType: 'complete' }; + if (params.taskId === 'failing') { + return { + resultType: 'complete', + taskId: params.taskId, + status: 'completed', + result: { content: [], isError: true } + }; + } + if (params.taskId === 'protocol-error') { + return { + resultType: 'complete', + taskId: params.taskId, + status: 'failed', + error: { code: -32603, message: 'Internal error' } + }; + } + return { + resultType: 'complete', + taskId: params.taskId, + status: 'completed', + result: { content: [{ type: 'text', text: 'done' }] } + }; + }; + + return { + serverUrl: 'http://unused.local', + specVersion: DRAFT_PROTOCOL_VERSION, + connect: async () => ({ request, close: async () => {} }) as any + }; +} + describe('tasks-lifecycle — no task created', () => { test('downstream task checks fail as untestable instead of SKIPPED', async () => { const mockUrl = mockServer(); @@ -80,3 +136,23 @@ describe('tasks-lifecycle — no task created', () => { expect(checks.every((c) => c.status !== 'SKIPPED')).toBe(true); }); }); + +describe('tasks-lifecycle — cancellation', () => { + test('fails when the cancellable fixture settles to completed', async () => { + const scenario = new TasksLifecycleScenario(); + const checks = await scenario.run(cancellationContext()); + + expect( + checks.find((check) => check.id === 'sep-2663-cancel-ack-empty-result') + ).toMatchObject({ + status: 'SUCCESS', + details: { statusAfterCancel: 'completed' } + }); + expect( + checks.find((check) => check.id === 'sep-2663-tasks-get-status-cancelled') + ).toMatchObject({ + status: 'FAILURE', + details: { statusAfterCancel: 'completed' } + }); + }); +}); diff --git a/src/scenarios/server/tasks/lifecycle.ts b/src/scenarios/server/tasks/lifecycle.ts index 2214ff16..19491036 100644 --- a/src/scenarios/server/tasks/lifecycle.ts +++ b/src/scenarios/server/tasks/lifecycle.ts @@ -423,7 +423,7 @@ The server MUST advertise \`io.modelcontextprotocol/tasks\` under const id = 'sep-2663-cancel-ack-empty-result'; const name = 'TasksCancelEmptyAck'; const description = - 'tasks/cancel returns {resultType:"complete"} ack; status settles to cancelled'; + 'tasks/cancel returns an empty {resultType:"complete"} acknowledgement'; let cancelTaskId: string | undefined; try { const created = (await conn.request('tools/call', { @@ -461,8 +461,6 @@ The server MUST advertise \`io.modelcontextprotocol/tasks\` under `cancel ack MUST NOT carry task-envelope fields; got: ${ackOffenders.join(', ')}` ); } - // SEP-2663 §Task Cancellation: transition to `cancelled` is not - // guaranteed; record the settled status as diagnostic detail only. const after = await waitForTerminal(conn, cancelTaskId); checks.push({ id, @@ -474,6 +472,20 @@ The server MUST advertise \`io.modelcontextprotocol/tasks\` under specReferences: [SEP_2663_REF, SEP_2322_REF], details: { cancelAck: ack, statusAfterCancel: after.status } }); + checks.push({ + id: 'sep-2663-tasks-get-status-cancelled', + name: 'TasksGetCancelledStatus', + description: + 'The cancellable slow_compute fixture settles to cancelled after tasks/cancel', + status: after.status === 'cancelled' ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + after.status === 'cancelled' + ? undefined + : `expected status:"cancelled"; got ${JSON.stringify(after.status)}`, + specReferences: [SEP_2663_REF], + details: { statusAfterCancel: after.status } + }); } } catch (error) { checks.push(failureCheck(id, name, description, error, [SEP_2663_REF]));