11import { describe , it , beforeEach , afterEach , expect , vi } from 'vitest' ;
22
3- import {
4- DefaultRequestHandler ,
5- ExecutionEventBus ,
6- InMemoryTaskStore ,
7- TaskStore ,
8- } from '../../../src/server/index.js' ;
3+ import { DefaultRequestHandler , InMemoryTaskStore , TaskStore } from '../../../src/server/index.js' ;
94import { AgentCard , CancelTaskRequest , Task , TaskState } from '../../../src/types/pb/a2a.js' ;
105import { DefaultExecutionEventBusManager } from '../../../src/server/events/execution_event_bus_manager.js' ;
11- import { AgentEvent } from '../../../src/server/events/execution_event_bus.js' ;
126import { ServerCallContext } from '../../../src/server/context.js' ;
13- import { TaskNotCancelableError , TaskNotFoundError } from '../../../src/errors.js' ;
147import { MockAgentExecutor } from '../mocks/agent-executor.mock.js' ;
158
169/**
17- * Focused coverage for {@link DefaultRequestHandler.cancelTask} per
18- * spec §3.1.5 (output is the "Updated Task with cancellation status";
19- * "success is not guaranteed (e.g., the task might have already
20- * completed or failed, or cancellation might not be supported at its
21- * current stage)") and §3.3.1 (cancel MUST be idempotent — "multiple
22- * cancellation requests have the same effect").
10+ * Focused coverage for the §3.3.1 idempotency carve-out in
11+ * {@link DefaultRequestHandler.cancelTask}: "Cancel Task operations
12+ * are idempotent — multiple cancellation requests have the same effect."
2313 *
24- * Mirrors the a2a-go idempotent pattern in
25- * `a2asrv/agentexec.go:268,357-359` and
26- * `internal/taskexec/distributed_manager.go:164-166`.
27- *
28- * The contract verified here:
29- *
30- * 1. **Idempotent on CANCELED** — a second cancel of an
31- * already-canceled task returns the snapshot, NOT
32- * `TaskNotCancelableError` (§3.3.1).
33- * 2. **Non-blocking** — the handler MUST NOT await an event drain
34- * after `agentExecutor.cancelTask(...)`. Returning the current
35- * snapshot satisfies §3.1.5's "Updated Task with cancellation
36- * status" output without blocking on the executor publishing a
37- * CANCELED event.
38- * 3. **Non-CANCELED final state is not an error** — §3.1.5 explicitly
39- * lists "the task might have already completed or failed" as a
40- * reason cancel may not succeed; that outcome is the snapshot, not
41- * a thrown error.
42- * 4. **Non-cancelable states still throw** — COMPLETED / FAILED /
43- * REJECTED tasks raise `TaskNotCancelableError` per the §3.1.5
44- * errors list.
45- * 5. **Unknown task still throws `TaskNotFoundError`** — preserved
46- * per the §3.1.5 errors list.
14+ * The rest of the cancel contract (terminal-state rejection, unknown
15+ * task, drain-then-return) is already covered in
16+ * `default_request_handler.spec.ts`.
4717 */
48- describe ( 'DefaultRequestHandler.cancelTask (§3.1.5, §3.3.1)' , ( ) => {
18+ describe ( 'DefaultRequestHandler.cancelTask idempotency ( §3.3.1)' , ( ) => {
4919 let handler : DefaultRequestHandler ;
5020 let taskStore : TaskStore ;
5121 let mockExecutor : MockAgentExecutor ;
5222 let eventBusManager : DefaultExecutionEventBusManager ;
5323
5424 const agentCard : AgentCard = {
5525 name : 'Cancel Task Agent' ,
56- description : 'Test agent for §3.1.5 / §3.3. 1 cancel contract ' ,
26+ description : 'Test agent for §3.3. 1 cancel idempotency ' ,
5727 version : '1.0.0' ,
5828 provider : undefined ,
5929 documentationUrl : '' ,
@@ -91,26 +61,22 @@ describe('DefaultRequestHandler.cancelTask (§3.1.5, §3.3.1)', () => {
9161 vi . restoreAllMocks ( ) ;
9262 } ) ;
9363
94- const makeTask = (
95- id : string ,
96- state : TaskState = TaskState . TASK_STATE_WORKING ,
97- contextId = `ctx-${ id } `
98- ) : Task => ( {
64+ const cancelReq = ( id : string ) : CancelTaskRequest => ( {
9965 id,
100- contextId,
101- status : { state, message : undefined , timestamp : undefined } ,
102- artifacts : [ ] ,
103- history : [ ] ,
66+ tenant : '' ,
10467 metadata : { } ,
10568 } ) ;
10669
107- const cancelReq = ( id : string ) : CancelTaskRequest => ( {
70+ const makeTask = ( id : string , state : TaskState ) : Task => ( {
10871 id,
109- tenant : '' ,
72+ contextId : `ctx-${ id } ` ,
73+ status : { state, message : undefined , timestamp : undefined } ,
74+ artifacts : [ ] ,
75+ history : [ ] ,
11076 metadata : { } ,
11177 } ) ;
11278
113- it ( 'returns the snapshot (no throw) when canceling an already-canceled task — §3.3.1 idempotency ' , async ( ) => {
79+ it ( 'returns the snapshot (no throw) when canceling an already-canceled task' , async ( ) => {
11480 // The user retries cancel after the first one succeeded (or two
11581 // clients raced the same cancel). Per §3.3.1 the second call MUST
11682 // be idempotent — return the snapshot, not TaskNotCancelableError.
@@ -126,145 +92,4 @@ describe('DefaultRequestHandler.cancelTask (§3.1.5, §3.3.1)', () => {
12692 // canceled — idempotency means a no-op, not a re-issue.
12793 expect ( mockExecutor . cancelTask ) . not . toHaveBeenCalled ( ) ;
12894 } ) ;
129-
130- it ( 'returns the current snapshot without awaiting a CANCELED event — §3.1.5 non-blocking' , async ( ) => {
131- // Executor's cancelTask is a no-op stub that resolves immediately
132- // without publishing CANCELED. Previously, cancelTask awaited
133- // `_processEvents`, which only resolves once the bus closes — so
134- // this call would never return. The handler must now fire the
135- // signal and return the current snapshot from the store; §3.1.5
136- // defines the output as "Updated Task with cancellation status",
137- // i.e. whatever the current state is at response time.
138- const taskId = 'task-no-cancel-event' ;
139- const contextId = `ctx-${ taskId } ` ;
140- const persisted = makeTask ( taskId , TaskState . TASK_STATE_WORKING , contextId ) ;
141- await taskStore . save ( persisted , serverContext ) ;
142-
143- // Register an active bus so the handler takes the executor-signal
144- // branch (not the no-bus direct-persist branch).
145- eventBusManager . createOrGetByTaskId ( taskId ) ;
146-
147- // If the handler awaited the event drain this `await` would never
148- // resolve — the test would time out instead of asserting. Reaching
149- // the assertions at all is the proof of non-blocking behavior.
150- const result = await handler . cancelTask ( cancelReq ( taskId ) , serverContext ) ;
151-
152- expect ( mockExecutor . cancelTask ) . toHaveBeenCalledExactlyOnceWith ( taskId , expect . anything ( ) ) ;
153- // Snapshot reflects the persisted state — the executor hasn't
154- // published anything in response to cancel, so it remains WORKING.
155- expect ( result . status ?. state ) . toBe ( TaskState . TASK_STATE_WORKING ) ;
156- } ) ;
157-
158- it ( 'returns the snapshot when the executor completes during cancel — no post-load throw' , async ( ) => {
159- // §3.1.5 explicitly names "the task might have already completed or
160- // failed" as a reason cancel may not succeed. Race: the user signals
161- // cancel, but the executor was already on the last step and
162- // publishes COMPLETED before processing the cancel. The previous
163- // implementation threw TaskNotCancelableError after the drain
164- // because final state was not CANCELED; the new contract returns
165- // the snapshot — the "Updated Task with cancellation status"
166- // §3.1.5 specifies as the output.
167- const taskId = 'task-natural-completion' ;
168- const contextId = `ctx-${ taskId } ` ;
169- const persisted = makeTask ( taskId , TaskState . TASK_STATE_WORKING , contextId ) ;
170- await taskStore . save ( persisted , serverContext ) ;
171-
172- const bus : ExecutionEventBus = eventBusManager . createOrGetByTaskId ( taskId ) ;
173-
174- // Simulate the completion landing while the cancel call is in
175- // flight: the executor publishes COMPLETED inside its cancelTask
176- // handler (a reasonable response when work already finished), and
177- // we persist that state to the store so the post-signal
178- // `taskStore.load(...)` observes COMPLETED.
179- mockExecutor . cancelTask . mockImplementation (
180- async ( cancelTaskId : string , eventBus : ExecutionEventBus ) => {
181- eventBus . publish (
182- AgentEvent . statusUpdate ( {
183- taskId : cancelTaskId ,
184- contextId,
185- status : {
186- state : TaskState . TASK_STATE_COMPLETED ,
187- message : undefined ,
188- timestamp : undefined ,
189- } ,
190- metadata : { } ,
191- } )
192- ) ;
193- // Persist directly — the handler no longer drains the bus, so
194- // a parallel ResultManager isn't writing this state for us in
195- // the test's deterministic window.
196- const current = await taskStore . load ( cancelTaskId , serverContext ) ;
197- if ( current ) {
198- current . status = {
199- state : TaskState . TASK_STATE_COMPLETED ,
200- message : undefined ,
201- timestamp : undefined ,
202- } ;
203- await taskStore . save ( current , serverContext ) ;
204- }
205- }
206- ) ;
207-
208- const result = await handler . cancelTask ( cancelReq ( taskId ) , serverContext ) ;
209-
210- expect ( result . id ) . toBe ( taskId ) ;
211- expect ( result . status ?. state ) . toBe ( TaskState . TASK_STATE_COMPLETED ) ;
212- // Bus is still around — `_runExecutor.finally` is what closes it,
213- // not cancelTask. Referenced so the no-unused-vars lint is happy.
214- expect ( bus ) . toBeDefined ( ) ;
215- } ) ;
216-
217- it ( 'throws TaskNotCancelableError for COMPLETED tasks' , async ( ) => {
218- const taskId = 'task-completed' ;
219- await taskStore . save ( makeTask ( taskId , TaskState . TASK_STATE_COMPLETED ) , serverContext ) ;
220- await expect ( handler . cancelTask ( cancelReq ( taskId ) , serverContext ) ) . rejects . toThrow (
221- TaskNotCancelableError
222- ) ;
223- expect ( mockExecutor . cancelTask ) . not . toHaveBeenCalled ( ) ;
224- } ) ;
225-
226- it ( 'throws TaskNotCancelableError for FAILED tasks' , async ( ) => {
227- const taskId = 'task-failed' ;
228- await taskStore . save ( makeTask ( taskId , TaskState . TASK_STATE_FAILED ) , serverContext ) ;
229- await expect ( handler . cancelTask ( cancelReq ( taskId ) , serverContext ) ) . rejects . toThrow (
230- TaskNotCancelableError
231- ) ;
232- expect ( mockExecutor . cancelTask ) . not . toHaveBeenCalled ( ) ;
233- } ) ;
234-
235- it ( 'throws TaskNotCancelableError for REJECTED tasks' , async ( ) => {
236- const taskId = 'task-rejected' ;
237- await taskStore . save ( makeTask ( taskId , TaskState . TASK_STATE_REJECTED ) , serverContext ) ;
238- await expect ( handler . cancelTask ( cancelReq ( taskId ) , serverContext ) ) . rejects . toThrow (
239- TaskNotCancelableError
240- ) ;
241- expect ( mockExecutor . cancelTask ) . not . toHaveBeenCalled ( ) ;
242- } ) ;
243-
244- it ( 'still throws TaskNotFoundError when the task id is unknown' , async ( ) => {
245- await expect ( handler . cancelTask ( cancelReq ( 'does-not-exist' ) , serverContext ) ) . rejects . toThrow (
246- TaskNotFoundError
247- ) ;
248- expect ( mockExecutor . cancelTask ) . not . toHaveBeenCalled ( ) ;
249- } ) ;
250-
251- it ( 'persists CANCELED state directly when no active bus exists' , async ( ) => {
252- // No-bus branch is unchanged: the executor isn't around to signal,
253- // so the handler writes CANCELED to the store and returns it. Pinned
254- // as a regression guard so the new idempotency check doesn't
255- // accidentally short-circuit the persist path.
256- const taskId = 'task-no-bus' ;
257- const persisted = makeTask ( taskId , TaskState . TASK_STATE_WORKING ) ;
258- await taskStore . save ( persisted , serverContext ) ;
259- expect ( eventBusManager . getByTaskId ( taskId ) ) . toBeUndefined ( ) ;
260-
261- const result = await handler . cancelTask ( cancelReq ( taskId ) , serverContext ) ;
262-
263- expect ( result . status ?. state ) . toBe ( TaskState . TASK_STATE_CANCELED ) ;
264- // No executor signal on the no-bus branch — there's nothing
265- // running to interrupt.
266- expect ( mockExecutor . cancelTask ) . not . toHaveBeenCalled ( ) ;
267- // The cancellation message should be appended to history.
268- expect ( result . history ?. length ) . toBeGreaterThan ( 0 ) ;
269- } ) ;
27095} ) ;
0 commit comments