From 6d14ce420edebb39d330c8d805c88d46946eb621 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Wed, 24 Dec 2025 10:50:57 -0700 Subject: [PATCH] Implement Export feature for V1 conversations with comprehensive unit tests (#12030) Co-authored-by: openhands Co-authored-by: hieptl --- .../v1-conversation-service.api.ts | 15 ++ .../conversation-card-actions.tsx | 5 + .../conversation-card-context-menu.tsx | 14 + .../conversation-card/conversation-card.tsx | 19 ++ .../conversation-name-context-menu.tsx | 24 +- .../conversation/conversation-name.tsx | 7 + .../use-conversation-name-context-menu.ts | 21 ++ .../src/hooks/use-download-conversation.ts | 25 ++ frontend/src/i18n/translation.json | 2 +- frontend/src/utils/utils.ts | 14 + .../app_conversation_router.py | 41 ++- .../app_conversation_service.py | 17 ++ .../live_status_app_conversation_service.py | 65 +++++ openhands/utils/search_utils.py | 5 +- ...st_live_status_app_conversation_service.py | 246 +++++++++++++++++- .../experiments/test_experiment_manager.py | 3 + .../server/data_models/test_conversation.py | 3 + 17 files changed, 516 insertions(+), 10 deletions(-) create mode 100644 frontend/src/hooks/use-download-conversation.ts diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index d2f8f51ff5..25aa4a1130 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -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 { + 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 diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx index 43b7bc1987..7afa2fed14 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-actions.tsx @@ -11,6 +11,7 @@ interface ConversationCardActionsProps { onStop?: (event: React.MouseEvent) => void; onEdit?: (event: React.MouseEvent) => void; onDownloadViaVSCode?: (event: React.MouseEvent) => void; + onDownloadConversation?: (event: React.MouseEvent) => 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" /> diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx index 30a7ec42cb..06f5021002 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx @@ -24,6 +24,7 @@ interface ConversationCardContextMenuProps { onShowAgentTools?: (event: React.MouseEvent) => void; onShowSkills?: (event: React.MouseEvent) => void; onDownloadViaVSCode?: (event: React.MouseEvent) => void; + onDownloadConversation?: (event: React.MouseEvent) => 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({ /> ), + onDownloadConversation && ( + + } + text={t(I18nKey.BUTTON$EXPORT_CONVERSATION)} + /> + + ), ])} {generateSection( [ diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx index fff0a0888d..22fa91f3ac 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card.tsx @@ -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, + ) => { + 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} diff --git a/frontend/src/components/features/conversation/conversation-name-context-menu.tsx b/frontend/src/components/features/conversation/conversation-name-context-menu.tsx index 95de15b37e..672c10cb6b 100644 --- a/frontend/src/components/features/conversation/conversation-name-context-menu.tsx +++ b/frontend/src/components/features/conversation/conversation-name-context-menu.tsx @@ -34,6 +34,7 @@ interface ConversationNameContextMenuProps { onShowSkills?: (event: React.MouseEvent) => void; onExportConversation?: (event: React.MouseEvent) => void; onDownloadViaVSCode?: (event: React.MouseEvent) => void; + onDownloadConversation?: (event: React.MouseEvent) => 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({ )} - {(hasExport || hasDownload) && !isV1Conversation && ( + {(hasExport || hasDownload) && !isV1Conversation ? ( - )} + ) : null} {onExportConversation && !isV1Conversation && ( )} - {(hasInfo || hasControl) && !isV1Conversation && ( - + {onDownloadConversation && isV1Conversation && ( + + } + text={t(I18nKey.BUTTON$EXPORT_CONVERSATION)} + className={CONTEXT_MENU_ICON_TEXT_CLASSNAME} + /> + )} + {(hasInfo || hasControl) && } + {onDisplayCost && ( )} diff --git a/frontend/src/hooks/use-conversation-name-context-menu.ts b/frontend/src/hooks/use-conversation-name-context-menu.ts index 6072d5331e..0bc43bd4b6 100644 --- a/frontend/src/hooks/use-conversation-name-context-menu.ts +++ b/frontend/src/hooks/use-conversation-name-context-menu.ts @@ -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, + ) => { + event.preventDefault(); + event.stopPropagation(); + if (conversationId && conversation?.conversation_version === "V1") { + await downloadConversation(conversationId); + } + onContextMenuToggle?.(false); + }; + const handleDisplayCost = (event: React.MouseEvent) => { 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), diff --git a/frontend/src/hooks/use-download-conversation.ts b/frontend/src/hooks/use-download-conversation.ts new file mode 100644 index 0000000000..ae2f4c507c --- /dev/null +++ b/frontend/src/hooks/use-download-conversation.ts @@ -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)); + }, + }); +}; diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 81df3b6f7d..717e515107 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -8768,7 +8768,7 @@ "uk": "Позначити це рішення як некорисне" }, "BUTTON$EXPORT_CONVERSATION": { - "en": "Export conversation", + "en": "Export Conversation", "zh-CN": "导出对话", "zh-TW": "導出對話", "de": "Konversation exportieren", diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 69ff7aae5f..a7408a7177 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -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 diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index a7a0414e31..532602dbca 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -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 ): diff --git a/openhands/app_server/app_conversation/app_conversation_service.py b/openhands/app_server/app_conversation/app_conversation_service.py index 8d6c6775a8..b1b10c39ba 100644 --- a/openhands/app_server/app_conversation/app_conversation_service.py +++ b/openhands/app_server/app_conversation/app_conversation_service.py @@ -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 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 db30710f76..84f20de07a 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 @@ -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, diff --git a/openhands/utils/search_utils.py b/openhands/utils/search_utils.py index b7714249f8..b5af01f2ec 100644 --- a/openhands/utils/search_utils.py +++ b/openhands/utils/search_utils.py @@ -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 diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index f662f33146..f05cb0581a 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -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' ) diff --git a/tests/unit/experiments/test_experiment_manager.py b/tests/unit/experiments/test_experiment_manager.py index c389423cf5..70cd6c5d07 100644 --- a/tests/unit/experiments/test_experiment_manager.py +++ b/tests/unit/experiments/test_experiment_manager.py @@ -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, diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py index d5e289ecfa..fc305d170e 100644 --- a/tests/unit/server/data_models/test_conversation.py +++ b/tests/unit/server/data_models/test_conversation.py @@ -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(),