mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08: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:
parent
36fe23aea3
commit
6d14ce420e
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
[
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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),
|
||||
|
||||
25
frontend/src/hooks/use-download-conversation.ts
Normal file
25
frontend/src/hooks/use-download-conversation.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -8768,7 +8768,7 @@
|
||||
"uk": "Позначити це рішення як некорисне"
|
||||
},
|
||||
"BUTTON$EXPORT_CONVERSATION": {
|
||||
"en": "Export conversation",
|
||||
"en": "Export Conversation",
|
||||
"zh-CN": "导出对话",
|
||||
"zh-TW": "導出對話",
|
||||
"de": "Konversation exportieren",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user