"""Tests for loop detection module.""" import pytest from app.services.safety.exceptions import LoopDetectedError from app.services.safety.loops.detector import ( ActionSignature, LoopBreaker, LoopDetector, ) from app.services.safety.models import ( ActionMetadata, ActionRequest, ActionType, AutonomyLevel, ) @pytest.fixture def detector() -> LoopDetector: """Create a fresh LoopDetector with low thresholds for testing.""" return LoopDetector( history_size=20, max_exact_repetitions=3, max_semantic_repetitions=5, ) @pytest.fixture def sample_metadata() -> ActionMetadata: """Create sample action metadata.""" return ActionMetadata( agent_id="test-agent", session_id="test-session", autonomy_level=AutonomyLevel.MILESTONE, ) def create_action( metadata: ActionMetadata, tool_name: str, resource: str = "/tmp/test.txt", # noqa: S108 arguments: dict | None = None, ) -> ActionRequest: """Helper to create test actions.""" return ActionRequest( action_type=ActionType.FILE_READ, tool_name=tool_name, resource=resource, arguments=arguments or {}, metadata=metadata, ) class TestActionSignature: """Tests for ActionSignature class.""" def test_exact_key_includes_args(self, sample_metadata: ActionMetadata) -> None: """Test that exact key includes argument hash.""" action1 = create_action(sample_metadata, "file_read", arguments={"path": "a"}) action2 = create_action(sample_metadata, "file_read", arguments={"path": "b"}) sig1 = ActionSignature(action1) sig2 = ActionSignature(action2) assert sig1.exact_key() != sig2.exact_key() def test_semantic_key_ignores_args(self, sample_metadata: ActionMetadata) -> None: """Test that semantic key ignores arguments.""" action1 = create_action(sample_metadata, "file_read", arguments={"path": "a"}) action2 = create_action(sample_metadata, "file_read", arguments={"path": "b"}) sig1 = ActionSignature(action1) sig2 = ActionSignature(action2) assert sig1.semantic_key() == sig2.semantic_key() def test_type_key(self, sample_metadata: ActionMetadata) -> None: """Test type key extraction.""" action = create_action(sample_metadata, "file_read") sig = ActionSignature(action) assert sig.type_key() == "file_read" class TestLoopDetector: """Tests for LoopDetector class.""" @pytest.mark.asyncio async def test_no_loop_on_first_action( self, detector: LoopDetector, sample_metadata: ActionMetadata, ) -> None: """Test that first action is never a loop.""" action = create_action(sample_metadata, "file_read") is_loop, loop_type = await detector.check(action) assert is_loop is False assert loop_type is None @pytest.mark.asyncio async def test_exact_loop_detection( self, detector: LoopDetector, sample_metadata: ActionMetadata, ) -> None: """Test detection of exact repetitions.""" action = create_action( sample_metadata, "file_read", resource="/tmp/same.txt", # noqa: S108 arguments={"path": "/tmp/same.txt"}, # noqa: S108 ) # Record the same action 3 times (threshold) for _ in range(3): await detector.record(action) # Next should be detected as a loop is_loop, loop_type = await detector.check(action) assert is_loop is True assert loop_type == "exact" @pytest.mark.asyncio async def test_semantic_loop_detection( self, detector: LoopDetector, sample_metadata: ActionMetadata, ) -> None: """Test detection of semantic (similar) repetitions.""" # Record same tool/resource but different arguments test_resource = "/tmp/test.txt" # noqa: S108 for i in range(5): action = create_action( sample_metadata, "file_read", resource=test_resource, arguments={"offset": i}, ) await detector.record(action) # Next similar action should be detected as semantic loop action = create_action( sample_metadata, "file_read", resource=test_resource, arguments={"offset": 100}, ) is_loop, loop_type = await detector.check(action) assert is_loop is True assert loop_type == "semantic" @pytest.mark.asyncio async def test_oscillation_detection( self, detector: LoopDetector, sample_metadata: ActionMetadata, ) -> None: """Test detection of A→B→A→B oscillation pattern.""" action_a = create_action(sample_metadata, "tool_a", resource="/a") action_b = create_action(sample_metadata, "tool_b", resource="/b") # Create A→B→A pattern await detector.record(action_a) await detector.record(action_b) await detector.record(action_a) # Fourth action completing A→B→A→B should be detected as oscillation is_loop, loop_type = await detector.check(action_b) assert is_loop is True assert loop_type == "oscillation" @pytest.mark.asyncio async def test_different_actions_no_loop( self, detector: LoopDetector, sample_metadata: ActionMetadata, ) -> None: """Test that different actions don't trigger loops.""" for i in range(10): action = create_action( sample_metadata, f"tool_{i}", resource=f"/resource_{i}", ) is_loop, _ = await detector.check(action) assert is_loop is False await detector.record(action) @pytest.mark.asyncio async def test_check_and_raise( self, detector: LoopDetector, sample_metadata: ActionMetadata, ) -> None: """Test check_and_raise raises on loop detection.""" action = create_action(sample_metadata, "file_read") # Record threshold number of times for _ in range(3): await detector.record(action) # Should raise with pytest.raises(LoopDetectedError) as exc_info: await detector.check_and_raise(action) assert "exact" in exc_info.value.loop_type.lower() @pytest.mark.asyncio async def test_clear_history( self, detector: LoopDetector, sample_metadata: ActionMetadata, ) -> None: """Test clearing agent history.""" action = create_action(sample_metadata, "file_read") # Record multiple times for _ in range(3): await detector.record(action) # Clear history await detector.clear_history(sample_metadata.agent_id) # Should no longer detect loop is_loop, _ = await detector.check(action) assert is_loop is False @pytest.mark.asyncio async def test_per_agent_history( self, detector: LoopDetector, ) -> None: """Test that history is tracked per agent.""" metadata1 = ActionMetadata(agent_id="agent-1", session_id="s1") metadata2 = ActionMetadata(agent_id="agent-2", session_id="s2") action1 = create_action(metadata1, "file_read") action2 = create_action(metadata2, "file_read") # Record for agent 1 (threshold times) for _ in range(3): await detector.record(action1) # Agent 1 should detect loop is_loop1, _ = await detector.check(action1) assert is_loop1 is True # Agent 2 should not detect loop is_loop2, _ = await detector.check(action2) assert is_loop2 is False @pytest.mark.asyncio async def test_get_stats( self, detector: LoopDetector, sample_metadata: ActionMetadata, ) -> None: """Test getting loop detection stats.""" for i in range(5): action = create_action( sample_metadata, f"tool_{i % 2}", # Alternate between 2 tools resource=f"/resource_{i}", ) await detector.record(action) stats = await detector.get_stats(sample_metadata.agent_id) assert stats["history_size"] == 5 assert len(stats["action_type_counts"]) > 0 class TestLoopBreaker: """Tests for LoopBreaker class.""" @pytest.mark.asyncio async def test_suggest_alternatives_exact( self, sample_metadata: ActionMetadata, ) -> None: """Test suggestions for exact loops.""" action = create_action(sample_metadata, "file_read") suggestions = await LoopBreaker.suggest_alternatives(action, "exact") assert len(suggestions) > 0 assert "same action" in suggestions[0].lower() @pytest.mark.asyncio async def test_suggest_alternatives_semantic( self, sample_metadata: ActionMetadata, ) -> None: """Test suggestions for semantic loops.""" action = create_action(sample_metadata, "file_read") suggestions = await LoopBreaker.suggest_alternatives(action, "semantic") assert len(suggestions) > 0 assert "similar" in suggestions[0].lower() @pytest.mark.asyncio async def test_suggest_alternatives_oscillation( self, sample_metadata: ActionMetadata, ) -> None: """Test suggestions for oscillation loops.""" action = create_action(sample_metadata, "file_read") suggestions = await LoopBreaker.suggest_alternatives(action, "oscillation") assert len(suggestions) > 0 assert "oscillat" in suggestions[0].lower()