"""Test parallel agents' history persistence when interrupted.""" import asyncio from unittest.mock import MagicMock import pytest from cai.repl.commands.parallel import ( PARALLEL_AGENT_INSTANCES, PARALLEL_CONFIGS, ParallelCommand, ParallelConfig, ) from cai.sdk.agents.parallel_isolation import PARALLEL_ISOLATION class TestParallelInterruptHistory: """Test suite for parallel agent history persistence on interruption.""" def setup_method(self): """Set up test environment before each test.""" # Clear any existing configurations PARALLEL_CONFIGS.clear() PARALLEL_AGENT_INSTANCES.clear() PARALLEL_ISOLATION.clear_all_histories() self.command = ParallelCommand() def teardown_method(self): """Clean up after each test.""" PARALLEL_CONFIGS.clear() PARALLEL_AGENT_INSTANCES.clear() PARALLEL_ISOLATION.clear_all_histories() def test_parallel_history_saved_on_interrupt(self): """Test that parallel agents' histories are saved when interrupted with Ctrl+C.""" # Create mock agents with message histories def create_mock_agent(name, agent_id): mock_agent = MagicMock() mock_agent.name = name mock_agent.model = MagicMock() mock_agent.model.message_history = [] mock_agent.model.agent_id = agent_id # Mock the add_to_message_history method to append to the list def add_message(msg): mock_agent.model.message_history.append(msg) # Also update PARALLEL_ISOLATION if PARALLEL_ISOLATION.is_parallel_mode() and agent_id: PARALLEL_ISOLATION.update_isolated_history(agent_id, msg) mock_agent.model.add_to_message_history = add_message return mock_agent # Setup parallel configs config1 = ParallelConfig("redteam_agent") config1.id = "P1" config2 = ParallelConfig("bug_bounter_agent") config2.id = "P2" PARALLEL_CONFIGS.extend([config1, config2]) # Create mock agents agent1 = create_mock_agent("Red Team Agent", "P1") agent2 = create_mock_agent("Bug Bounty Hunter", "P2") # Store them in PARALLEL_AGENT_INSTANCES PARALLEL_AGENT_INSTANCES[(config1.agent_name, 1)] = agent1 PARALLEL_AGENT_INSTANCES[(config2.agent_name, 2)] = agent2 # Enable parallel mode PARALLEL_ISOLATION._parallel_mode = True # Add initial history base_history = [{"role": "user", "content": "Initial message"}] PARALLEL_ISOLATION.transfer_to_parallel(base_history, 2, ["P1", "P2"]) # First, set up the agents' message histories with the initial history agent1.model.message_history = base_history.copy() agent2.model.message_history = base_history.copy() # Simulate agents adding messages during execution agent1.model.add_to_message_history( {"role": "assistant", "content": "Response from agent 1"} ) agent2.model.add_to_message_history( {"role": "assistant", "content": "Response from agent 2"} ) # Simulate interruption by saving histories (this is what our fix does) for idx, config in enumerate(PARALLEL_CONFIGS, 1): instance_key = (config.agent_name, idx) if instance_key in PARALLEL_AGENT_INSTANCES: instance_agent = PARALLEL_AGENT_INSTANCES[instance_key] if hasattr(instance_agent, "model") and hasattr( instance_agent.model, "message_history" ): agent_id = config.id or f"P{idx}" PARALLEL_ISOLATION.replace_isolated_history( agent_id, instance_agent.model.message_history ) # Verify histories were saved history1 = PARALLEL_ISOLATION.get_isolated_history("P1") history2 = PARALLEL_ISOLATION.get_isolated_history("P2") assert len(history1) == 2 # Initial + agent response assert history1[0]["content"] == "Initial message" assert history1[1]["content"] == "Response from agent 1" assert len(history2) == 2 # Initial + agent response assert history2[0]["content"] == "Initial message" assert history2[1]["content"] == "Response from agent 2" @pytest.mark.asyncio async def test_async_cancellation_saves_history(self): """Test that histories are saved when async tasks are cancelled.""" # Setup parallel configs config = ParallelConfig("redteam_agent") config.id = "P1" # Create a mock agent mock_agent = MagicMock() mock_agent.name = "Red Team Agent" mock_agent.model = MagicMock() mock_agent.model.message_history = [ {"role": "user", "content": "Test message"}, {"role": "assistant", "content": "Test response"}, ] # Enable parallel mode PARALLEL_ISOLATION._parallel_mode = True # Simulate the exception handler saving history try: # Simulate asyncio.CancelledError raise asyncio.CancelledError() except asyncio.CancelledError: # This is what our fix does in run_agent_instance if mock_agent and config.id: if hasattr(mock_agent, "model") and hasattr(mock_agent.model, "message_history"): PARALLEL_ISOLATION.replace_isolated_history( config.id, mock_agent.model.message_history ) # Verify history was saved saved_history = PARALLEL_ISOLATION.get_isolated_history("P1") assert saved_history is not None assert len(saved_history) == 2 assert saved_history[0]["content"] == "Test message" assert saved_history[1]["content"] == "Test response" def test_history_command_shows_saved_histories(self): """Test that /history command can access saved parallel agent histories.""" from cai.sdk.agents.simple_agent_manager import AGENT_MANAGER # Setup parallel mode with some history PARALLEL_ISOLATION._parallel_mode = True # Setup parallel configs config1 = ParallelConfig("redteam_agent") config1.id = "P1" config2 = ParallelConfig("bug_bounter_agent") config2.id = "P2" PARALLEL_CONFIGS.extend([config1, config2]) # Add test histories history1 = [ {"role": "user", "content": "Message to agent 1"}, {"role": "assistant", "content": "Response from agent 1"}, ] history2 = [ {"role": "user", "content": "Message to agent 2"}, {"role": "assistant", "content": "Response from agent 2"}, ] PARALLEL_ISOLATION.replace_isolated_history("P1", history1) PARALLEL_ISOLATION.replace_isolated_history("P2", history2) # Sync with AGENT_MANAGER (simulating what would happen after interruption) AGENT_MANAGER.clear_all_histories() # Add histories directly without registering for msg in history1: AGENT_MANAGER.add_to_history("Red Team Agent #1", msg) for msg in history2: AGENT_MANAGER.add_to_history("Bug Bounty Hunter #2", msg) # Verify histories are accessible via AGENT_MANAGER agent1_history = AGENT_MANAGER.get_message_history("Red Team Agent #1") agent2_history = AGENT_MANAGER.get_message_history("Bug Bounty Hunter #2") assert len(agent1_history) == 2 assert agent1_history[0]["content"] == "Message to agent 1" assert len(agent2_history) == 2 assert agent2_history[0]["content"] == "Message to agent 2" # Also verify PARALLEL_ISOLATION still has the histories iso_hist1 = PARALLEL_ISOLATION.get_isolated_history("P1") iso_hist2 = PARALLEL_ISOLATION.get_isolated_history("P2") assert len(iso_hist1) == 2 assert len(iso_hist2) == 2