1414import java .util .concurrent .ConcurrentMap ;
1515import java .util .concurrent .Executor ;
1616import java .util .concurrent .Flow ;
17+ import java .util .concurrent .atomic .AtomicBoolean ;
1718import java .util .concurrent .atomic .AtomicReference ;
1819import java .util .function .Supplier ;
1920
4849import io .a2a .spec .StreamingEventKind ;
4950import io .a2a .spec .Task ;
5051import io .a2a .spec .TaskIdParams ;
52+ import io .a2a .spec .TaskNotCancelableError ;
53+ import io .a2a .spec .TaskState ;
5154import io .a2a .spec .TaskNotFoundError ;
5255import io .a2a .spec .TaskPushNotificationConfig ;
5356import io .a2a .spec .TaskQueryParams ;
@@ -122,6 +125,13 @@ public Task onCancelTask(TaskIdParams params, ServerCallContext context) throws
122125 if (task == null ) {
123126 throw new TaskNotFoundError ();
124127 }
128+
129+ // Check if task is in a non-cancelable state (completed, canceled, failed, rejected)
130+ if (task .getStatus ().state ().isFinal ()) {
131+ throw new TaskNotCancelableError (
132+ "Task cannot be canceled - current state: " + task .getStatus ().state ().asString ());
133+ }
134+
125135 TaskManager taskManager = new TaskManager (
126136 task .getId (),
127137 task .getContextId (),
@@ -148,11 +158,17 @@ public Task onCancelTask(TaskIdParams params, ServerCallContext context) throws
148158
149159 EventConsumer consumer = new EventConsumer (queue );
150160 EventKind type = resultAggregator .consumeAll (consumer );
151- if (type instanceof Task tempTask ) {
152- return tempTask ;
161+ if (!( type instanceof Task tempTask ) ) {
162+ throw new InternalError ( "Agent did not return valid response for cancel" ) ;
153163 }
154164
155- throw new InternalError ("Agent did not return a valid response" );
165+ // Verify task was actually canceled (not completed concurrently)
166+ if (tempTask .getStatus ().state () != TaskState .CANCELED ) {
167+ throw new TaskNotCancelableError (
168+ "Task cannot be canceled - current state: " + tempTask .getStatus ().state ().asString ());
169+ }
170+
171+ return tempTask ;
156172 }
157173
158174 @ Override
@@ -166,32 +182,42 @@ public EventKind onMessageSend(MessageSendParams params, ServerCallContext conte
166182 EventQueue queue = queueManager .createOrTap (taskId );
167183 ResultAggregator resultAggregator = new ResultAggregator (mss .taskManager , null );
168184
169- boolean interrupted = false ;
185+ boolean blocking = true ; // Default to blocking behavior
186+ if (params .configuration () != null && Boolean .FALSE .equals (params .configuration ().blocking ())) {
187+ blocking = false ;
188+ }
189+
190+ boolean interruptedOrNonBlocking = false ;
170191
171192 EnhancedRunnable producerRunnable = registerAndExecuteAgentAsync (taskId , mss .requestContext , queue );
172193 ResultAggregator .EventTypeAndInterrupt etai = null ;
173194 try {
195+ // Create callback for push notifications during background event processing
196+ Runnable pushNotificationCallback = () -> sendPushNotification (taskId , resultAggregator );
197+
174198 EventConsumer consumer = new EventConsumer (queue );
175199
176200 // This callback must be added before we start consuming. Otherwise,
177201 // any errors thrown by the producerRunnable are not picked up by the consumer
178202 producerRunnable .addDoneCallback (consumer .createAgentRunnableDoneCallback ());
179- etai = resultAggregator .consumeAndBreakOnInterrupt (consumer );
203+ etai = resultAggregator .consumeAndBreakOnInterrupt (consumer , blocking , pushNotificationCallback );
180204
181205 if (etai == null ) {
182206 LOGGER .debug ("No result, throwing InternalError" );
183207 throw new InternalError ("No result" );
184208 }
185- interrupted = etai .interrupted ();
186- LOGGER .debug ("Was interrupted: {}" , interrupted );
209+ interruptedOrNonBlocking = etai .interrupted ();
210+ LOGGER .debug ("Was interrupted or non-blocking : {}" , interruptedOrNonBlocking );
187211
188212 EventKind kind = etai .eventType ();
189213 if (kind instanceof Task taskResult && !taskId .equals (taskResult .getId ())) {
190214 throw new InternalError ("Task ID mismatch in agent response" );
191215 }
192216
217+ // Send push notification after initial return (for both blocking and non-blocking)
218+ pushNotificationCallback .run ();
193219 } finally {
194- if (interrupted ) {
220+ if (interruptedOrNonBlocking ) {
195221 CompletableFuture <Void > cleanupTask = CompletableFuture .runAsync (() -> cleanupProducer (taskId ), executor );
196222 trackBackgroundTask (cleanupTask );
197223 } else {
@@ -214,13 +240,15 @@ public Flow.Publisher<StreamingEventKind> onMessageSendStream(
214240 ResultAggregator resultAggregator = new ResultAggregator (mss .taskManager , null );
215241
216242 EnhancedRunnable producerRunnable = registerAndExecuteAgentAsync (taskId .get (), mss .requestContext , queue );
243+
244+ // Move consumer creation and callback registration outside try block
245+ // so consumer is available for background consumption on client disconnect
217246 EventConsumer consumer = new EventConsumer (queue );
247+ producerRunnable .addDoneCallback (consumer .createAgentRunnableDoneCallback ());
218248
219- try {
249+ AtomicBoolean backgroundConsumeStarted = new AtomicBoolean ( false );
220250
221- // This callback must be added before we start consuming. Otherwise,
222- // any errors thrown by the producerRunnable are not picked up by the consumer
223- producerRunnable .addDoneCallback (consumer .createAgentRunnableDoneCallback ());
251+ try {
224252 Flow .Publisher <Event > results = resultAggregator .consumeAndEmit (consumer );
225253
226254 Flow .Publisher <Event > eventPublisher =
@@ -258,7 +286,61 @@ public Flow.Publisher<StreamingEventKind> onMessageSendStream(
258286 return true ;
259287 }));
260288
261- return convertingProcessor (eventPublisher , event -> (StreamingEventKind ) event );
289+ Flow .Publisher <StreamingEventKind > finalPublisher = convertingProcessor (eventPublisher , event -> (StreamingEventKind ) event );
290+
291+ // Wrap publisher to detect client disconnect and continue background consumption
292+ return subscriber -> finalPublisher .subscribe (new Flow .Subscriber <StreamingEventKind >() {
293+ private Flow .Subscription subscription ;
294+
295+ @ Override
296+ public void onSubscribe (Flow .Subscription subscription ) {
297+ this .subscription = subscription ;
298+ // Wrap subscription to detect cancellation
299+ subscriber .onSubscribe (new Flow .Subscription () {
300+ @ Override
301+ public void request (long n ) {
302+ subscription .request (n );
303+ }
304+
305+ @ Override
306+ public void cancel () {
307+ LOGGER .debug ("Client cancelled subscription for task {}, starting background consumption" , taskId .get ());
308+ startBackgroundConsumption ();
309+ subscription .cancel ();
310+ }
311+ });
312+ }
313+
314+ @ Override
315+ public void onNext (StreamingEventKind item ) {
316+ subscriber .onNext (item );
317+ }
318+
319+ @ Override
320+ public void onError (Throwable throwable ) {
321+ subscriber .onError (throwable );
322+ }
323+
324+ @ Override
325+ public void onComplete () {
326+ subscriber .onComplete ();
327+ }
328+
329+ private void startBackgroundConsumption () {
330+ if (backgroundConsumeStarted .compareAndSet (false , true )) {
331+ // Client disconnected: continue consuming and persisting events in background
332+ CompletableFuture <Void > bgTask = CompletableFuture .runAsync (() -> {
333+ try {
334+ resultAggregator .consumeAll (consumer );
335+ LOGGER .debug ("Background consumption completed for task {}" , taskId .get ());
336+ } catch (Exception e ) {
337+ LOGGER .error ("Error during background consumption for task {}" , taskId .get (), e );
338+ }
339+ }, executor );
340+ trackBackgroundTask (bgTask );
341+ }
342+ }
343+ });
262344 } finally {
263345 CompletableFuture <Void > cleanupTask = CompletableFuture .runAsync (() -> cleanupProducer (taskId .get ()), executor );
264346 trackBackgroundTask (cleanupTask );
@@ -454,5 +536,14 @@ private MessageSendSetup initMessageSend(MessageSendParams params, ServerCallCon
454536 return new MessageSendSetup (taskManager , task , requestContext );
455537 }
456538
539+ private void sendPushNotification (String taskId , ResultAggregator resultAggregator ) {
540+ if (pushSender != null && taskId != null ) {
541+ EventKind latest = resultAggregator .getCurrentResult ();
542+ if (latest instanceof Task latestTask ) {
543+ pushSender .sendNotification (latestTask );
544+ }
545+ }
546+ }
547+
457548 private record MessageSendSetup (TaskManager taskManager , Task task , RequestContext requestContext ) {}
458549}
0 commit comments