mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 06:23:59 -05:00
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:
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user