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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 516 additions and 10 deletions

View File

@ -317,6 +317,21 @@ class V1ConversationService {
return data;
}
/**
* Download a conversation trajectory as a zip file
* @param conversationId The conversation ID
* @returns A blob containing the zip file
*/
static async downloadConversation(conversationId: string): Promise<Blob> {
const response = await openHands.get(
`/api/v1/app-conversations/${conversationId}/download`,
{
responseType: "blob",
},
);
return response.data;
}
/**
* Get all skills associated with a V1 conversation
* @param conversationId The conversation ID

View File

@ -11,6 +11,7 @@ interface ConversationCardActionsProps {
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
conversationStatus?: ConversationStatus;
conversationId?: string;
showOptions?: boolean;
@ -23,6 +24,7 @@ export function ConversationCardActions({
onStop,
onEdit,
onDownloadViaVSCode,
onDownloadConversation,
conversationStatus,
conversationId,
showOptions,
@ -62,6 +64,9 @@ export function ConversationCardActions({
onDownloadViaVSCode={
conversationId && showOptions ? onDownloadViaVSCode : undefined
}
onDownloadConversation={
conversationId ? onDownloadConversation : undefined
}
position="bottom"
/>
</div>

View File

@ -24,6 +24,7 @@ interface ConversationCardContextMenuProps {
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
@ -39,6 +40,7 @@ export function ConversationCardContextMenu({
onShowAgentTools,
onShowSkills,
onDownloadViaVSCode,
onDownloadConversation,
position = "bottom",
}: ConversationCardContextMenuProps) {
const { t } = useTranslation();
@ -134,6 +136,18 @@ export function ConversationCardContextMenu({
/>
</ContextMenuListItem>
),
onDownloadConversation && (
<ContextMenuListItem
testId="download-trajectory-button"
onClick={onDownloadConversation}
className={contextMenuListItemClassName}
>
<ConversationNameContextMenuIconText
icon={<DownloadIcon width={16} height={16} />}
text={t(I18nKey.BUTTON$EXPORT_CONVERSATION)}
/>
</ContextMenuListItem>
),
])}
{generateSection(
[

View File

@ -8,6 +8,7 @@ import { RepositorySelection } from "#/api/open-hands.types";
import { ConversationCardHeader } from "./conversation-card-header";
import { ConversationCardActions } from "./conversation-card-actions";
import { ConversationCardFooter } from "./conversation-card-footer";
import { useDownloadConversation } from "#/hooks/use-download-conversation";
interface ConversationCardProps {
onClick?: () => void;
@ -46,6 +47,7 @@ export function ConversationCard({
}: ConversationCardProps) {
const posthog = usePostHog();
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const { mutateAsync: downloadConversation } = useDownloadConversation();
const onTitleSave = (newTitle: string) => {
if (newTitle !== "" && newTitle !== title) {
@ -101,6 +103,18 @@ export function ConversationCard({
onContextMenuToggle?.(false);
};
const handleDownloadConversation = async (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.preventDefault();
event.stopPropagation();
if (conversationId && conversationVersion === "V1") {
await downloadConversation(conversationId);
}
onContextMenuToggle?.(false);
};
const hasContextMenu = !!(onDelete || onChangeTitle || showOptions);
return (
@ -130,6 +144,11 @@ export function ConversationCard({
onStop={onStop && handleStop}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={handleDownloadViaVSCode}
onDownloadConversation={
conversationVersion === "V1"
? handleDownloadConversation
: undefined
}
conversationStatus={conversationStatus}
conversationId={conversationId}
showOptions={showOptions}

View File

@ -34,6 +34,7 @@ interface ConversationNameContextMenuProps {
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
@ -47,6 +48,7 @@ export function ConversationNameContextMenu({
onShowSkills,
onExportConversation,
onDownloadViaVSCode,
onDownloadConversation,
position = "bottom",
}: ConversationNameContextMenuProps) {
const { width } = useWindowSize();
@ -58,7 +60,7 @@ export function ConversationNameContextMenu({
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
const hasDownload = Boolean(onDownloadViaVSCode);
const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation);
const hasExport = Boolean(onExportConversation);
const hasTools = Boolean(onShowAgentTools || onShowSkills);
const hasInfo = Boolean(onDisplayCost);
@ -118,9 +120,9 @@ export function ConversationNameContextMenu({
</ContextMenuListItem>
)}
{(hasExport || hasDownload) && !isV1Conversation && (
{(hasExport || hasDownload) && !isV1Conversation ? (
<Divider testId="separator-export" />
)}
) : null}
{onExportConversation && !isV1Conversation && (
<ContextMenuListItem
@ -150,10 +152,22 @@ export function ConversationNameContextMenu({
</ContextMenuListItem>
)}
{(hasInfo || hasControl) && !isV1Conversation && (
<Divider testId="separator-info-control" />
{onDownloadConversation && isV1Conversation && (
<ContextMenuListItem
testId="download-trajectory-button"
onClick={onDownloadConversation}
className={contextMenuListItemClassName}
>
<ConversationNameContextMenuIconText
icon={<DownloadIcon width={16} height={16} />}
text={t(I18nKey.BUTTON$EXPORT_CONVERSATION)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
)}
{(hasInfo || hasControl) && <Divider testId="separator-info-control" />}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"

View File

@ -30,6 +30,7 @@ export function ConversationName() {
handleDelete,
handleStop,
handleDownloadViaVSCode,
handleDownloadConversation,
handleDisplayCost,
handleShowAgentTools,
handleShowSkills,
@ -50,6 +51,7 @@ export function ConversationName() {
shouldShowStop,
shouldShowDownload,
shouldShowExport,
shouldShowDownloadConversation,
shouldShowDisplayCost,
shouldShowAgentTools,
shouldShowSkills,
@ -177,6 +179,11 @@ export function ConversationName() {
onDownloadViaVSCode={
shouldShowDownload ? handleDownloadViaVSCode : undefined
}
onDownloadConversation={
shouldShowDownloadConversation
? handleDownloadConversation
: undefined
}
position="bottom"
/>
)}

View File

@ -15,6 +15,8 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import { useEventStore } from "#/stores/use-event-store";
import { isV0Event } from "#/types/v1/type-guards";
import { useActiveConversation } from "./query/use-active-conversation";
import { useDownloadConversation } from "./use-download-conversation";
interface UseConversationNameContextMenuProps {
conversationId?: string;
@ -34,6 +36,7 @@ export function useConversationNameContextMenu({
const { conversationId: currentConversationId } = useParams();
const navigate = useNavigate();
const events = useEventStore((state) => state.events);
const { data: conversation } = useActiveConversation();
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
const { mutate: getTrajectory } = useGetTrajectory();
@ -46,6 +49,7 @@ export function useConversationNameContextMenu({
React.useState(false);
const [confirmStopModalVisible, setConfirmStopModalVisible] =
React.useState(false);
const { mutateAsync: downloadConversation } = useDownloadConversation();
const systemMessage = events
.filter(isV0Event)
@ -148,6 +152,17 @@ export function useConversationNameContextMenu({
onContextMenuToggle?.(false);
};
const handleDownloadConversation = async (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.preventDefault();
event.stopPropagation();
if (conversationId && conversation?.conversation_version === "V1") {
await downloadConversation(conversationId);
}
onContextMenuToggle?.(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setMetricsModalVisible(true);
@ -173,6 +188,7 @@ export function useConversationNameContextMenu({
handleEdit,
handleExportConversation,
handleDownloadViaVSCode,
handleDownloadConversation,
handleDisplayCost,
handleShowAgentTools,
handleShowSkills,
@ -199,6 +215,11 @@ export function useConversationNameContextMenu({
shouldShowStop: conversationStatus !== "STOPPED",
shouldShowDownload: Boolean(conversationId && showOptions),
shouldShowExport: Boolean(conversationId && showOptions),
shouldShowDownloadConversation: Boolean(
conversationId &&
showOptions &&
conversation?.conversation_version === "V1",
),
shouldShowDisplayCost: showOptions,
shouldShowAgentTools: Boolean(showOptions && systemMessage),
shouldShowSkills: Boolean(showOptions && conversationId),

View File

@ -0,0 +1,25 @@
import { useMutation } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { useTranslation } from "react-i18next";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { downloadBlob } from "#/utils/utils";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
export const useDownloadConversation = () => {
const posthog = usePostHog();
const { t } = useTranslation();
return useMutation({
mutationKey: ["conversations", "download"],
mutationFn: async (conversationId: string) => {
posthog.capture("download_trajectory_button_clicked");
const blob =
await V1ConversationService.downloadConversation(conversationId);
downloadBlob(blob, `conversation_${conversationId}.zip`);
},
onError: () => {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
},
});
};

View File

@ -8768,7 +8768,7 @@
"uk": "Позначити це рішення як некорисне"
},
"BUTTON$EXPORT_CONVERSATION": {
"en": "Export conversation",
"en": "Export Conversation",
"zh-CN": "导出对话",
"zh-TW": "導出對話",
"de": "Konversation exportieren",

View File

@ -12,6 +12,20 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Trigger a download for a provided Blob with the given filename
*/
export const downloadBlob = (blob: Blob, filename: string): void => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
/**
* Get the numeric height value from an element's style property
* @param el The HTML element to get the height from

View File

@ -29,7 +29,7 @@ else:
return await async_iterator.__anext__()
from fastapi import APIRouter, Query, Request, status
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
@ -546,6 +546,45 @@ async def get_conversation_skills(
)
@router.get('/{conversation_id}/download')
async def export_conversation(
conversation_id: UUID,
app_conversation_service: AppConversationService = (
app_conversation_service_dependency
),
):
"""Download a conversation trajectory as a zip file.
Returns a zip file containing all events and metadata for the conversation.
Args:
conversation_id: The UUID of the conversation to download
Returns:
A zip file containing the conversation trajectory
"""
try:
# Get the zip file content
zip_content = await app_conversation_service.export_conversation(
conversation_id
)
# Return as a downloadable zip file
return Response(
content=zip_content,
media_type='application/zip',
headers={
'Content-Disposition': f'attachment; filename="conversation_{conversation_id}.zip"'
},
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f'Failed to download trajectory: {str(e)}'
)
async def _consume_remaining(
async_iter, db_session: AsyncSession, httpx_client: httpx.AsyncClient
):

View File

@ -113,6 +113,23 @@ class AppConversationService(ABC):
Returns True if the conversation was deleted successfully, False otherwise.
"""
@abstractmethod
async def export_conversation(self, conversation_id: UUID) -> bytes:
"""Download a conversation trajectory as a zip file.
Args:
conversation_id: The UUID of the conversation to download.
This method should:
1. Get all events for the conversation
2. Create a temporary directory
3. Save each event as a JSON file
4. Save conversation metadata as meta.json
5. Create and return a zip file containing all the data
Returns the zip file as bytes.
"""
class AppConversationServiceInjector(
DiscriminatedUnionMixin, Injector[AppConversationService], ABC

View File

@ -1,5 +1,9 @@
import asyncio
import json
import logging
import os
import tempfile
import zipfile
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta
@ -44,6 +48,7 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
)
from openhands.app_server.config import get_event_callback_service
from openhands.app_server.errors import SandboxError
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event_callback.event_callback_models import EventCallback
from openhands.app_server.event_callback.event_callback_service import (
EventCallbackService,
@ -71,6 +76,7 @@ from openhands.integrations.provider import ProviderType
from openhands.sdk import Agent, AgentContext, LocalWorkspace
from openhands.sdk.llm import LLM
from openhands.sdk.secret import LookupSecret, StaticSecret
from openhands.sdk.utils.paging import page_iterator
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
from openhands.tools.preset.default import (
@ -93,6 +99,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
app_conversation_info_service: AppConversationInfoService
app_conversation_start_task_service: AppConversationStartTaskService
event_callback_service: EventCallbackService
event_service: EventService
sandbox_service: SandboxService
sandbox_spec_service: SandboxSpecService
jwt_service: JwtService
@ -1178,6 +1185,61 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
return deleted_info or deleted_tasks
async def export_conversation(self, conversation_id: UUID) -> bytes:
"""Download a conversation trajectory as a zip file.
Args:
conversation_id: The UUID of the conversation to download.
Returns the zip file as bytes.
"""
# Get the conversation info to verify it exists and user has access
conversation_info = (
await self.app_conversation_info_service.get_app_conversation_info(
conversation_id
)
)
if not conversation_info:
raise ValueError(f'Conversation not found: {conversation_id}')
# Create a temporary directory to store files
with tempfile.TemporaryDirectory() as temp_dir:
# Get all events for this conversation
i = 0
async for event in page_iterator(
self.event_service.search_events, conversation_id__eq=conversation_id
):
event_filename = f'event_{i:06d}_{event.id}.json'
event_path = os.path.join(temp_dir, event_filename)
with open(event_path, 'w') as f:
# Use model_dump with mode='json' to handle UUID serialization
event_data = event.model_dump(mode='json')
json.dump(event_data, f, indent=2)
i += 1
# Create meta.json with conversation info
meta_path = os.path.join(temp_dir, 'meta.json')
with open(meta_path, 'w') as f:
f.write(conversation_info.model_dump_json(indent=2))
# Create zip file in memory
zip_buffer = tempfile.NamedTemporaryFile()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Add all files from temp directory to zip
for root, dirs, files in os.walk(temp_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, temp_dir)
zipf.write(file_path, arcname)
# Read the zip file content
zip_buffer.seek(0)
zip_content = zip_buffer.read()
zip_buffer.close()
return zip_content
class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
sandbox_startup_timeout: int = Field(
@ -1208,6 +1270,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
from openhands.app_server.config import (
get_app_conversation_info_service,
get_app_conversation_start_task_service,
get_event_service,
get_global_config,
get_httpx_client,
get_jwt_service,
@ -1227,6 +1290,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
state, request
) as app_conversation_start_task_service,
get_event_callback_service(state, request) as event_callback_service,
get_event_service(state, request) as event_service,
get_jwt_service(state, request) as jwt_service,
get_httpx_client(state, request) as httpx_client,
):
@ -1274,6 +1338,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
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,
jwt_service=jwt_service,
sandbox_startup_timeout=self.sandbox_startup_timeout,
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,

View File

@ -22,7 +22,10 @@ async def iterate(fn: Callable, **kwargs) -> AsyncIterator:
kwargs['page_id'] = None
while True:
result_set = await fn(**kwargs)
for result in result_set.results:
items = getattr(result_set, 'items', None)
if items is None:
items = getattr(result_set, 'results')
for result in items:
yield result
if result_set.next_page_id is None:
return

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(),