diff --git a/openhands/app_server/app_conversation/app_conversation_info_service.py b/openhands/app_server/app_conversation/app_conversation_info_service.py index 1bbd06531b..56c4d77fae 100644 --- a/openhands/app_server/app_conversation/app_conversation_info_service.py +++ b/openhands/app_server/app_conversation/app_conversation_info_service.py @@ -26,6 +26,7 @@ class AppConversationInfoService(ABC): sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 100, + include_sub_conversations: bool = False, ) -> AppConversationInfoPage: """Search for sandboxed conversations.""" diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index 997e8b6528..b66d998362 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -99,6 +99,12 @@ async def search_app_conversations( lte=100, ), ] = 100, + include_sub_conversations: Annotated[ + bool, + Query( + title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.' + ), + ] = False, app_conversation_service: AppConversationService = ( app_conversation_service_dependency ), @@ -114,6 +120,7 @@ async def search_app_conversations( updated_at__lt=updated_at__lt, page_id=page_id, limit=limit, + include_sub_conversations=include_sub_conversations, ) @@ -193,7 +200,8 @@ async def stream_app_conversation_start( user_context: UserContext = user_context_dependency, ) -> list[AppConversationStartTask]: """Start an app conversation start task and stream updates from it. - Leaves the connection open until either the conversation starts or there was an error""" + Leaves the connection open until either the conversation starts or there was an error + """ response = StreamingResponse( _stream_app_conversation_start(request, user_context), media_type='application/json', diff --git a/openhands/app_server/app_conversation/app_conversation_service.py b/openhands/app_server/app_conversation/app_conversation_service.py index d910856c76..8c39a66ae5 100644 --- a/openhands/app_server/app_conversation/app_conversation_service.py +++ b/openhands/app_server/app_conversation/app_conversation_service.py @@ -30,6 +30,7 @@ class AppConversationService(ABC): sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 100, + include_sub_conversations: bool = False, ) -> AppConversationPage: """Search for sandboxed conversations.""" diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 73f26781a6..e8bd8fc331 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -105,6 +105,7 @@ class LiveStatusAppConversationService(GitAppConversationService): sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 20, + include_sub_conversations: bool = False, ) -> AppConversationPage: """Search for sandboxed conversations.""" page = await self.app_conversation_info_service.search_app_conversation_info( @@ -116,6 +117,7 @@ class LiveStatusAppConversationService(GitAppConversationService): sort_order=sort_order, page_id=page_id, limit=limit, + include_sub_conversations=include_sub_conversations, ) conversations: list[AppConversation] = await self._build_app_conversations( page.items diff --git a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py index 90da5f5d4b..9b13711e92 100644 --- a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py +++ b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py @@ -111,10 +111,18 @@ class SQLAppConversationInfoService(AppConversationInfoService): sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 100, + include_sub_conversations: bool = False, ) -> AppConversationInfoPage: """Search for sandboxed conversations without permission checks.""" query = await self._secure_select() + # Conditionally exclude sub-conversations based on the parameter + if not include_sub_conversations: + # Exclude sub-conversations (only include top-level conversations) + query = query.where( + StoredConversationMetadata.parent_conversation_id.is_(None) + ) + query = self._apply_filters( query=query, title__contains=title__contains, diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 7d0b1f1c0c..56f6b95f6c 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -6,9 +6,10 @@ import os import re import uuid from datetime import datetime, timedelta, timezone +from typing import Annotated import base62 -from fastapi import APIRouter, Depends, Request, status +from fastapi import APIRouter, Depends, Query, Request, status from fastapi.responses import JSONResponse from jinja2 import Environment, FileSystemLoader from pydantic import BaseModel, ConfigDict, Field @@ -309,6 +310,12 @@ async def search_conversations( limit: int = 20, selected_repository: str | None = None, conversation_trigger: ConversationTrigger | None = None, + include_sub_conversations: Annotated[ + bool, + Query( + title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.' + ), + ] = False, conversation_store: ConversationStore = Depends(get_conversation_store), app_conversation_service: AppConversationService = app_conversation_service_dependency, ) -> ConversationInfoResultSet: @@ -343,6 +350,7 @@ async def search_conversations( limit=limit, # Apply age filter at the service level if possible created_at__gte=age_filter_date, + include_sub_conversations=include_sub_conversations, ) # Convert V1 conversations to ConversationInfo format @@ -1187,6 +1195,7 @@ async def _fetch_v1_conversations_safe( app_conversation_service: App conversation service for V1 v1_page_id: Page ID for V1 pagination limit: Maximum number of results + include_sub_conversations: If True, include sub-conversations in results Returns: Tuple of (v1_conversations, v1_next_page_id) diff --git a/tests/unit/app_server/test_sql_app_conversation_info_service.py b/tests/unit/app_server/test_sql_app_conversation_info_service.py index 2ff5974f73..393e2e654b 100644 --- a/tests/unit/app_server/test_sql_app_conversation_info_service.py +++ b/tests/unit/app_server/test_sql_app_conversation_info_service.py @@ -623,3 +623,383 @@ class TestSQLAppConversationInfoService: created_at__gte=start_time, created_at__lt=end_time ) assert count == 2 + + @pytest.mark.asyncio + async def test_search_excludes_sub_conversations_by_default( + self, + service: SQLAppConversationInfoService, + ): + """Test that search excludes sub-conversations by default.""" + # Create a parent conversation + parent_id = uuid4() + parent_info = AppConversationInfo( + id=parent_id, + created_by_user_id='test_user_123', + sandbox_id='sandbox_parent', + title='Parent Conversation', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + + # Create sub-conversations + sub_info_1 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub1', + title='Sub Conversation 1', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + + sub_info_2 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub2', + title='Sub Conversation 2', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await service.save_app_conversation_info(parent_info) + await service.save_app_conversation_info(sub_info_1) + await service.save_app_conversation_info(sub_info_2) + + # Search without include_sub_conversations (default False) + page = await service.search_app_conversation_info() + + # Should only return the parent conversation + assert len(page.items) == 1 + assert page.items[0].id == parent_id + assert page.items[0].title == 'Parent Conversation' + assert page.items[0].parent_conversation_id is None + + @pytest.mark.asyncio + async def test_search_includes_sub_conversations_when_flag_true( + self, + service: SQLAppConversationInfoService, + ): + """Test that search includes sub-conversations when include_sub_conversations=True.""" + # Create a parent conversation + parent_id = uuid4() + parent_info = AppConversationInfo( + id=parent_id, + created_by_user_id='test_user_123', + sandbox_id='sandbox_parent', + title='Parent Conversation', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + + # Create sub-conversations + sub_info_1 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub1', + title='Sub Conversation 1', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + + sub_info_2 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub2', + title='Sub Conversation 2', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await service.save_app_conversation_info(parent_info) + await service.save_app_conversation_info(sub_info_1) + await service.save_app_conversation_info(sub_info_2) + + # Search with include_sub_conversations=True + page = await service.search_app_conversation_info( + include_sub_conversations=True + ) + + # Should return all conversations (1 parent + 2 sub-conversations) + assert len(page.items) == 3 + + # Verify all conversations are present + conversation_ids = {item.id for item in page.items} + assert parent_id in conversation_ids + assert sub_info_1.id in conversation_ids + assert sub_info_2.id in conversation_ids + + # Verify parent conversation has no parent_conversation_id + parent_item = next(item for item in page.items if item.id == parent_id) + assert parent_item.parent_conversation_id is None + + # Verify sub-conversations have parent_conversation_id set + sub_item_1 = next(item for item in page.items if item.id == sub_info_1.id) + assert sub_item_1.parent_conversation_id == parent_id + + sub_item_2 = next(item for item in page.items if item.id == sub_info_2.id) + assert sub_item_2.parent_conversation_id == parent_id + + @pytest.mark.asyncio + async def test_search_sub_conversations_with_filters( + self, + service: SQLAppConversationInfoService, + ): + """Test that include_sub_conversations works correctly with other filters.""" + # Create a parent conversation + parent_id = uuid4() + parent_info = AppConversationInfo( + id=parent_id, + created_by_user_id='test_user_123', + sandbox_id='sandbox_parent', + title='Parent Conversation', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + + # Create sub-conversations with different titles + sub_info_1 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub1', + title='Sub Conversation Alpha', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + + sub_info_2 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub2', + title='Sub Conversation Beta', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await service.save_app_conversation_info(parent_info) + await service.save_app_conversation_info(sub_info_1) + await service.save_app_conversation_info(sub_info_2) + + # Search with title filter and include_sub_conversations=False (default) + page = await service.search_app_conversation_info(title__contains='Alpha') + # Should only find parent if it matches, but parent doesn't have "Alpha" + # So should find nothing or only sub if we include them + assert len(page.items) == 0 + + # Search with title filter and include_sub_conversations=True + page = await service.search_app_conversation_info( + title__contains='Alpha', include_sub_conversations=True + ) + # Should find the sub-conversation with "Alpha" in title + assert len(page.items) == 1 + assert page.items[0].title == 'Sub Conversation Alpha' + assert page.items[0].parent_conversation_id == parent_id + + # Search with title filter for "Parent" and include_sub_conversations=True + page = await service.search_app_conversation_info( + title__contains='Parent', include_sub_conversations=True + ) + # Should find the parent conversation + assert len(page.items) == 1 + assert page.items[0].title == 'Parent Conversation' + assert page.items[0].parent_conversation_id is None + + @pytest.mark.asyncio + async def test_search_sub_conversations_with_date_filters( + self, + service: SQLAppConversationInfoService, + ): + """Test that include_sub_conversations works correctly with date filters.""" + # Create a parent conversation + parent_id = uuid4() + parent_info = AppConversationInfo( + id=parent_id, + created_by_user_id='test_user_123', + sandbox_id='sandbox_parent', + title='Parent Conversation', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + + # Create sub-conversations at different times + sub_info_1 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub1', + title='Sub Conversation 1', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + + sub_info_2 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub2', + title='Sub Conversation 2', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await service.save_app_conversation_info(parent_info) + await service.save_app_conversation_info(sub_info_1) + await service.save_app_conversation_info(sub_info_2) + + # Search with date filter and include_sub_conversations=False (default) + cutoff_time = datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc) + page = await service.search_app_conversation_info(created_at__gte=cutoff_time) + # Should only return parent if it matches the filter, but parent is at 12:00 + assert len(page.items) == 0 + + # Search with date filter and include_sub_conversations=True + page = await service.search_app_conversation_info( + created_at__gte=cutoff_time, include_sub_conversations=True + ) + # Should find sub-conversations created after cutoff (sub_info_2 at 14:00) + assert len(page.items) == 1 + assert page.items[0].id == sub_info_2.id + assert page.items[0].parent_conversation_id == parent_id + + @pytest.mark.asyncio + async def test_search_multiple_parents_with_sub_conversations( + self, + service: SQLAppConversationInfoService, + ): + """Test search with multiple parent conversations and their sub-conversations.""" + # Create first parent conversation + parent1_id = uuid4() + parent1_info = AppConversationInfo( + id=parent1_id, + created_by_user_id='test_user_123', + sandbox_id='sandbox_parent1', + title='Parent 1', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + + # Create second parent conversation + parent2_id = uuid4() + parent2_info = AppConversationInfo( + id=parent2_id, + created_by_user_id='test_user_123', + sandbox_id='sandbox_parent2', + title='Parent 2', + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + + # Create sub-conversations for parent1 + sub1_1 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub1_1', + title='Sub 1-1', + parent_conversation_id=parent1_id, + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Create sub-conversations for parent2 + sub2_1 = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id='sandbox_sub2_1', + title='Sub 2-1', + parent_conversation_id=parent2_id, + created_at=datetime(2024, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 15, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await service.save_app_conversation_info(parent1_info) + await service.save_app_conversation_info(parent2_info) + await service.save_app_conversation_info(sub1_1) + await service.save_app_conversation_info(sub2_1) + + # Search without include_sub_conversations (default False) + page = await service.search_app_conversation_info() + # Should only return the 2 parent conversations + assert len(page.items) == 2 + conversation_ids = {item.id for item in page.items} + assert parent1_id in conversation_ids + assert parent2_id in conversation_ids + assert sub1_1.id not in conversation_ids + assert sub2_1.id not in conversation_ids + + # Search with include_sub_conversations=True + page = await service.search_app_conversation_info( + include_sub_conversations=True + ) + # Should return all 4 conversations (2 parents + 2 sub-conversations) + assert len(page.items) == 4 + conversation_ids = {item.id for item in page.items} + assert parent1_id in conversation_ids + assert parent2_id in conversation_ids + assert sub1_1.id in conversation_ids + assert sub2_1.id in conversation_ids + + @pytest.mark.asyncio + async def test_search_sub_conversations_with_pagination( + self, + service: SQLAppConversationInfoService, + ): + """Test that include_sub_conversations works correctly with pagination.""" + # Create a parent conversation + parent_id = uuid4() + parent_info = AppConversationInfo( + id=parent_id, + created_by_user_id='test_user_123', + sandbox_id='sandbox_parent', + title='Parent Conversation', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + + # Create multiple sub-conversations + sub_conversations = [] + for i in range(5): + sub_info = AppConversationInfo( + id=uuid4(), + created_by_user_id='test_user_123', + sandbox_id=f'sandbox_sub{i}', + title=f'Sub Conversation {i}', + parent_conversation_id=parent_id, + created_at=datetime(2024, 1, 1, 13 + i, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13 + i, 30, 0, tzinfo=timezone.utc), + ) + sub_conversations.append(sub_info) + await service.save_app_conversation_info(sub_info) + + # Save parent + await service.save_app_conversation_info(parent_info) + + # Search with include_sub_conversations=True and pagination + page1 = await service.search_app_conversation_info( + include_sub_conversations=True, limit=3 + ) + # Should return 3 items (1 parent + 2 sub-conversations) + assert len(page1.items) == 3 + assert page1.next_page_id is not None + + # Get next page + page2 = await service.search_app_conversation_info( + include_sub_conversations=True, limit=3, page_id=page1.next_page_id + ) + # Should return remaining items + assert len(page2.items) == 3 + assert page2.next_page_id is None + + # Verify all conversations are present across pages + all_ids = {item.id for item in page1.items} | {item.id for item in page2.items} + assert parent_id in all_ids + for sub_info in sub_conversations: + assert sub_info.id in all_ids diff --git a/tests/unit/server/routes/test_conversation_routes.py b/tests/unit/server/routes/test_conversation_routes.py index 3d374f688e..343894cefa 100644 --- a/tests/unit/server/routes/test_conversation_routes.py +++ b/tests/unit/server/routes/test_conversation_routes.py @@ -13,6 +13,7 @@ from openhands.app_server.app_conversation.app_conversation_info_service import from openhands.app_server.app_conversation.app_conversation_models import ( AgentType, AppConversationInfo, + AppConversationPage, AppConversationStartRequest, AppConversationStartTask, AppConversationStartTaskStatus, @@ -22,6 +23,9 @@ from openhands.app_server.app_conversation.app_conversation_service import ( ) from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent from openhands.microagent.types import MicroagentMetadata, MicroagentType +from openhands.server.data_models.conversation_info_result_set import ( + ConversationInfoResultSet, +) from openhands.server.routes.conversation import ( AddMessageRequest, add_message, @@ -29,11 +33,15 @@ from openhands.server.routes.conversation import ( ) from openhands.server.routes.manage_conversations import ( UpdateConversationRequest, + search_conversations, update_conversation, ) from openhands.server.session.conversation import ServerConversation from openhands.storage.conversation.conversation_store import ConversationStore -from openhands.storage.data_models.conversation_metadata import ConversationMetadata +from openhands.storage.data_models.conversation_metadata import ( + ConversationMetadata, + ConversationTrigger, +) @pytest.mark.asyncio @@ -1200,3 +1208,254 @@ async def test_create_sub_conversation_with_planning_agent(): assert task.request.parent_conversation_id == parent_conversation_id assert task.sandbox_id == sandbox_id break + + +@pytest.mark.asyncio +async def test_search_conversations_include_sub_conversations_default_false(): + """Test that include_sub_conversations defaults to False when not provided.""" + 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=[]) + ) + + # Create a mock app conversation service + mock_app_conversation_service = AsyncMock() + mock_app_conversation_service.search_app_conversations.return_value = ( + AppConversationPage(items=[]) + ) + + # Call search_conversations without include_sub_conversations parameter + await search_conversations( + page_id=None, + limit=20, + selected_repository=None, + conversation_trigger=None, + conversation_store=mock_store, + app_conversation_service=mock_app_conversation_service, + ) + + # Verify that search_app_conversations was called with include_sub_conversations=False (default) + mock_app_conversation_service.search_app_conversations.assert_called_once() + call_kwargs = ( + mock_app_conversation_service.search_app_conversations.call_args[1] + ) + assert call_kwargs.get('include_sub_conversations') is False + + +@pytest.mark.asyncio +async def test_search_conversations_include_sub_conversations_explicit_false(): + """Test that include_sub_conversations=False is properly passed through.""" + 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=[]) + ) + + # Create a mock app conversation service + mock_app_conversation_service = AsyncMock() + mock_app_conversation_service.search_app_conversations.return_value = ( + AppConversationPage(items=[]) + ) + + # Call search_conversations with include_sub_conversations=False + await search_conversations( + page_id=None, + limit=20, + selected_repository=None, + conversation_trigger=None, + include_sub_conversations=False, + conversation_store=mock_store, + app_conversation_service=mock_app_conversation_service, + ) + + # Verify that search_app_conversations was called with include_sub_conversations=False + mock_app_conversation_service.search_app_conversations.assert_called_once() + call_kwargs = ( + mock_app_conversation_service.search_app_conversations.call_args[1] + ) + assert call_kwargs.get('include_sub_conversations') is False + + +@pytest.mark.asyncio +async def test_search_conversations_include_sub_conversations_explicit_true(): + """Test that include_sub_conversations=True is properly passed through.""" + 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=[]) + ) + + # Create a mock app conversation service + mock_app_conversation_service = AsyncMock() + mock_app_conversation_service.search_app_conversations.return_value = ( + AppConversationPage(items=[]) + ) + + # Call search_conversations with include_sub_conversations=True + await search_conversations( + page_id=None, + limit=20, + selected_repository=None, + conversation_trigger=None, + include_sub_conversations=True, + conversation_store=mock_store, + app_conversation_service=mock_app_conversation_service, + ) + + # Verify that search_app_conversations was called with include_sub_conversations=True + mock_app_conversation_service.search_app_conversations.assert_called_once() + call_kwargs = ( + mock_app_conversation_service.search_app_conversations.call_args[1] + ) + assert call_kwargs.get('include_sub_conversations') is True + + +@pytest.mark.asyncio +async def test_search_conversations_include_sub_conversations_with_other_filters(): + """Test that include_sub_conversations works correctly with other filters.""" + 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=[]) + ) + + # Create a mock app conversation service + mock_app_conversation_service = AsyncMock() + mock_app_conversation_service.search_app_conversations.return_value = ( + AppConversationPage(items=[]) + ) + + # Create a valid base64-encoded page_id for testing + import base64 + + page_id_data = json.dumps({'v0': None, 'v1': 'test_v1_page_id'}) + encoded_page_id = base64.b64encode(page_id_data.encode()).decode() + + # Call search_conversations with include_sub_conversations and other filters + await search_conversations( + page_id=encoded_page_id, + limit=50, + selected_repository='test/repo', + conversation_trigger=ConversationTrigger.GUI, + include_sub_conversations=True, + conversation_store=mock_store, + app_conversation_service=mock_app_conversation_service, + ) + + # Verify that search_app_conversations was called with all parameters including include_sub_conversations=True + mock_app_conversation_service.search_app_conversations.assert_called_once() + call_kwargs = ( + mock_app_conversation_service.search_app_conversations.call_args[1] + ) + assert call_kwargs.get('include_sub_conversations') is True + assert call_kwargs.get('page_id') == 'test_v1_page_id' + assert call_kwargs.get('limit') == 50