Implement Export feature for V1 conversations with comprehensive unit tests (#12030)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
Tim O'Farrell
2025-12-24 10:50:57 -07:00
committed by GitHub
parent 36fe23aea3
commit 6d14ce420e
17 changed files with 516 additions and 10 deletions

View File

@@ -1,13 +1,21 @@
"""Unit tests for the methods in LiveStatusAppConversationService."""
import io
import json
import zipfile
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
from uuid import UUID, uuid4
import pytest
from openhands.agent_server.models import SendMessageRequest, StartConversationRequest
from openhands.agent_server.models import (
SendMessageRequest,
StartConversationRequest,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AgentType,
AppConversationInfo,
AppConversationStartRequest,
)
from openhands.app_server.app_conversation.live_status_app_conversation_service import (
@@ -22,7 +30,7 @@ from openhands.app_server.sandbox.sandbox_models import (
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderType
from openhands.sdk import Agent
from openhands.sdk import Agent, Event
from openhands.sdk.llm import LLM
from openhands.sdk.secret import LookupSecret, StaticSecret
from openhands.sdk.workspace import LocalWorkspace
@@ -45,6 +53,7 @@ class TestLiveStatusAppConversationService:
self.mock_app_conversation_info_service = Mock()
self.mock_app_conversation_start_task_service = Mock()
self.mock_event_callback_service = Mock()
self.mock_event_service = Mock()
self.mock_httpx_client = Mock()
# Create service instance
@@ -54,6 +63,7 @@ class TestLiveStatusAppConversationService:
app_conversation_info_service=self.mock_app_conversation_info_service,
app_conversation_start_task_service=self.mock_app_conversation_start_task_service,
event_callback_service=self.mock_event_callback_service,
event_service=self.mock_event_service,
sandbox_service=self.mock_sandbox_service,
sandbox_spec_service=self.mock_sandbox_spec_service,
jwt_service=self.mock_jwt_service,
@@ -852,6 +862,238 @@ class TestLiveStatusAppConversationService:
self.service._finalize_conversation_request.assert_called_once()
@pytest.mark.asyncio
async def test_export_conversation_success(self):
"""Test successful download of conversation trajectory."""
# Arrange
conversation_id = uuid4()
# Mock conversation info
mock_conversation_info = Mock(spec=AppConversationInfo)
mock_conversation_info.id = conversation_id
mock_conversation_info.title = 'Test Conversation'
mock_conversation_info.created_at = datetime(2024, 1, 1, 12, 0, 0)
mock_conversation_info.updated_at = datetime(2024, 1, 1, 13, 0, 0)
mock_conversation_info.selected_repository = 'test/repo'
mock_conversation_info.git_provider = 'github'
mock_conversation_info.selected_branch = 'main'
mock_conversation_info.model_dump_json = Mock(
return_value='{"id": "test", "title": "Test Conversation"}'
)
self.mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_conversation_info
)
# Mock events
mock_event1 = Mock(spec=Event)
mock_event1.id = uuid4()
mock_event1.model_dump = Mock(
return_value={'id': str(mock_event1.id), 'type': 'action'}
)
mock_event2 = Mock(spec=Event)
mock_event2.id = uuid4()
mock_event2.model_dump = Mock(
return_value={'id': str(mock_event2.id), 'type': 'observation'}
)
# Mock event service search_events to return paginated results
mock_event_page1 = Mock()
mock_event_page1.items = [mock_event1]
mock_event_page1.next_page_id = 'page2'
mock_event_page2 = Mock()
mock_event_page2.items = [mock_event2]
mock_event_page2.next_page_id = None
self.mock_event_service.search_events = AsyncMock(
side_effect=[mock_event_page1, mock_event_page2]
)
# Act
result = await self.service.export_conversation(conversation_id)
# Assert
assert result is not None
assert isinstance(result, bytes) # Should be bytes
# Verify the zip file contents
with zipfile.ZipFile(io.BytesIO(result), 'r') as zipf:
file_list = zipf.namelist()
# Should contain meta.json and event files
assert 'meta.json' in file_list
assert any(
f.startswith('event_') and f.endswith('.json') for f in file_list
)
# Check meta.json content
with zipf.open('meta.json') as meta_file:
meta_content = meta_file.read().decode('utf-8')
assert '"id": "test"' in meta_content
assert '"title": "Test Conversation"' in meta_content
# Check event files
event_files = [f for f in file_list if f.startswith('event_')]
assert len(event_files) == 2 # Should have 2 event files
# Verify event file content
with zipf.open(event_files[0]) as event_file:
event_content = json.loads(event_file.read().decode('utf-8'))
assert 'id' in event_content
assert 'type' in event_content
# Verify service calls
self.mock_app_conversation_info_service.get_app_conversation_info.assert_called_once_with(
conversation_id
)
assert self.mock_event_service.search_events.call_count == 2
mock_conversation_info.model_dump_json.assert_called_once_with(indent=2)
@pytest.mark.asyncio
async def test_export_conversation_conversation_not_found(self):
"""Test download when conversation is not found."""
# Arrange
conversation_id = uuid4()
self.mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=None
)
# Act & Assert
with pytest.raises(
ValueError, match=f'Conversation not found: {conversation_id}'
):
await self.service.export_conversation(conversation_id)
# Verify service calls
self.mock_app_conversation_info_service.get_app_conversation_info.assert_called_once_with(
conversation_id
)
self.mock_event_service.search_events.assert_not_called()
@pytest.mark.asyncio
async def test_export_conversation_empty_events(self):
"""Test download with conversation that has no events."""
# Arrange
conversation_id = uuid4()
# Mock conversation info
mock_conversation_info = Mock(spec=AppConversationInfo)
mock_conversation_info.id = conversation_id
mock_conversation_info.title = 'Empty Conversation'
mock_conversation_info.model_dump_json = Mock(
return_value='{"id": "test", "title": "Empty Conversation"}'
)
self.mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_conversation_info
)
# Mock empty event page
mock_event_page = Mock()
mock_event_page.items = []
mock_event_page.next_page_id = None
self.mock_event_service.search_events = AsyncMock(return_value=mock_event_page)
# Act
result = await self.service.export_conversation(conversation_id)
# Assert
assert result is not None
assert isinstance(result, bytes) # Should be bytes
# Verify the zip file contents
with zipfile.ZipFile(io.BytesIO(result), 'r') as zipf:
file_list = zipf.namelist()
# Should only contain meta.json (no event files)
assert 'meta.json' in file_list
assert len([f for f in file_list if f.startswith('event_')]) == 0
# Verify service calls
self.mock_app_conversation_info_service.get_app_conversation_info.assert_called_once_with(
conversation_id
)
self.mock_event_service.search_events.assert_called_once()
@pytest.mark.asyncio
async def test_export_conversation_large_pagination(self):
"""Test download with multiple pages of events."""
# Arrange
conversation_id = uuid4()
# Mock conversation info
mock_conversation_info = Mock(spec=AppConversationInfo)
mock_conversation_info.id = conversation_id
mock_conversation_info.title = 'Large Conversation'
mock_conversation_info.model_dump_json = Mock(
return_value='{"id": "test", "title": "Large Conversation"}'
)
self.mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_conversation_info
)
# Create multiple pages of events
events_per_page = 3
total_pages = 4
all_events = []
for page_num in range(total_pages):
page_events = []
for i in range(events_per_page):
mock_event = Mock(spec=Event)
mock_event.id = uuid4()
mock_event.model_dump = Mock(
return_value={
'id': str(mock_event.id),
'type': f'event_page_{page_num}_item_{i}',
}
)
page_events.append(mock_event)
all_events.append(mock_event)
mock_event_page = Mock()
mock_event_page.items = page_events
mock_event_page.next_page_id = (
f'page{page_num + 1}' if page_num < total_pages - 1 else None
)
if page_num == 0:
first_page = mock_event_page
elif page_num == 1:
second_page = mock_event_page
elif page_num == 2:
third_page = mock_event_page
else:
fourth_page = mock_event_page
self.mock_event_service.search_events = AsyncMock(
side_effect=[first_page, second_page, third_page, fourth_page]
)
# Act
result = await self.service.export_conversation(conversation_id)
# Assert
assert result is not None
assert isinstance(result, bytes) # Should be bytes
# Verify the zip file contents
with zipfile.ZipFile(io.BytesIO(result), 'r') as zipf:
file_list = zipf.namelist()
# Should contain meta.json and all event files
assert 'meta.json' in file_list
event_files = [f for f in file_list if f.startswith('event_')]
assert (
len(event_files) == total_pages * events_per_page
) # Should have all events
# Verify service calls - should call search_events for each page
assert self.mock_event_service.search_events.call_count == total_pages
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.AsyncRemoteWorkspace'
)

