@@ -273,6 +273,70 @@ func TestAgent_RunMission_Success(t *testing.T) {
273273 mockLTM .AssertExpectations (t ) // Verify LTM mock expectations
274274}
275275
276+ // NEW TEST: TestAgent_ActionLoop_PanicRecovery verifies that the action loop recovers from panics
277+ // during message processing and acknowledges the message to prevent shutdown deadlocks.
278+ func TestAgent_ActionLoop_PanicRecovery (t * testing.T ) {
279+ // 1. Setup
280+ // We need a short timeout for the overall test execution to detect deadlocks.
281+ testCtx , cancelTest := context .WithTimeout (context .Background (), 5 * time .Second )
282+ defer cancelTest ()
283+
284+ // Initialize the agent and its dependencies.
285+ agent , _ , bus , mockExecutors , _ , _ , _ , _ , _ , _ := setupAgentTest (t )
286+
287+ // Use a separate context for the actionLoop itself, which we won't cancel until the end.
288+ loopCtx , cancelLoop := context .WithCancel (context .Background ())
289+
290+ // Subscribe to the action channel that the loop will consume from.
291+ actionChan , unsubscribeActions := bus .Subscribe (MessageTypeAction )
292+ defer unsubscribeActions ()
293+
294+ // 2. Configure the mock executor to panic when a specific action is executed.
295+ panickingAction := Action {Type : ActionClick , ID : "panic-action" }
296+ mockExecutors .On ("Execute" , mock .Anything , panickingAction ).Run (func (args mock.Arguments ) {
297+ panic ("Simulated executor panic!" )
298+ }).Return (nil , errors .New ("this error is ignored because of panic" )).Once ()
299+
300+ // 3. Start the action loop in a separate goroutine.
301+ agent .wg .Add (1 )
302+ loopFinishedChan := make (chan struct {})
303+ go func () {
304+ // Catch any panic propagating out of the loop just in case the internal recovery fails.
305+ defer func () {
306+ if r := recover (); r != nil {
307+ t .Logf ("Test caught panic propagating out of actionLoop (unexpected): %v" , r )
308+ }
309+ close (loopFinishedChan )
310+ }()
311+ agent .actionLoop (loopCtx , actionChan )
312+ }()
313+
314+ // 4. Post the message that will cause the panic.
315+ err := bus .Post (testCtx , CognitiveMessage {ID : "test-msg-panic" , Type : MessageTypeAction , Payload : panickingAction })
316+ require .NoError (t , err )
317+
318+ // 5. Verify Acknowledgment by attempting to shut down the bus.
319+ // If the message wasn't acknowledged (because the loop crashed before recovery), bus.Shutdown() will hang.
320+ shutdownDone := make (chan struct {})
321+ go func () {
322+ // Shutdown waits for all in-flight messages to be acknowledged.
323+ bus .Shutdown ()
324+ close (shutdownDone )
325+ }()
326+
327+ select {
328+ case <- shutdownDone :
329+ // Success: Bus shut down cleanly, meaning the message was acknowledged despite the panic.
330+ case <- testCtx .Done ():
331+ t .Fatal ("Timeout waiting for bus shutdown. Message likely unacknowledged due to panic in actionLoop." )
332+ }
333+
334+ // 6. Clean up the running loop.
335+ cancelLoop ()
336+ // Wait for the loop goroutine to finish (safe because we know it didn't deadlock).
337+ <- loopFinishedChan
338+ }
339+
276340// TestAgent_RunMission_MindFailure verifies the agent fails fast if the Mind fails to start.
277341func TestAgent_RunMission_MindFailure (t * testing.T ) {
278342 // Arrange
@@ -353,6 +417,58 @@ func TestAgent_RunMission_ContextCancellation(t *testing.T) {
353417 mockLLM .AssertExpectations (t )
354418}
355419
420+ // NEW TEST: TestAgent_RunMission_CancellationBeforeFinish verifies that the actionLoop
421+ // does not leak if the context is cancelled right when the agent tries to finish.
422+ func TestAgent_RunMission_CancellationBeforeFinish (t * testing.T ) {
423+ // This tests the fix where finish() now accepts a context and uses select{} when sending the result.
424+
425+ agent , mockMind , _ , _ , _ , mockKG , mockLLM , mockLTM , _ , _ := setupAgentTest (t )
426+ // Create a context that we can cancel.
427+ ctx , cancel := context .WithCancel (context .Background ())
428+
429+ // Set expectations for the initial startup
430+ mockMind .On ("SetMission" , agent .mission ).Return ().Once ()
431+ // Mind.Start should run until the context passed to it (missionCtx) is cancelled.
432+ mockMind .On ("Start" , mock .Anything ).Run (func (args mock.Arguments ) {
433+ startCtx := args .Get (0 ).(context.Context )
434+ <- startCtx .Done () // Block until cancelled
435+ }).Return (context .Canceled ).Once ()
436+ mockMind .On ("Stop" ).Return ().Once ()
437+ mockLTM .On ("Start" ).Return ().Once ()
438+
439+ // Set expectations for the conclusion (which will happen after cancellation in RunMission)
440+ // These mocks are needed because RunMission calls concludeMission upon cancellation.
441+ mockKG .On ("GetNode" , mock .Anything , mock .Anything ).Return (schemas.Node {}, nil ).Maybe ()
442+ mockKG .On ("GetEdges" , mock .Anything , mock .Anything ).Return ([]schemas.Edge {}, nil ).Maybe ()
443+ mockLLM .On ("Generate" , mock .Anything , mock .Anything ).Return ("Cancelled summary." , nil ).Maybe ()
444+
445+ // Act
446+ var runMissionWg sync.WaitGroup
447+ runMissionWg .Add (1 )
448+ go func () {
449+ defer runMissionWg .Done ()
450+ // RunMission will block until cancelled.
451+ _ , _ = agent .RunMission (ctx )
452+ }()
453+
454+ // Allow the agent and its actionLoop to start up.
455+ time .Sleep (100 * time .Millisecond )
456+
457+ // We need to ensure the actionLoop attempts to process the message *after* we cancel the context.
458+ // This simulates the race condition where RunMission exits due to cancellation
459+ // before the actionLoop finishes sending the result via finish().
460+ cancel () // Cancel the context, causing RunMission to start shutting down.
461+
462+ // Wait for RunMission to return. This confirms the receiver (RunMission) is gone.
463+ runMissionWg .Wait ()
464+
465+ // Crucial Assertion: Wait for the agent's internal WaitGroup (which includes the actionLoop).
466+ // If the actionLoop leaks (because finish() blocks), this will time out.
467+ assert .True (t , waitTimeout (& agent .wg , 2 * time .Second ), "Agent WaitGroup did not complete, potential goroutine leak in actionLoop/finish." )
468+
469+ mockMind .AssertExpectations (t )
470+ }
471+
356472// TestAgent_ActionLoop verifies the correct dispatching of various action types.
357473func TestAgent_ActionLoop (t * testing.T ) {
358474 // Helper to setup and run the action loop in the background
@@ -561,54 +677,8 @@ func TestAgent_ActionLoop(t *testing.T) {
561677 }
562678 })
563679
564- // NEW: Test for complex actions being dispatched to the executor
565- t .Run ("ExecuteLoginSequenceAction_DispatchedToExecutor" , func (t * testing.T ) {
566- agent , bus , cancelRoot , _ := setupActionLoop (t )
567- defer cancelRoot ()
568- mockExecutors := agent .executors .(* MockExecutorRegistry )
569-
570- action := Action {Type : ActionExecuteLoginSequence , Rationale : "Attempting login" }
571- obsChan , unsub := bus .Subscribe (MessageTypeObservation )
572- defer unsub ()
573-
574- execResult := & ExecutionResult {Status : "success" , ObservationType : ObservedAuthResult }
575- mockExecutors .On ("Execute" , mock .Anything , action ).Return (execResult , nil ).Once ()
576-
577- err := bus .Post (context .Background (), CognitiveMessage {ID : "login-msg" , Type : MessageTypeAction , Payload : action })
578- require .NoError (t , err )
579-
580- select {
581- case msg := <- obsChan :
582- bus .Acknowledge (msg )
583- mockExecutors .AssertExpectations (t )
584- case <- time .After (2 * time .Second ):
585- t .Fatal ("Timeout waiting for ActionExecuteLoginSequence to be dispatched" )
586- }
587- })
588-
589- t .Run ("ExploreApplicationAction_DispatchedToExecutor" , func (t * testing.T ) {
590- agent , bus , cancelRoot , _ := setupActionLoop (t )
591- defer cancelRoot ()
592- mockExecutors := agent .executors .(* MockExecutorRegistry )
593-
594- action := Action {Type : ActionExploreApplication , Rationale : "Exploring the app" }
595- obsChan , unsub := bus .Subscribe (MessageTypeObservation )
596- defer unsub ()
597-
598- execResult := & ExecutionResult {Status : "success" , ObservationType : ObservedDOMChange }
599- mockExecutors .On ("Execute" , mock .Anything , action ).Return (execResult , nil ).Once ()
600-
601- err := bus .Post (context .Background (), CognitiveMessage {ID : "explore-msg" , Type : MessageTypeAction , Payload : action })
602- require .NoError (t , err )
603-
604- select {
605- case msg := <- obsChan :
606- bus .Acknowledge (msg )
607- mockExecutors .AssertExpectations (t )
608- case <- time .After (2 * time .Second ):
609- t .Fatal ("Timeout waiting for ActionExploreApplication to be dispatched" )
610- }
611- })
680+ // REMOVED: ExecuteLoginSequenceAction and ExploreApplicationAction tests are removed here
681+ // because they are now explicitly covered by the ExecutorRegistry tests (TestExecutorRegistry_Execute/RegisteredComplexActions_RoutedToBrowserExecutor).
612682
613683 t .Run ("FuzzEndpointAction_DispatchedToExecutor" , func (t * testing.T ) {
614684 agent , bus , cancelRoot , _ := setupActionLoop (t )
0 commit comments