"""Tests for HITL (Human-in-the-Loop) Manager. Tests cover: - ApprovalQueue: add, get, complete, wait, cancel, cleanup - HITLManager: lifecycle, request, wait, approve, deny, cancel, notifications - Edge cases: timeouts, concurrent access, notification errors """ import asyncio from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from app.services.safety.exceptions import ( ApprovalDeniedError, ApprovalRequiredError, ApprovalTimeoutError, ) from app.services.safety.hitl.manager import ApprovalQueue, HITLManager from app.services.safety.models import ( ActionMetadata, ActionRequest, ActionType, ApprovalRequest, ApprovalResponse, ApprovalStatus, AutonomyLevel, ) # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def action_metadata() -> ActionMetadata: """Create standard action metadata for tests.""" return ActionMetadata( agent_id="test-agent", project_id="test-project", session_id="test-session", autonomy_level=AutonomyLevel.MILESTONE, ) @pytest.fixture def action_request(action_metadata: ActionMetadata) -> ActionRequest: """Create a standard action request for tests.""" return ActionRequest( id="action-123", action_type=ActionType.FILE_WRITE, tool_name="file_write", resource="/path/to/file.txt", arguments={"content": "test content"}, metadata=action_metadata, is_destructive=True, ) @pytest.fixture def approval_request(action_request: ActionRequest) -> ApprovalRequest: """Create a standard approval request for tests.""" return ApprovalRequest( id="approval-123", action=action_request, reason="File write requires approval", urgency="normal", timeout_seconds=30, expires_at=datetime.utcnow() + timedelta(seconds=30), ) @pytest_asyncio.fixture async def approval_queue() -> ApprovalQueue: """Create an ApprovalQueue for testing.""" return ApprovalQueue() @pytest_asyncio.fixture async def hitl_manager() -> HITLManager: """Create an HITLManager for testing.""" with patch("app.services.safety.hitl.manager.get_safety_config") as mock_config: mock_config.return_value = MagicMock(hitl_default_timeout=30) manager = HITLManager(default_timeout=10) yield manager # Ensure cleanup await manager.stop() # ============================================================================ # ApprovalQueue Tests # ============================================================================ class TestApprovalQueue: """Tests for the ApprovalQueue class.""" @pytest.mark.asyncio async def test_add_and_get_pending( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test adding and retrieving a pending request.""" await approval_queue.add(approval_request) pending = await approval_queue.get_pending(approval_request.id) assert pending is not None assert pending.id == approval_request.id assert pending.reason == "File write requires approval" @pytest.mark.asyncio async def test_get_pending_nonexistent( self, approval_queue: ApprovalQueue, ) -> None: """Test getting a non-existent pending request returns None.""" pending = await approval_queue.get_pending("nonexistent-id") assert pending is None @pytest.mark.asyncio async def test_complete_success( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test completing an approval request.""" await approval_queue.add(approval_request) response = ApprovalResponse( request_id=approval_request.id, status=ApprovalStatus.APPROVED, decided_by="admin", reason="Looks good", ) success = await approval_queue.complete(response) assert success is True # Should no longer be pending pending = await approval_queue.get_pending(approval_request.id) assert pending is None @pytest.mark.asyncio async def test_complete_nonexistent( self, approval_queue: ApprovalQueue, ) -> None: """Test completing a non-existent request returns False.""" response = ApprovalResponse( request_id="nonexistent-id", status=ApprovalStatus.APPROVED, ) success = await approval_queue.complete(response) assert success is False @pytest.mark.asyncio async def test_complete_notifies_waiters( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test that completing a request notifies waiters.""" await approval_queue.add(approval_request) # Start waiting in background wait_task = asyncio.create_task( approval_queue.wait_for_response(approval_request.id, timeout_seconds=5.0) ) # Give the wait task time to start await asyncio.sleep(0.01) # Complete the request response = ApprovalResponse( request_id=approval_request.id, status=ApprovalStatus.APPROVED, decided_by="admin", ) await approval_queue.complete(response) # Wait should return the response result = await wait_task assert result is not None assert result.status == ApprovalStatus.APPROVED assert result.decided_by == "admin" @pytest.mark.asyncio async def test_wait_for_response_timeout( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test waiting for a response that times out.""" await approval_queue.add(approval_request) result = await approval_queue.wait_for_response( approval_request.id, timeout_seconds=0.05, # 50ms timeout ) assert result is None @pytest.mark.asyncio async def test_wait_for_response_already_completed( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test waiting for an already completed response.""" await approval_queue.add(approval_request) # Complete first response = ApprovalResponse( request_id=approval_request.id, status=ApprovalStatus.DENIED, reason="Not allowed", ) await approval_queue.complete(response) # Wait should return the completed response result = await approval_queue.wait_for_response( approval_request.id, timeout_seconds=1.0, ) assert result is not None assert result.status == ApprovalStatus.DENIED @pytest.mark.asyncio async def test_wait_for_nonexistent_request( self, approval_queue: ApprovalQueue, ) -> None: """Test waiting for a request that was never added.""" result = await approval_queue.wait_for_response( "nonexistent-id", timeout_seconds=0.1, ) assert result is None @pytest.mark.asyncio async def test_list_pending( self, approval_queue: ApprovalQueue, action_request: ActionRequest, ) -> None: """Test listing all pending requests.""" # Add multiple requests req1 = ApprovalRequest( id="req-1", action=action_request, reason="Reason 1", ) req2 = ApprovalRequest( id="req-2", action=action_request, reason="Reason 2", ) await approval_queue.add(req1) await approval_queue.add(req2) pending = await approval_queue.list_pending() assert len(pending) == 2 ids = {r.id for r in pending} assert ids == {"req-1", "req-2"} @pytest.mark.asyncio async def test_cancel_success( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test cancelling a pending request.""" await approval_queue.add(approval_request) success = await approval_queue.cancel(approval_request.id) assert success is True # Should no longer be pending pending = await approval_queue.get_pending(approval_request.id) assert pending is None @pytest.mark.asyncio async def test_cancel_nonexistent( self, approval_queue: ApprovalQueue, ) -> None: """Test cancelling a non-existent request.""" success = await approval_queue.cancel("nonexistent-id") assert success is False @pytest.mark.asyncio async def test_cancel_notifies_waiters( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test that cancellation notifies waiters.""" await approval_queue.add(approval_request) # Start waiting in background wait_task = asyncio.create_task( approval_queue.wait_for_response(approval_request.id, timeout_seconds=5.0) ) await asyncio.sleep(0.01) # Cancel the request await approval_queue.cancel(approval_request.id) # Wait should return the cancelled response result = await wait_task assert result is not None assert result.status == ApprovalStatus.CANCELLED @pytest.mark.asyncio async def test_cleanup_expired( self, approval_queue: ApprovalQueue, action_request: ActionRequest, ) -> None: """Test cleaning up expired requests.""" # Create an already-expired request expired_request = ApprovalRequest( id="expired-req", action=action_request, reason="Expired reason", expires_at=datetime.utcnow() - timedelta(seconds=10), # Already expired ) await approval_queue.add(expired_request) # Create a valid request valid_request = ApprovalRequest( id="valid-req", action=action_request, reason="Valid reason", expires_at=datetime.utcnow() + timedelta(seconds=300), ) await approval_queue.add(valid_request) # Cleanup should remove expired count = await approval_queue.cleanup_expired() assert count == 1 # Expired should be gone pending = await approval_queue.get_pending("expired-req") assert pending is None # Valid should remain pending = await approval_queue.get_pending("valid-req") assert pending is not None @pytest.mark.asyncio async def test_cleanup_expired_notifies_waiters( self, approval_queue: ApprovalQueue, action_request: ActionRequest, ) -> None: """Test that cleanup notifies waiters of expired requests.""" expired_request = ApprovalRequest( id="expired-req", action=action_request, reason="Expired", expires_at=datetime.utcnow() - timedelta(seconds=1), ) await approval_queue.add(expired_request) # Start waiting wait_task = asyncio.create_task( approval_queue.wait_for_response("expired-req", timeout_seconds=5.0) ) await asyncio.sleep(0.01) # Cleanup await approval_queue.cleanup_expired() # Wait should return timeout response result = await wait_task assert result is not None assert result.status == ApprovalStatus.TIMEOUT # ============================================================================ # HITLManager Tests # ============================================================================ class TestHITLManager: """Tests for the HITLManager class.""" @pytest.mark.asyncio async def test_start_and_stop(self, hitl_manager: HITLManager) -> None: """Test starting and stopping the manager.""" await hitl_manager.start() assert hitl_manager._running is True assert hitl_manager._cleanup_task is not None await hitl_manager.stop() assert hitl_manager._running is False @pytest.mark.asyncio async def test_start_idempotent(self, hitl_manager: HITLManager) -> None: """Test that starting twice is safe.""" await hitl_manager.start() task1 = hitl_manager._cleanup_task await hitl_manager.start() task2 = hitl_manager._cleanup_task # Should be the same task assert task1 is task2 await hitl_manager.stop() @pytest.mark.asyncio async def test_request_approval( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test creating an approval request.""" request = await hitl_manager.request_approval( action=action_request, reason="Destructive action", timeout_seconds=60, urgency="high", context={"extra": "info"}, ) assert request.id is not None assert request.action.id == action_request.id assert request.reason == "Destructive action" assert request.urgency == "high" assert request.timeout_seconds == 60 assert request.context == {"extra": "info"} assert request.expires_at is not None @pytest.mark.asyncio async def test_request_approval_default_timeout( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test approval request uses default timeout.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) # Should use the manager's default timeout (10 seconds from fixture) assert request.timeout_seconds == 10 @pytest.mark.asyncio async def test_wait_for_approval_success( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test waiting for an approved request.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", timeout_seconds=5, ) # Approve in background async def approve_later(): await asyncio.sleep(0.05) await hitl_manager.approve( request_id=request.id, decided_by="admin", reason="Approved", ) _task = asyncio.create_task(approve_later()) # noqa: RUF006 response = await hitl_manager.wait_for_approval(request.id) assert response.status == ApprovalStatus.APPROVED assert response.decided_by == "admin" @pytest.mark.asyncio async def test_wait_for_approval_denied( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test waiting for a denied request raises error.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) # Deny in background async def deny_later(): await asyncio.sleep(0.05) await hitl_manager.deny( request_id=request.id, decided_by="admin", reason="Not allowed", ) _task = asyncio.create_task(deny_later()) # noqa: RUF006 with pytest.raises(ApprovalDeniedError) as exc_info: await hitl_manager.wait_for_approval(request.id) assert exc_info.value.approval_id == request.id assert exc_info.value.denied_by == "admin" assert "Not allowed" in str(exc_info.value.denial_reason) @pytest.mark.asyncio async def test_wait_for_approval_timeout( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test waiting for approval that times out.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", timeout_seconds=1, # Short timeout ) with pytest.raises(ApprovalTimeoutError) as exc_info: await hitl_manager.wait_for_approval(request.id, timeout_seconds=0.1) assert exc_info.value.approval_id == request.id @pytest.mark.asyncio async def test_wait_for_approval_cancelled( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test waiting for a cancelled request raises error.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) # Cancel in background async def cancel_later(): await asyncio.sleep(0.05) await hitl_manager.cancel(request.id) _task = asyncio.create_task(cancel_later()) # noqa: RUF006 with pytest.raises(ApprovalDeniedError) as exc_info: await hitl_manager.wait_for_approval(request.id) assert "Cancelled" in str(exc_info.value.denial_reason) @pytest.mark.asyncio async def test_wait_for_approval_not_found( self, hitl_manager: HITLManager, ) -> None: """Test waiting for a non-existent request raises error.""" with pytest.raises(ApprovalRequiredError) as exc_info: await hitl_manager.wait_for_approval("nonexistent-id") assert "nonexistent-id" in str(exc_info.value) @pytest.mark.asyncio async def test_approve_success( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test approving a request.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) success = await hitl_manager.approve( request_id=request.id, decided_by="admin", reason="Looks good", modifications={"timeout": 30}, ) assert success is True # Should no longer be pending pending = await hitl_manager.get_request(request.id) assert pending is None @pytest.mark.asyncio async def test_approve_nonexistent( self, hitl_manager: HITLManager, ) -> None: """Test approving a non-existent request.""" success = await hitl_manager.approve( request_id="nonexistent-id", decided_by="admin", ) assert success is False @pytest.mark.asyncio async def test_deny_success( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test denying a request.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) success = await hitl_manager.deny( request_id=request.id, decided_by="admin", reason="Security concern", ) assert success is True @pytest.mark.asyncio async def test_deny_nonexistent( self, hitl_manager: HITLManager, ) -> None: """Test denying a non-existent request.""" success = await hitl_manager.deny( request_id="nonexistent-id", decided_by="admin", ) assert success is False @pytest.mark.asyncio async def test_cancel_success( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test cancelling a request.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) success = await hitl_manager.cancel(request.id) assert success is True @pytest.mark.asyncio async def test_list_pending( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test listing pending requests.""" await hitl_manager.request_approval(action=action_request, reason="Test 1") await hitl_manager.request_approval(action=action_request, reason="Test 2") pending = await hitl_manager.list_pending() assert len(pending) == 2 @pytest.mark.asyncio async def test_get_request( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test getting a specific request.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) retrieved = await hitl_manager.get_request(request.id) assert retrieved is not None assert retrieved.id == request.id @pytest.mark.asyncio async def test_get_request_nonexistent( self, hitl_manager: HITLManager, ) -> None: """Test getting a non-existent request.""" retrieved = await hitl_manager.get_request("nonexistent-id") assert retrieved is None # ============================================================================ # Notification Handler Tests # ============================================================================ class TestHITLNotifications: """Tests for notification handler functionality.""" @pytest.mark.asyncio async def test_add_notification_handler( self, hitl_manager: HITLManager, ) -> None: """Test adding a notification handler.""" handler = MagicMock() hitl_manager.add_notification_handler(handler) assert handler in hitl_manager._notification_handlers @pytest.mark.asyncio async def test_remove_notification_handler( self, hitl_manager: HITLManager, ) -> None: """Test removing a notification handler.""" handler = MagicMock() hitl_manager.add_notification_handler(handler) hitl_manager.remove_notification_handler(handler) assert handler not in hitl_manager._notification_handlers @pytest.mark.asyncio async def test_remove_nonexistent_handler( self, hitl_manager: HITLManager, ) -> None: """Test removing a handler that was never added.""" handler = MagicMock() # Should not raise hitl_manager.remove_notification_handler(handler) @pytest.mark.asyncio async def test_sync_handler_called_on_approval_request( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test that sync handlers are called on approval request.""" handler = MagicMock() hitl_manager.add_notification_handler(handler) await hitl_manager.request_approval( action=action_request, reason="Test", ) handler.assert_called_once() args = handler.call_args[0] assert args[0] == "approval_requested" assert isinstance(args[1], ApprovalRequest) @pytest.mark.asyncio async def test_async_handler_called_on_approval_request( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test that async handlers are called on approval request.""" handler = AsyncMock() hitl_manager.add_notification_handler(handler) await hitl_manager.request_approval( action=action_request, reason="Test", ) handler.assert_called_once() args = handler.call_args[0] assert args[0] == "approval_requested" @pytest.mark.asyncio async def test_handler_called_on_approval_granted( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test that handlers are called when approval is granted.""" handler = MagicMock() hitl_manager.add_notification_handler(handler) request = await hitl_manager.request_approval( action=action_request, reason="Test", ) # Reset to check only approve call handler.reset_mock() await hitl_manager.approve(request.id, decided_by="admin") handler.assert_called_once() args = handler.call_args[0] assert args[0] == "approval_granted" @pytest.mark.asyncio async def test_handler_called_on_approval_denied( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test that handlers are called when approval is denied.""" handler = MagicMock() hitl_manager.add_notification_handler(handler) request = await hitl_manager.request_approval( action=action_request, reason="Test", ) handler.reset_mock() await hitl_manager.deny(request.id, decided_by="admin", reason="No") handler.assert_called_once() args = handler.call_args[0] assert args[0] == "approval_denied" @pytest.mark.asyncio async def test_handler_error_logged_not_raised( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test that handler errors are logged but don't crash the manager.""" def bad_handler(event_type, data): raise ValueError("Handler exploded!") hitl_manager.add_notification_handler(bad_handler) # Should not raise despite the handler error with patch("app.services.safety.hitl.manager.logger") as mock_logger: await hitl_manager.request_approval( action=action_request, reason="Test", ) # Error should be logged mock_logger.error.assert_called() @pytest.mark.asyncio async def test_async_handler_error_logged( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test that async handler errors are logged.""" async def bad_async_handler(event_type, data): raise RuntimeError("Async handler exploded!") hitl_manager.add_notification_handler(bad_async_handler) with patch("app.services.safety.hitl.manager.logger") as mock_logger: await hitl_manager.request_approval( action=action_request, reason="Test", ) mock_logger.error.assert_called() # ============================================================================ # Edge Cases and Potential Bug Detection # ============================================================================ class TestHITLEdgeCases: """Edge cases that could reveal hidden bugs.""" @pytest.mark.asyncio async def test_concurrent_complete_same_request( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test that concurrent completions are handled safely.""" await approval_queue.add(approval_request) # Try to complete twice concurrently response1 = ApprovalResponse( request_id=approval_request.id, status=ApprovalStatus.APPROVED, decided_by="admin1", ) response2 = ApprovalResponse( request_id=approval_request.id, status=ApprovalStatus.DENIED, decided_by="admin2", ) results = await asyncio.gather( approval_queue.complete(response1), approval_queue.complete(response2), ) # Only one should succeed assert sum(results) == 1 @pytest.mark.asyncio async def test_double_cancel( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test that double cancellation is safe.""" await approval_queue.add(approval_request) success1 = await approval_queue.cancel(approval_request.id) success2 = await approval_queue.cancel(approval_request.id) assert success1 is True assert success2 is False @pytest.mark.asyncio async def test_complete_after_cancel( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test that completing after cancellation fails.""" await approval_queue.add(approval_request) await approval_queue.cancel(approval_request.id) response = ApprovalResponse( request_id=approval_request.id, status=ApprovalStatus.APPROVED, ) success = await approval_queue.complete(response) assert success is False @pytest.mark.asyncio async def test_wait_after_complete( self, approval_queue: ApprovalQueue, approval_request: ApprovalRequest, ) -> None: """Test waiting after request is already completed.""" await approval_queue.add(approval_request) response = ApprovalResponse( request_id=approval_request.id, status=ApprovalStatus.APPROVED, decided_by="admin", ) await approval_queue.complete(response) # Wait should return immediately with the cached response result = await approval_queue.wait_for_response( approval_request.id, timeout_seconds=1.0, ) assert result is not None assert result.status == ApprovalStatus.APPROVED @pytest.mark.asyncio async def test_approval_with_modifications( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test approval with action modifications.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) modifications = {"timeout": 60, "sandbox": True} async def approve_later(): await asyncio.sleep(0.05) await hitl_manager.approve( request_id=request.id, decided_by="admin", modifications=modifications, ) _task = asyncio.create_task(approve_later()) # noqa: RUF006 response = await hitl_manager.wait_for_approval(request.id) assert response.modifications == modifications @pytest.mark.asyncio async def test_cleanup_while_waiting( self, approval_queue: ApprovalQueue, action_request: ActionRequest, ) -> None: """Test cleanup running while someone is waiting.""" # Create an expired request expired_request = ApprovalRequest( id="expired-req", action=action_request, reason="Expired", expires_at=datetime.utcnow() - timedelta(seconds=1), ) await approval_queue.add(expired_request) # Start waiting wait_task = asyncio.create_task( approval_queue.wait_for_response("expired-req", timeout_seconds=5.0) ) await asyncio.sleep(0.01) # Run cleanup count = await approval_queue.cleanup_expired() assert count == 1 # Wait should get timeout response result = await wait_task assert result is not None assert result.status == ApprovalStatus.TIMEOUT @pytest.mark.asyncio async def test_very_short_timeout( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test handling of very short timeout values.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", timeout_seconds=1, ) # Zero timeout should trigger immediately with pytest.raises(ApprovalTimeoutError): await hitl_manager.wait_for_approval(request.id, timeout_seconds=0.01) @pytest.mark.asyncio async def test_empty_reason_approval( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test approval with empty reason.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", ) success = await hitl_manager.approve( request_id=request.id, decided_by="admin", reason=None, # No reason provided ) assert success is True @pytest.mark.asyncio async def test_wait_for_approval_status_timeout_from_queue( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test that TIMEOUT status from queue raises ApprovalTimeoutError.""" request = await hitl_manager.request_approval( action=action_request, reason="Test", timeout_seconds=10, ) # Simulate cleanup marking as timeout async def simulate_cleanup(): await asyncio.sleep(0.05) response = ApprovalResponse( request_id=request.id, status=ApprovalStatus.TIMEOUT, reason="Timed out by cleanup", ) await hitl_manager._queue.complete(response) _task = asyncio.create_task(simulate_cleanup()) # noqa: RUF006 with pytest.raises(ApprovalTimeoutError): await hitl_manager.wait_for_approval(request.id) @pytest.mark.asyncio async def test_multiple_handlers_all_called( self, hitl_manager: HITLManager, action_request: ActionRequest, ) -> None: """Test that multiple handlers are all called.""" handler1 = MagicMock() handler2 = MagicMock() handler3 = AsyncMock() hitl_manager.add_notification_handler(handler1) hitl_manager.add_notification_handler(handler2) hitl_manager.add_notification_handler(handler3) await hitl_manager.request_approval( action=action_request, reason="Test", ) handler1.assert_called_once() handler2.assert_called_once() handler3.assert_called_once() @pytest.mark.asyncio async def test_periodic_cleanup_runs( self, hitl_manager: HITLManager, ) -> None: """Test that periodic cleanup task runs without errors.""" await hitl_manager.start() # Let it run briefly await asyncio.sleep(0.1) # Should still be running assert hitl_manager._running is True assert hitl_manager._cleanup_task is not None assert not hitl_manager._cleanup_task.done() await hitl_manager.stop() @pytest.mark.asyncio async def test_stop_cancels_cleanup_task( self, hitl_manager: HITLManager, ) -> None: """Test that stop properly cancels the cleanup task.""" await hitl_manager.start() cleanup_task = hitl_manager._cleanup_task await hitl_manager.stop() # Task should be cancelled assert cleanup_task.cancelled() or cleanup_task.done()