feat(backend): exclude sub-conversations when searching for conversations (#11733)

This commit is contained in:
Hiep Le
2025-11-15 00:21:27 +07:00
committed by GitHub
parent 2841e35f24
commit 833aae1833
8 changed files with 671 additions and 3 deletions

View File

@@ -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

View File

@@ -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