@@ -26,6 +26,7 @@ import (
2626 "net/http"
2727 "path/filepath"
2828 "strings"
29+ "sync"
2930 "testing"
3031 "time"
3132
@@ -380,6 +381,8 @@ func TestA2ACleanupPropagation(t *testing.T) {
380381 // Remote A2A server publishes a submitted task and start generating artifact updates
381382 // until it detects a context cancelation
382383 remoteTaskIDChan , remoteCleanupCalledChan := make (chan a2a.TaskID , 1 ), make (chan struct {}, 2 )
384+ // Artifact text the mock subagent streams; the cancel step keys off it.
385+ const remoteArtifactText = "remote-subagent-working"
383386 serverB := startA2AServer (& mockA2AExecutor {
384387 cancelFn : func (ctx context.Context , reqCtx * a2asrv.ExecutorContext ) iter.Seq2 [a2a.Event , error ] {
385388 return func (yield func (a2a.Event , error ) bool ) {
@@ -393,7 +396,7 @@ func TestA2ACleanupPropagation(t *testing.T) {
393396 return
394397 }
395398 for ctx .Err () == nil {
396- if ! yield (a2a .NewArtifactEvent (reqCtx , a2a .NewTextPart ("foo" )), nil ) {
399+ if ! yield (a2a .NewArtifactEvent (reqCtx , a2a .NewTextPart (remoteArtifactText )), nil ) {
397400 return
398401 }
399402 time .Sleep (1 * time .Millisecond )
@@ -427,27 +430,44 @@ func TestA2ACleanupPropagation(t *testing.T) {
427430
428431 client := newA2AClient (t , serverA )
429432
430- // Send a streaming message in a detached goroutine, passing status update through chan
433+ // Join the detached streaming/cancel goroutines before teardown; a late
434+ // t.Errorf from an in-flight RPC on a finished test would panic.
435+ var wg sync.WaitGroup
436+ t .Cleanup (wg .Wait )
437+
438+ // remoteStreamingChan closes when the subagent's own output first reaches the client.
431439 statusUpdateEventChan := make (chan a2a.Event , 10 )
440+ remoteStreamingChan := make (chan struct {})
441+ wg .Add (1 )
432442 go func () {
443+ defer wg .Done ()
433444 defer close (statusUpdateEventChan )
445+ remoteStreaming := false
434446 msg := a2a .NewMessage (a2a .MessageRoleUser , a2a .NewTextPart ("work" ))
435447 for event , err := range client .SendStreamingMessage (t .Context (), & a2a.SendMessageRequest {Message : msg }) {
436448 if err != nil {
437449 t .Errorf ("client.SendStreamingMessage() error = %v" , err )
438450 return
439451 }
440- if _ , ok := event .(* a2a.TaskArtifactUpdateEvent ); ok {
452+ if tau , ok := event .(* a2a.TaskArtifactUpdateEvent ); ok {
453+ if ! remoteStreaming && artifactContainsText (tau , remoteArtifactText ) {
454+ remoteStreaming = true
455+ close (remoteStreamingChan )
456+ }
441457 continue
442458 }
443459 statusUpdateEventChan <- event
444460 }
445461 }()
446462
447- // Issue a task cancellation request
463+ // Cancel only after the subagent's output reaches the client: before that the
464+ // parent doesn't know the subagent task ID, so cancellation can't propagate.
448465 taskID := (<- statusUpdateEventChan ).TaskInfo ().TaskID
466+ awaitN (t , remoteStreamingChan , 1 , "remote subagent streaming" )
449467 cancelResultChan := make (chan * a2a.Task , 1 )
468+ wg .Add (1 )
450469 go func () {
470+ defer wg .Done ()
451471 defer close (cancelResultChan )
452472 task , err := client .CancelTask (t .Context (), & a2a.CancelTaskRequest {ID : taskID })
453473 if err != nil {
@@ -470,25 +490,12 @@ func TestA2ACleanupPropagation(t *testing.T) {
470490 t .Fatalf ("type(lastStreamingUpdate) = %T, want *a2a.TaskStatusUpdateEvent" , lastStreamingUpdate )
471491 }
472492
473- // Check subagent task got cancelled when the parent task was cancelled.
474- // Reads from channel twice because cleanup gets called both for cancelation and execution.
475- timeout := time .After (5 * time .Second )
476- for range 2 {
477- select {
478- case <- remoteCleanupCalledChan :
479- case <- timeout :
480- t .Fatalf ("remote cleanup was not called" )
481- }
482- }
493+ // Subagent cleanup fires twice: once for cancelation, once for execution.
494+ // A generous per-wait deadline avoids flaking under CPU contention.
495+ awaitN (t , remoteCleanupCalledChan , 2 , "remote cleanup" )
483496 remoteTaskID := <- remoteTaskIDChan
497+ awaitN (t , executorCleanupCalledChan , 2 , "executor cleanup" )
484498
485- for range 2 {
486- select {
487- case <- executorCleanupCalledChan :
488- case <- timeout :
489- t .Fatalf ("executor cleanup was not called" )
490- }
491- }
492499 remoteClient := newA2AClient (t , serverB )
493500 remoteTask , err := remoteClient .GetTask (t .Context (), & a2a.GetTaskRequest {ID : remoteTaskID })
494501 if err != nil {
@@ -497,6 +504,35 @@ func TestA2ACleanupPropagation(t *testing.T) {
497504 if remoteTask .Status .State != a2a .TaskStateCanceled {
498505 t .Errorf ("remoteTask.Status.State = %q, want %q" , remoteTask .Status .State , a2a .TaskStateCanceled )
499506 }
507+
508+ // Join the cancel RPC so it can't log on t after the test returns.
509+ awaitN (t , cancelResultChan , 1 , "cancel task" )
510+ }
511+
512+ // awaitN receives n values from ch or fails the test after a generous, contention-
513+ // tolerant deadline. A closed channel counts as a receive, so it also joins a
514+ // goroutine that closed ch without sending.
515+ func awaitN [T any ](t * testing.T , ch <- chan T , n int , what string ) {
516+ t .Helper ()
517+ const deadline = 30 * time .Second
518+ timer := time .NewTimer (deadline )
519+ defer timer .Stop ()
520+ for i := range n {
521+ select {
522+ case <- ch :
523+ case <- timer .C :
524+ t .Fatalf ("%s: got %d of %d within %v" , what , i , n , deadline )
525+ }
526+ }
527+ }
528+
529+ func artifactContainsText (tau * a2a.TaskArtifactUpdateEvent , substr string ) bool {
530+ for _ , p := range tau .Artifact .Parts {
531+ if strings .Contains (p .Text (), substr ) {
532+ return true
533+ }
534+ }
535+ return false
500536}
501537
502538func TestA2ASingleHopFinalResponse (t * testing.T ) {
0 commit comments