View File

@@ -176,12 +176,15 @@ class TestExperimentManagerIntegration:
jwt_service = Mock()
httpx_client = Mock()
event_service = Mock()
service = LiveStatusAppConversationService(
init_git_in_empty_workspace=False,
user_context=user_context,
app_conversation_info_service=app_conversation_info_service,
app_conversation_start_task_service=app_conversation_start_task_service,
event_callback_service=event_callback_service,
event_service=event_service,
sandbox_service=sandbox_service,
sandbox_spec_service=sandbox_spec_service,
jwt_service=jwt_service,

View File

@@ -2183,6 +2183,7 @@ async def test_delete_v1_conversation_with_sub_conversations():
app_conversation_info_service=mock_info_service,
app_conversation_start_task_service=mock_start_task_service,
event_callback_service=MagicMock(),
event_service=MagicMock(),
sandbox_service=mock_sandbox_service,
sandbox_spec_service=MagicMock(),
jwt_service=MagicMock(),
@@ -2305,6 +2306,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations():
app_conversation_info_service=mock_info_service,
app_conversation_start_task_service=mock_start_task_service,
event_callback_service=MagicMock(),
event_service=MagicMock(),
sandbox_service=mock_sandbox_service,
sandbox_spec_service=MagicMock(),
jwt_service=MagicMock(),
@@ -2457,6 +2459,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error():
app_conversation_info_service=mock_info_service,
app_conversation_start_task_service=mock_start_task_service,
event_callback_service=MagicMock(),
event_service=MagicMock(),
sandbox_service=mock_sandbox_service,
sandbox_spec_service=MagicMock(),
jwt_service=MagicMock(),