"""Unit tests for the NestedEventStore class. These tests focus on the search_events method, which retrieves events from a remote API and applies filtering based on various criteria. """ from typing import Any from unittest.mock import MagicMock, patch import pytest from openhands.events.action import MessageAction from openhands.events.event import EventSource from openhands.events.event_filter import EventFilter from openhands.events.nested_event_store import NestedEventStore def create_mock_event( id: int, content: str, source: str = 'user', hidden: bool = False ) -> dict[str, Any]: """Create a properly formatted mock event dictionary.""" event_dict = { 'id': id, 'action': 'message', # This is required for the event_from_dict function 'args': { 'content': content, }, 'source': source, } # Add hidden as a property that will be set on the event after deserialization if hidden: event_dict['hidden'] = True return event_dict def create_mock_response( events: list[dict[str, Any]], has_more: bool = False ) -> MagicMock: """Helper function to create a mock HTTP response.""" mock_response = MagicMock() mock_response.json.return_value = {'events': events, 'has_more': has_more} return mock_response class TestNestedEventStore: """Tests for the NestedEventStore class.""" @pytest.fixture def event_store(self): """Create a NestedEventStore instance for testing.""" return NestedEventStore( base_url='http://test-api.example.com', sid='test-session', user_id='test-user', session_api_key='test-api-key', ) @patch('httpx.get') def test_search_events_basic(self, mock_get, event_store): """Test basic event retrieval without filters.""" # Setup mock response with two events mock_events = [ create_mock_event(1, 'Hello', 'user'), create_mock_event(2, 'World', 'agent'), ] mock_get.return_value = create_mock_response(mock_events) # Call the method events = list(event_store.search_events()) # Verify the results assert len(events) == 2 assert events[0].id == 1 assert events[1].id == 2 # Verify the API call mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_with_limit(self, mock_get, event_store): """Test event retrieval with a limit.""" # Setup mock response mock_events = [ create_mock_event(1, 'Hello', 'user'), create_mock_event(2, 'World', 'agent'), ] mock_get.return_value = create_mock_response(mock_events) # Call the method with a limit events = list(event_store.search_events(limit=1)) # Verify the results assert len(events) == 1 assert events[0].id == 1 # Verify the API call includes the limit parameter mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=False&limit=1', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_with_start_id(self, mock_get, event_store): """Test event retrieval with a specific start_id.""" # Setup mock response mock_events = [ create_mock_event(5, 'Hello', 'user'), create_mock_event(6, 'World', 'agent'), ] mock_get.return_value = create_mock_response(mock_events) # Call the method with a start_id events = list(event_store.search_events(start_id=5)) # Verify the results assert len(events) == 2 assert events[0].id == 5 assert events[1].id == 6 # Verify the API call includes the correct start_id mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=5&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_reverse_order(self, mock_get, event_store): """Test event retrieval in reverse order.""" # Setup mock response mock_events = [ create_mock_event(3, 'World', 'agent'), create_mock_event(2, 'Hello', 'user'), ] mock_get.return_value = create_mock_response(mock_events) # Call the method with reverse=True events = list(event_store.search_events(reverse=True)) # Verify the results assert len(events) == 2 assert events[0].id == 3 assert events[1].id == 2 # Verify the API call includes reverse=True mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=True', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_with_end_id(self, mock_get, event_store): """Test event retrieval with a specific end_id.""" # Setup mock response mock_events = [ create_mock_event(1, 'Hello', 'user'), create_mock_event(2, 'World', 'agent'), create_mock_event(3, 'End', 'user'), ] mock_get.return_value = create_mock_response(mock_events) # Call the method with an end_id events = list(event_store.search_events(end_id=3)) # Verify the results assert len(events) == 3 assert events[0].id == 1 assert events[1].id == 2 assert events[2].id == 3 # Verify the API call mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') @patch('openhands.events.event_filter.EventFilter.exclude') def test_search_events_with_filter(self, mock_exclude, mock_get, event_store): """Test event retrieval with an EventFilter.""" # Setup mock response with mixed events mock_events = [ create_mock_event(1, 'Hello', 'user'), create_mock_event(2, 'World', 'agent'), create_mock_event(3, 'Hidden', 'user'), ] mock_get.return_value = create_mock_response(mock_events) # Configure the mock to exclude the third event (simulating exclude_hidden=True) # Return True for the third event (to exclude it) and False for others mock_exclude.side_effect = [False, False, True] # Create a filter (the actual implementation doesn't matter since we're mocking exclude) event_filter = EventFilter() # Call the method with the filter events = list(event_store.search_events(filter=event_filter)) # Verify the results (should exclude the third event) assert len(events) == 2 assert events[0].id == 1 assert events[1].id == 2 # Verify the API call mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_with_source_filter(self, mock_get, event_store): """Test event retrieval with a source filter.""" # Setup mock response with mixed sources mock_events = [ create_mock_event(1, 'Hello', 'user'), create_mock_event(2, 'World', 'agent'), create_mock_event(3, 'Another', 'user'), ] mock_get.return_value = create_mock_response(mock_events) # Create a filter to include only user events event_filter = EventFilter(source='user') # Call the method with the filter events = list(event_store.search_events(filter=event_filter)) # Verify the results (should only include user events) assert len(events) == 2 assert events[0].id == 1 assert events[0].source == EventSource.USER assert events[1].id == 3 assert events[1].source == EventSource.USER # Verify the API call mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_with_type_filter(self, mock_get, event_store): """Test event retrieval with a type filter.""" # Setup mock response with different event types mock_events = [ create_mock_event(1, 'Hello', 'user'), # Create a different type of event (read) { 'id': 2, 'action': 'read', # Using the correct ActionType.READ value 'args': { 'path': '/test/file.txt', }, 'source': 'agent', }, create_mock_event(3, 'Another', 'user'), ] mock_get.return_value = create_mock_response(mock_events) # Create a filter to include only MessageAction events event_filter = EventFilter(include_types=(MessageAction,)) # Call the method with the filter events = list(event_store.search_events(filter=event_filter)) # Verify the results (should only include MessageAction events) assert len(events) == 2 assert events[0].id == 1 assert events[1].id == 3 # Verify the API call mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_pagination(self, mock_get, event_store): """Test event retrieval with pagination (has_more=True).""" # Setup first page response first_page_events = [ create_mock_event(1, 'Hello', 'user'), create_mock_event(2, 'World', 'agent'), ] first_response = create_mock_response(first_page_events, has_more=True) # Setup second page response second_page_events = [ create_mock_event(3, 'More', 'user'), create_mock_event(4, 'Data', 'agent'), ] second_response = create_mock_response(second_page_events, has_more=False) # Configure mock to return different responses on consecutive calls mock_get.side_effect = [first_response, second_response] # Call the method events = list(event_store.search_events()) # Verify the results (should include all events from both pages) assert len(events) == 4 assert events[0].id == 1 assert events[1].id == 2 assert events[2].id == 3 assert events[3].id == 4 # Verify the API calls assert mock_get.call_count == 2 # First call with start_id=0 mock_get.assert_any_call( 'http://test-api.example.com/events?start_id=0&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) # Second call with start_id=3 (after processing events with IDs 1 and 2) mock_get.assert_any_call( 'http://test-api.example.com/events?start_id=3&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_no_session_api_key(self, mock_get): """Test event retrieval without a session API key.""" # Create event store without session_api_key event_store = NestedEventStore( base_url='http://test-api.example.com', sid='test-session', user_id='test-user', ) # Setup mock response mock_events = [create_mock_event(1, 'Hello', 'user')] mock_get.return_value = create_mock_response(mock_events) # Call the method events = list(event_store.search_events()) # Verify the results assert len(events) == 1 # Verify the API call has no headers mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=False', headers={} ) @patch('httpx.get') def test_search_events_with_query_filter(self, mock_get, event_store): """Test event retrieval with a text query filter.""" # Setup mock response with different content mock_events = [ create_mock_event(1, 'Hello world', 'user'), create_mock_event(2, 'Python is great', 'agent'), create_mock_event(3, 'Hello Python', 'user'), ] mock_get.return_value = create_mock_response(mock_events) # Create a filter to search for 'Python' event_filter = EventFilter(query='Python') # Call the method with the filter events = list(event_store.search_events(filter=event_filter)) # Verify the results (should only include events with 'Python' in content) assert len(events) == 2 assert events[0].id == 2 assert events[1].id == 3 # Verify the API call mock_get.assert_called_once_with( 'http://test-api.example.com/events?start_id=0&reverse=False', headers={'X-Session-API-Key': 'test-api-key'}, ) @patch('httpx.get') def test_search_events_reverse_pagination_multiple_pages( self, mock_get, event_store ): """Ensure reverse pagination works across multiple server pages. We emulate the remote /events endpoint by using an in-memory EventStream as the backing store and having httpx.get return paginated JSON responses derived from it. """ from urllib.parse import parse_qs, urlparse from openhands.events.event import EventSource from openhands.events.observation import NullObservation from openhands.events.serialization.event import event_to_dict from openhands.events.stream import EventStream from openhands.storage.memory import InMemoryFileStore # Build a fake server-side store with many events so that server pagination kicks in fs = InMemoryFileStore() server_stream = EventStream('test-session', fs, user_id='test-user') total_events = 50 for i in range(total_events): server_stream.add_event(NullObservation(f'e{i}'), EventSource.AGENT) def server_side_get(url: str, headers: dict | None = None): # Parse query params like the FastAPI layer would receive parsed = urlparse(url) qs = parse_qs(parsed.query) start_id = int(qs.get('start_id', ['0'])[0]) reverse = qs.get('reverse', ['False'])[0] == 'True' end_id = int(qs['end_id'][0]) if 'end_id' in qs else None limit = int(qs.get('limit', ['20'])[0]) # server default = 20 # Emulate server route logic: request limit+1 to compute has_more events = list( server_stream.search_events( start_id=start_id, end_id=end_id, reverse=reverse, limit=limit + 1 ) ) has_more = len(events) > limit if has_more: events = events[:limit] mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { 'events': [event_to_dict(e) for e in events], 'has_more': has_more, } return mock_response mock_get.side_effect = server_side_get # Execute the nested search in reverse without a client-side limit results = list(event_store.search_events(reverse=True)) # Verify we received all events in descending order assert len(results) == total_events assert [e.id for e in results] == list(range(total_events - 1, -1, -1)) # Ensure multiple HTTP calls were made due to pagination assert mock_get.call_count >= 2