diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index bce9eb6681..cd555b5967 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -220,20 +220,43 @@ async def new_conversation( async def search_conversations( page_id: str | None = None, limit: int = 20, + selected_repository: str | None = None, + conversation_trigger: ConversationTrigger | None = None, conversation_store: ConversationStore = Depends(get_conversation_store), ) -> ConversationInfoResultSet: conversation_metadata_result_set = await conversation_store.search(page_id, limit) - # Filter out conversations older than max_age + # Apply filters at API level + filtered_results = [] now = datetime.now(timezone.utc) max_age = config.conversation_max_age_seconds - filtered_results = [ - conversation - for conversation in conversation_metadata_result_set.results - if hasattr(conversation, 'created_at') - and (now - conversation.created_at.replace(tzinfo=timezone.utc)).total_seconds() - <= max_age - ] + + for conversation in conversation_metadata_result_set.results: + # Skip conversations without created_at or older than max_age + if not hasattr(conversation, 'created_at'): + continue + + age_seconds = ( + now - conversation.created_at.replace(tzinfo=timezone.utc) + ).total_seconds() + if age_seconds > max_age: + continue + + # Apply repository filter + if ( + selected_repository is not None + and conversation.selected_repository != selected_repository + ): + continue + + # Apply conversation trigger filter + if ( + conversation_trigger is not None + and conversation.trigger != conversation_trigger + ): + continue + + filtered_results.append(conversation) conversation_ids = set( conversation.conversation_id for conversation in filtered_results diff --git a/tests/unit/test_conversation.py b/tests/unit/test_conversation.py index b19ad9d855..e37a6b5541 100644 --- a/tests/unit/test_conversation.py +++ b/tests/unit/test_conversation.py @@ -159,6 +159,8 @@ async def test_search_conversations(): result_set = await search_conversations( page_id=None, limit=20, + selected_repository=None, + conversation_trigger=None, conversation_store=mock_store, ) @@ -183,6 +185,422 @@ async def test_search_conversations(): assert result_set == expected +@pytest.mark.asyncio +async def test_search_conversations_with_repository_filter(): + """Test searching conversations with repository filter.""" + with _patch_store(): + with patch( + 'openhands.server.routes.manage_conversations.config' + ) as mock_config: + mock_config.conversation_max_age_seconds = 864000 # 10 days + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + + async def mock_get_running_agent_loops(*args, **kwargs): + return set() + + async def mock_get_connections(*args, **kwargs): + return {} + + async def get_agent_loop_info(*args, **kwargs): + return [] + + mock_manager.get_running_agent_loops = mock_get_running_agent_loops + mock_manager.get_connections = mock_get_connections + mock_manager.get_agent_loop_info = get_agent_loop_info + with patch( + 'openhands.server.routes.manage_conversations.datetime' + ) as mock_datetime: + mock_datetime.now.return_value = datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ) + mock_datetime.fromisoformat = datetime.fromisoformat + mock_datetime.timezone = timezone + + # Mock the conversation store + mock_store = MagicMock() + mock_store.search = AsyncMock( + return_value=ConversationInfoResultSet( + results=[ + ConversationMetadata( + conversation_id='conversation_1', + title='Conversation 1', + created_at=datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ), + last_updated_at=datetime.fromisoformat( + '2025-01-01T00:01:00+00:00' + ), + selected_repository='test/repo', + user_id='12345', + ) + ] + ) + ) + + result_set = await search_conversations( + page_id=None, + limit=20, + selected_repository='test/repo', + conversation_trigger=None, + conversation_store=mock_store, + ) + + # Verify that search was called with only pagination parameters (filtering is done at API level) + mock_store.search.assert_called_once_with(None, 20) + + # Verify the result contains only conversations from the specified repository + assert len(result_set.results) == 1 + assert result_set.results[0].selected_repository == 'test/repo' + + +@pytest.mark.asyncio +async def test_search_conversations_with_trigger_filter(): + """Test searching conversations with conversation trigger filter.""" + with _patch_store(): + with patch( + 'openhands.server.routes.manage_conversations.config' + ) as mock_config: + mock_config.conversation_max_age_seconds = 864000 # 10 days + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + + async def mock_get_running_agent_loops(*args, **kwargs): + return set() + + async def mock_get_connections(*args, **kwargs): + return {} + + async def get_agent_loop_info(*args, **kwargs): + return [] + + mock_manager.get_running_agent_loops = mock_get_running_agent_loops + mock_manager.get_connections = mock_get_connections + mock_manager.get_agent_loop_info = get_agent_loop_info + with patch( + 'openhands.server.routes.manage_conversations.datetime' + ) as mock_datetime: + mock_datetime.now.return_value = datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ) + mock_datetime.fromisoformat = datetime.fromisoformat + mock_datetime.timezone = timezone + + # Mock the conversation store + mock_store = MagicMock() + mock_store.search = AsyncMock( + return_value=ConversationInfoResultSet( + results=[ + ConversationMetadata( + conversation_id='conversation_1', + title='Conversation 1', + created_at=datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ), + last_updated_at=datetime.fromisoformat( + '2025-01-01T00:01:00+00:00' + ), + selected_repository='test/repo', + trigger=ConversationTrigger.GUI, + user_id='12345', + ) + ] + ) + ) + + result_set = await search_conversations( + page_id=None, + limit=20, + selected_repository=None, + conversation_trigger=ConversationTrigger.GUI, + conversation_store=mock_store, + ) + + # Verify that search was called with only pagination parameters (filtering is done at API level) + mock_store.search.assert_called_once_with(None, 20) + + # Verify the result contains only conversations with the specified trigger + assert len(result_set.results) == 1 + assert result_set.results[0].trigger == ConversationTrigger.GUI + + +@pytest.mark.asyncio +async def test_search_conversations_with_both_filters(): + """Test searching conversations with both repository and trigger filters.""" + with _patch_store(): + with patch( + 'openhands.server.routes.manage_conversations.config' + ) as mock_config: + mock_config.conversation_max_age_seconds = 864000 # 10 days + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + + async def mock_get_running_agent_loops(*args, **kwargs): + return set() + + async def mock_get_connections(*args, **kwargs): + return {} + + async def get_agent_loop_info(*args, **kwargs): + return [] + + mock_manager.get_running_agent_loops = mock_get_running_agent_loops + mock_manager.get_connections = mock_get_connections + mock_manager.get_agent_loop_info = get_agent_loop_info + with patch( + 'openhands.server.routes.manage_conversations.datetime' + ) as mock_datetime: + mock_datetime.now.return_value = datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ) + mock_datetime.fromisoformat = datetime.fromisoformat + mock_datetime.timezone = timezone + + # Mock the conversation store + mock_store = MagicMock() + mock_store.search = AsyncMock( + return_value=ConversationInfoResultSet( + results=[ + ConversationMetadata( + conversation_id='conversation_1', + title='Conversation 1', + created_at=datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ), + last_updated_at=datetime.fromisoformat( + '2025-01-01T00:01:00+00:00' + ), + selected_repository='test/repo', + trigger=ConversationTrigger.SUGGESTED_TASK, + user_id='12345', + ) + ] + ) + ) + + result_set = await search_conversations( + page_id=None, + limit=20, + selected_repository='test/repo', + conversation_trigger=ConversationTrigger.SUGGESTED_TASK, + conversation_store=mock_store, + ) + + # Verify that search was called with only pagination parameters (filtering is done at API level) + mock_store.search.assert_called_once_with(None, 20) + + # Verify the result contains only conversations matching both filters + assert len(result_set.results) == 1 + result = result_set.results[0] + assert result.selected_repository == 'test/repo' + assert result.trigger == ConversationTrigger.SUGGESTED_TASK + + +@pytest.mark.asyncio +async def test_search_conversations_with_pagination(): + """Test searching conversations with pagination.""" + with _patch_store(): + with patch( + 'openhands.server.routes.manage_conversations.config' + ) as mock_config: + mock_config.conversation_max_age_seconds = 864000 # 10 days + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + + async def mock_get_running_agent_loops(*args, **kwargs): + return set() + + async def mock_get_connections(*args, **kwargs): + return {} + + async def get_agent_loop_info(*args, **kwargs): + return [] + + mock_manager.get_running_agent_loops = mock_get_running_agent_loops + mock_manager.get_connections = mock_get_connections + mock_manager.get_agent_loop_info = get_agent_loop_info + with patch( + 'openhands.server.routes.manage_conversations.datetime' + ) as mock_datetime: + mock_datetime.now.return_value = datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ) + mock_datetime.fromisoformat = datetime.fromisoformat + mock_datetime.timezone = timezone + + # Mock the conversation store + mock_store = MagicMock() + mock_store.search = AsyncMock( + return_value=ConversationInfoResultSet( + results=[ + ConversationMetadata( + conversation_id='conversation_1', + title='Conversation 1', + created_at=datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ), + last_updated_at=datetime.fromisoformat( + '2025-01-01T00:01:00+00:00' + ), + selected_repository='test/repo', + user_id='12345', + ) + ], + next_page_id='next_page_123', + ) + ) + + result_set = await search_conversations( + page_id='page_123', + limit=10, + selected_repository=None, + conversation_trigger=None, + conversation_store=mock_store, + ) + + # Verify that search was called with pagination parameters (filtering is done at API level) + mock_store.search.assert_called_once_with('page_123', 10) + + # Verify the result includes pagination info + assert result_set.next_page_id == 'next_page_123' + + +@pytest.mark.asyncio +async def test_search_conversations_with_filters_and_pagination(): + """Test searching conversations with filters and pagination.""" + with _patch_store(): + with patch( + 'openhands.server.routes.manage_conversations.config' + ) as mock_config: + mock_config.conversation_max_age_seconds = 864000 # 10 days + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + + async def mock_get_running_agent_loops(*args, **kwargs): + return set() + + async def mock_get_connections(*args, **kwargs): + return {} + + async def get_agent_loop_info(*args, **kwargs): + return [] + + mock_manager.get_running_agent_loops = mock_get_running_agent_loops + mock_manager.get_connections = mock_get_connections + mock_manager.get_agent_loop_info = get_agent_loop_info + with patch( + 'openhands.server.routes.manage_conversations.datetime' + ) as mock_datetime: + mock_datetime.now.return_value = datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ) + mock_datetime.fromisoformat = datetime.fromisoformat + mock_datetime.timezone = timezone + + # Mock the conversation store + mock_store = MagicMock() + mock_store.search = AsyncMock( + return_value=ConversationInfoResultSet( + results=[ + ConversationMetadata( + conversation_id='conversation_1', + title='Conversation 1', + created_at=datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ), + last_updated_at=datetime.fromisoformat( + '2025-01-01T00:01:00+00:00' + ), + selected_repository='test/repo', + trigger=ConversationTrigger.GUI, + user_id='12345', + ) + ], + next_page_id='next_page_456', + ) + ) + + result_set = await search_conversations( + page_id='page_456', + limit=5, + selected_repository='test/repo', + conversation_trigger=ConversationTrigger.GUI, + conversation_store=mock_store, + ) + + # Verify that search was called with only pagination parameters (filtering is done at API level) + mock_store.search.assert_called_once_with('page_456', 5) + + # Verify the result includes pagination info + assert result_set.next_page_id == 'next_page_456' + assert len(result_set.results) == 1 + result = result_set.results[0] + assert result.selected_repository == 'test/repo' + assert result.trigger == ConversationTrigger.GUI + + +@pytest.mark.asyncio +async def test_search_conversations_empty_results(): + """Test searching conversations that returns empty results.""" + with _patch_store(): + with patch( + 'openhands.server.routes.manage_conversations.config' + ) as mock_config: + mock_config.conversation_max_age_seconds = 864000 # 10 days + with patch( + 'openhands.server.routes.manage_conversations.conversation_manager' + ) as mock_manager: + + async def mock_get_running_agent_loops(*args, **kwargs): + return set() + + async def mock_get_connections(*args, **kwargs): + return {} + + async def get_agent_loop_info(*args, **kwargs): + return [] + + mock_manager.get_running_agent_loops = mock_get_running_agent_loops + mock_manager.get_connections = mock_get_connections + mock_manager.get_agent_loop_info = get_agent_loop_info + with patch( + 'openhands.server.routes.manage_conversations.datetime' + ) as mock_datetime: + mock_datetime.now.return_value = datetime.fromisoformat( + '2025-01-01T00:00:00+00:00' + ) + mock_datetime.fromisoformat = datetime.fromisoformat + mock_datetime.timezone = timezone + + # Mock the conversation store + mock_store = MagicMock() + mock_store.search = AsyncMock( + return_value=ConversationInfoResultSet( + results=[], next_page_id=None + ) + ) + + result_set = await search_conversations( + page_id=None, + limit=20, + selected_repository='nonexistent/repo', + conversation_trigger=ConversationTrigger.GUI, + conversation_store=mock_store, + ) + + # Verify that search was called with only pagination parameters (filtering is done at API level) + mock_store.search.assert_called_once_with(None, 20) + + # Verify the result is empty + assert len(result_set.results) == 0 + assert result_set.next_page_id is None + + @pytest.mark.asyncio async def test_get_conversation(): with _patch_store():