mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +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;
|
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
|
* Get all skills associated with a V1 conversation
|
||||||
* @param conversationId The conversation ID
|
* @param conversationId The conversation ID
|
||||||
|
|||||||
@ -11,6 +11,7 @@ interface ConversationCardActionsProps {
|
|||||||
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
conversationStatus?: ConversationStatus;
|
conversationStatus?: ConversationStatus;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
showOptions?: boolean;
|
showOptions?: boolean;
|
||||||
@ -23,6 +24,7 @@ export function ConversationCardActions({
|
|||||||
onStop,
|
onStop,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDownloadViaVSCode,
|
onDownloadViaVSCode,
|
||||||
|
onDownloadConversation,
|
||||||
conversationStatus,
|
conversationStatus,
|
||||||
conversationId,
|
conversationId,
|
||||||
showOptions,
|
showOptions,
|
||||||
@ -62,6 +64,9 @@ export function ConversationCardActions({
|
|||||||
onDownloadViaVSCode={
|
onDownloadViaVSCode={
|
||||||
conversationId && showOptions ? onDownloadViaVSCode : undefined
|
conversationId && showOptions ? onDownloadViaVSCode : undefined
|
||||||
}
|
}
|
||||||
|
onDownloadConversation={
|
||||||
|
conversationId ? onDownloadConversation : undefined
|
||||||
|
}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ interface ConversationCardContextMenuProps {
|
|||||||
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
position?: "top" | "bottom";
|
position?: "top" | "bottom";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ export function ConversationCardContextMenu({
|
|||||||
onShowAgentTools,
|
onShowAgentTools,
|
||||||
onShowSkills,
|
onShowSkills,
|
||||||
onDownloadViaVSCode,
|
onDownloadViaVSCode,
|
||||||
|
onDownloadConversation,
|
||||||
position = "bottom",
|
position = "bottom",
|
||||||
}: ConversationCardContextMenuProps) {
|
}: ConversationCardContextMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -134,6 +136,18 @@ export function ConversationCardContextMenu({
|
|||||||
/>
|
/>
|
||||||
</ContextMenuListItem>
|
</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(
|
{generateSection(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { RepositorySelection } from "#/api/open-hands.types";
|
|||||||
import { ConversationCardHeader } from "./conversation-card-header";
|
import { ConversationCardHeader } from "./conversation-card-header";
|
||||||
import { ConversationCardActions } from "./conversation-card-actions";
|
import { ConversationCardActions } from "./conversation-card-actions";
|
||||||
import { ConversationCardFooter } from "./conversation-card-footer";
|
import { ConversationCardFooter } from "./conversation-card-footer";
|
||||||
|
import { useDownloadConversation } from "#/hooks/use-download-conversation";
|
||||||
|
|
||||||
interface ConversationCardProps {
|
interface ConversationCardProps {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@ -46,6 +47,7 @@ export function ConversationCard({
|
|||||||
}: ConversationCardProps) {
|
}: ConversationCardProps) {
|
||||||
const posthog = usePostHog();
|
const posthog = usePostHog();
|
||||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||||
|
const { mutateAsync: downloadConversation } = useDownloadConversation();
|
||||||
|
|
||||||
const onTitleSave = (newTitle: string) => {
|
const onTitleSave = (newTitle: string) => {
|
||||||
if (newTitle !== "" && newTitle !== title) {
|
if (newTitle !== "" && newTitle !== title) {
|
||||||
@ -101,6 +103,18 @@ export function ConversationCard({
|
|||||||
onContextMenuToggle?.(false);
|
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);
|
const hasContextMenu = !!(onDelete || onChangeTitle || showOptions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -130,6 +144,11 @@ export function ConversationCard({
|
|||||||
onStop={onStop && handleStop}
|
onStop={onStop && handleStop}
|
||||||
onEdit={onChangeTitle && handleEdit}
|
onEdit={onChangeTitle && handleEdit}
|
||||||
onDownloadViaVSCode={handleDownloadViaVSCode}
|
onDownloadViaVSCode={handleDownloadViaVSCode}
|
||||||
|
onDownloadConversation={
|
||||||
|
conversationVersion === "V1"
|
||||||
|
? handleDownloadConversation
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
conversationStatus={conversationStatus}
|
conversationStatus={conversationStatus}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
showOptions={showOptions}
|
showOptions={showOptions}
|
||||||
|
|||||||
@ -34,6 +34,7 @@ interface ConversationNameContextMenuProps {
|
|||||||
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
position?: "top" | "bottom";
|
position?: "top" | "bottom";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,6 +48,7 @@ export function ConversationNameContextMenu({
|
|||||||
onShowSkills,
|
onShowSkills,
|
||||||
onExportConversation,
|
onExportConversation,
|
||||||
onDownloadViaVSCode,
|
onDownloadViaVSCode,
|
||||||
|
onDownloadConversation,
|
||||||
position = "bottom",
|
position = "bottom",
|
||||||
}: ConversationNameContextMenuProps) {
|
}: ConversationNameContextMenuProps) {
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
@ -58,7 +60,7 @@ export function ConversationNameContextMenu({
|
|||||||
// This is a temporary measure and may be re-enabled in the future
|
// This is a temporary measure and may be re-enabled in the future
|
||||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||||
|
|
||||||
const hasDownload = Boolean(onDownloadViaVSCode);
|
const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation);
|
||||||
const hasExport = Boolean(onExportConversation);
|
const hasExport = Boolean(onExportConversation);
|
||||||
const hasTools = Boolean(onShowAgentTools || onShowSkills);
|
const hasTools = Boolean(onShowAgentTools || onShowSkills);
|
||||||
const hasInfo = Boolean(onDisplayCost);
|
const hasInfo = Boolean(onDisplayCost);
|
||||||
@ -118,9 +120,9 @@ export function ConversationNameContextMenu({
|
|||||||
</ContextMenuListItem>
|
</ContextMenuListItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasExport || hasDownload) && !isV1Conversation && (
|
{(hasExport || hasDownload) && !isV1Conversation ? (
|
||||||
<Divider testId="separator-export" />
|
<Divider testId="separator-export" />
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{onExportConversation && !isV1Conversation && (
|
{onExportConversation && !isV1Conversation && (
|
||||||
<ContextMenuListItem
|
<ContextMenuListItem
|
||||||
@ -150,10 +152,22 @@ export function ConversationNameContextMenu({
|
|||||||
</ContextMenuListItem>
|
</ContextMenuListItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasInfo || hasControl) && !isV1Conversation && (
|
{onDownloadConversation && isV1Conversation && (
|
||||||
<Divider testId="separator-info-control" />
|
<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 && (
|
{onDisplayCost && (
|
||||||
<ContextMenuListItem
|
<ContextMenuListItem
|
||||||
testId="display-cost-button"
|
testId="display-cost-button"
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export function ConversationName() {
|
|||||||
handleDelete,
|
handleDelete,
|
||||||
handleStop,
|
handleStop,
|
||||||
handleDownloadViaVSCode,
|
handleDownloadViaVSCode,
|
||||||
|
handleDownloadConversation,
|
||||||
handleDisplayCost,
|
handleDisplayCost,
|
||||||
handleShowAgentTools,
|
handleShowAgentTools,
|
||||||
handleShowSkills,
|
handleShowSkills,
|
||||||
@ -50,6 +51,7 @@ export function ConversationName() {
|
|||||||
shouldShowStop,
|
shouldShowStop,
|
||||||
shouldShowDownload,
|
shouldShowDownload,
|
||||||
shouldShowExport,
|
shouldShowExport,
|
||||||
|
shouldShowDownloadConversation,
|
||||||
shouldShowDisplayCost,
|
shouldShowDisplayCost,
|
||||||
shouldShowAgentTools,
|
shouldShowAgentTools,
|
||||||
shouldShowSkills,
|
shouldShowSkills,
|
||||||
@ -177,6 +179,11 @@ export function ConversationName() {
|
|||||||
onDownloadViaVSCode={
|
onDownloadViaVSCode={
|
||||||
shouldShowDownload ? handleDownloadViaVSCode : undefined
|
shouldShowDownload ? handleDownloadViaVSCode : undefined
|
||||||
}
|
}
|
||||||
|
onDownloadConversation={
|
||||||
|
shouldShowDownloadConversation
|
||||||
|
? handleDownloadConversation
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
|||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
import { useEventStore } from "#/stores/use-event-store";
|
import { useEventStore } from "#/stores/use-event-store";
|
||||||
import { isV0Event } from "#/types/v1/type-guards";
|
import { isV0Event } from "#/types/v1/type-guards";
|
||||||
|
import { useActiveConversation } from "./query/use-active-conversation";
|
||||||
|
import { useDownloadConversation } from "./use-download-conversation";
|
||||||
|
|
||||||
interface UseConversationNameContextMenuProps {
|
interface UseConversationNameContextMenuProps {
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
@ -34,6 +36,7 @@ export function useConversationNameContextMenu({
|
|||||||
const { conversationId: currentConversationId } = useParams();
|
const { conversationId: currentConversationId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const events = useEventStore((state) => state.events);
|
const events = useEventStore((state) => state.events);
|
||||||
|
const { data: conversation } = useActiveConversation();
|
||||||
const { mutate: deleteConversation } = useDeleteConversation();
|
const { mutate: deleteConversation } = useDeleteConversation();
|
||||||
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
|
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
|
||||||
const { mutate: getTrajectory } = useGetTrajectory();
|
const { mutate: getTrajectory } = useGetTrajectory();
|
||||||
@ -46,6 +49,7 @@ export function useConversationNameContextMenu({
|
|||||||
React.useState(false);
|
React.useState(false);
|
||||||
const [confirmStopModalVisible, setConfirmStopModalVisible] =
|
const [confirmStopModalVisible, setConfirmStopModalVisible] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
|
const { mutateAsync: downloadConversation } = useDownloadConversation();
|
||||||
|
|
||||||
const systemMessage = events
|
const systemMessage = events
|
||||||
.filter(isV0Event)
|
.filter(isV0Event)
|
||||||
@ -148,6 +152,17 @@ export function useConversationNameContextMenu({
|
|||||||
onContextMenuToggle?.(false);
|
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>) => {
|
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setMetricsModalVisible(true);
|
setMetricsModalVisible(true);
|
||||||
@ -173,6 +188,7 @@ export function useConversationNameContextMenu({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleExportConversation,
|
handleExportConversation,
|
||||||
handleDownloadViaVSCode,
|
handleDownloadViaVSCode,
|
||||||
|
handleDownloadConversation,
|
||||||
handleDisplayCost,
|
handleDisplayCost,
|
||||||
handleShowAgentTools,
|
handleShowAgentTools,
|
||||||
handleShowSkills,
|
handleShowSkills,
|
||||||
@ -199,6 +215,11 @@ export function useConversationNameContextMenu({
|
|||||||
shouldShowStop: conversationStatus !== "STOPPED",
|
shouldShowStop: conversationStatus !== "STOPPED",
|
||||||
shouldShowDownload: Boolean(conversationId && showOptions),
|
shouldShowDownload: Boolean(conversationId && showOptions),
|
||||||
shouldShowExport: Boolean(conversationId && showOptions),
|
shouldShowExport: Boolean(conversationId && showOptions),
|
||||||
|
shouldShowDownloadConversation: Boolean(
|
||||||
|
conversationId &&
|
||||||
|
showOptions &&
|
||||||
|
conversation?.conversation_version === "V1",
|
||||||
|
),
|
||||||
shouldShowDisplayCost: showOptions,
|
shouldShowDisplayCost: showOptions,
|
||||||
shouldShowAgentTools: Boolean(showOptions && systemMessage),
|
shouldShowAgentTools: Boolean(showOptions && systemMessage),
|
||||||
shouldShowSkills: Boolean(showOptions && conversationId),
|
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": "Позначити це рішення як некорисне"
|
"uk": "Позначити це рішення як некорисне"
|
||||||
},
|
},
|
||||||
"BUTTON$EXPORT_CONVERSATION": {
|
"BUTTON$EXPORT_CONVERSATION": {
|
||||||
"en": "Export conversation",
|
"en": "Export Conversation",
|
||||||
"zh-CN": "导出对话",
|
"zh-CN": "导出对话",
|
||||||
"zh-TW": "導出對話",
|
"zh-TW": "導出對話",
|
||||||
"de": "Konversation exportieren",
|
"de": "Konversation exportieren",
|
||||||
|
|||||||
@ -12,6 +12,20 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
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
|
* Get the numeric height value from an element's style property
|
||||||
* @param el The HTML element to get the height from
|
* @param el The HTML element to get the height from
|
||||||
|
|||||||
@ -29,7 +29,7 @@ else:
|
|||||||
return await async_iterator.__anext__()
|
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 fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 def _consume_remaining(
|
||||||
async_iter, db_session: AsyncSession, httpx_client: httpx.AsyncClient
|
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.
|
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(
|
class AppConversationServiceInjector(
|
||||||
DiscriminatedUnionMixin, Injector[AppConversationService], ABC
|
DiscriminatedUnionMixin, Injector[AppConversationService], ABC
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
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.config import get_event_callback_service
|
||||||
from openhands.app_server.errors import SandboxError
|
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_models import EventCallback
|
||||||
from openhands.app_server.event_callback.event_callback_service import (
|
from openhands.app_server.event_callback.event_callback_service import (
|
||||||
EventCallbackService,
|
EventCallbackService,
|
||||||
@ -71,6 +76,7 @@ from openhands.integrations.provider import ProviderType
|
|||||||
from openhands.sdk import Agent, AgentContext, LocalWorkspace
|
from openhands.sdk import Agent, AgentContext, LocalWorkspace
|
||||||
from openhands.sdk.llm import LLM
|
from openhands.sdk.llm import LLM
|
||||||
from openhands.sdk.secret import LookupSecret, StaticSecret
|
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.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
|
||||||
from openhands.server.types import AppMode
|
from openhands.server.types import AppMode
|
||||||
from openhands.tools.preset.default import (
|
from openhands.tools.preset.default import (
|
||||||
@ -93,6 +99,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
|||||||
app_conversation_info_service: AppConversationInfoService
|
app_conversation_info_service: AppConversationInfoService
|
||||||
app_conversation_start_task_service: AppConversationStartTaskService
|
app_conversation_start_task_service: AppConversationStartTaskService
|
||||||
event_callback_service: EventCallbackService
|
event_callback_service: EventCallbackService
|
||||||
|
event_service: EventService
|
||||||
sandbox_service: SandboxService
|
sandbox_service: SandboxService
|
||||||
sandbox_spec_service: SandboxSpecService
|
sandbox_spec_service: SandboxSpecService
|
||||||
jwt_service: JwtService
|
jwt_service: JwtService
|
||||||
@ -1178,6 +1185,61 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
|||||||
|
|
||||||
return deleted_info or deleted_tasks
|
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):
|
class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||||
sandbox_startup_timeout: int = Field(
|
sandbox_startup_timeout: int = Field(
|
||||||
@ -1208,6 +1270,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
|||||||
from openhands.app_server.config import (
|
from openhands.app_server.config import (
|
||||||
get_app_conversation_info_service,
|
get_app_conversation_info_service,
|
||||||
get_app_conversation_start_task_service,
|
get_app_conversation_start_task_service,
|
||||||
|
get_event_service,
|
||||||
get_global_config,
|
get_global_config,
|
||||||
get_httpx_client,
|
get_httpx_client,
|
||||||
get_jwt_service,
|
get_jwt_service,
|
||||||
@ -1227,6 +1290,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
|||||||
state, request
|
state, request
|
||||||
) as app_conversation_start_task_service,
|
) as app_conversation_start_task_service,
|
||||||
get_event_callback_service(state, request) as event_callback_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_jwt_service(state, request) as jwt_service,
|
||||||
get_httpx_client(state, request) as httpx_client,
|
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_info_service=app_conversation_info_service,
|
||||||
app_conversation_start_task_service=app_conversation_start_task_service,
|
app_conversation_start_task_service=app_conversation_start_task_service,
|
||||||
event_callback_service=event_callback_service,
|
event_callback_service=event_callback_service,
|
||||||
|
event_service=event_service,
|
||||||
jwt_service=jwt_service,
|
jwt_service=jwt_service,
|
||||||
sandbox_startup_timeout=self.sandbox_startup_timeout,
|
sandbox_startup_timeout=self.sandbox_startup_timeout,
|
||||||
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,
|
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,
|
||||||
|
|||||||
@ -22,7 +22,10 @@ async def iterate(fn: Callable, **kwargs) -> AsyncIterator:
|
|||||||
kwargs['page_id'] = None
|
kwargs['page_id'] = None
|
||||||
while True:
|
while True:
|
||||||
result_set = await fn(**kwargs)
|
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
|
yield result
|
||||||
if result_set.next_page_id is None:
|
if result_set.next_page_id is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,13 +1,21 @@
|
|||||||
"""Unit tests for the methods in LiveStatusAppConversationService."""
|
"""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 unittest.mock import AsyncMock, Mock, patch
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import pytest
|
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 (
|
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||||
AgentType,
|
AgentType,
|
||||||
|
AppConversationInfo,
|
||||||
AppConversationStartRequest,
|
AppConversationStartRequest,
|
||||||
)
|
)
|
||||||
from openhands.app_server.app_conversation.live_status_app_conversation_service import (
|
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.sandbox.sandbox_spec_models import SandboxSpecInfo
|
||||||
from openhands.app_server.user.user_context import UserContext
|
from openhands.app_server.user.user_context import UserContext
|
||||||
from openhands.integrations.provider import ProviderType
|
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.llm import LLM
|
||||||
from openhands.sdk.secret import LookupSecret, StaticSecret
|
from openhands.sdk.secret import LookupSecret, StaticSecret
|
||||||
from openhands.sdk.workspace import LocalWorkspace
|
from openhands.sdk.workspace import LocalWorkspace
|
||||||
@ -45,6 +53,7 @@ class TestLiveStatusAppConversationService:
|
|||||||
self.mock_app_conversation_info_service = Mock()
|
self.mock_app_conversation_info_service = Mock()
|
||||||
self.mock_app_conversation_start_task_service = Mock()
|
self.mock_app_conversation_start_task_service = Mock()
|
||||||
self.mock_event_callback_service = Mock()
|
self.mock_event_callback_service = Mock()
|
||||||
|
self.mock_event_service = Mock()
|
||||||
self.mock_httpx_client = Mock()
|
self.mock_httpx_client = Mock()
|
||||||
|
|
||||||
# Create service instance
|
# Create service instance
|
||||||
@ -54,6 +63,7 @@ class TestLiveStatusAppConversationService:
|
|||||||
app_conversation_info_service=self.mock_app_conversation_info_service,
|
app_conversation_info_service=self.mock_app_conversation_info_service,
|
||||||
app_conversation_start_task_service=self.mock_app_conversation_start_task_service,
|
app_conversation_start_task_service=self.mock_app_conversation_start_task_service,
|
||||||
event_callback_service=self.mock_event_callback_service,
|
event_callback_service=self.mock_event_callback_service,
|
||||||
|
event_service=self.mock_event_service,
|
||||||
sandbox_service=self.mock_sandbox_service,
|
sandbox_service=self.mock_sandbox_service,
|
||||||
sandbox_spec_service=self.mock_sandbox_spec_service,
|
sandbox_spec_service=self.mock_sandbox_spec_service,
|
||||||
jwt_service=self.mock_jwt_service,
|
jwt_service=self.mock_jwt_service,
|
||||||
@ -852,6 +862,238 @@ class TestLiveStatusAppConversationService:
|
|||||||
self.service._finalize_conversation_request.assert_called_once()
|
self.service._finalize_conversation_request.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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(
|
@patch(
|
||||||
'openhands.app_server.app_conversation.live_status_app_conversation_service.AsyncRemoteWorkspace'
|
'openhands.app_server.app_conversation.live_status_app_conversation_service.AsyncRemoteWorkspace'
|
||||||
)
|
)
|
||||||
|
|||||||
@ -176,12 +176,15 @@ class TestExperimentManagerIntegration:
|
|||||||
jwt_service = Mock()
|
jwt_service = Mock()
|
||||||
httpx_client = Mock()
|
httpx_client = Mock()
|
||||||
|
|
||||||
|
event_service = Mock()
|
||||||
|
|
||||||
service = LiveStatusAppConversationService(
|
service = LiveStatusAppConversationService(
|
||||||
init_git_in_empty_workspace=False,
|
init_git_in_empty_workspace=False,
|
||||||
user_context=user_context,
|
user_context=user_context,
|
||||||
app_conversation_info_service=app_conversation_info_service,
|
app_conversation_info_service=app_conversation_info_service,
|
||||||
app_conversation_start_task_service=app_conversation_start_task_service,
|
app_conversation_start_task_service=app_conversation_start_task_service,
|
||||||
event_callback_service=event_callback_service,
|
event_callback_service=event_callback_service,
|
||||||
|
event_service=event_service,
|
||||||
sandbox_service=sandbox_service,
|
sandbox_service=sandbox_service,
|
||||||
sandbox_spec_service=sandbox_spec_service,
|
sandbox_spec_service=sandbox_spec_service,
|
||||||
jwt_service=jwt_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_info_service=mock_info_service,
|
||||||
app_conversation_start_task_service=mock_start_task_service,
|
app_conversation_start_task_service=mock_start_task_service,
|
||||||
event_callback_service=MagicMock(),
|
event_callback_service=MagicMock(),
|
||||||
|
event_service=MagicMock(),
|
||||||
sandbox_service=mock_sandbox_service,
|
sandbox_service=mock_sandbox_service,
|
||||||
sandbox_spec_service=MagicMock(),
|
sandbox_spec_service=MagicMock(),
|
||||||
jwt_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_info_service=mock_info_service,
|
||||||
app_conversation_start_task_service=mock_start_task_service,
|
app_conversation_start_task_service=mock_start_task_service,
|
||||||
event_callback_service=MagicMock(),
|
event_callback_service=MagicMock(),
|
||||||
|
event_service=MagicMock(),
|
||||||
sandbox_service=mock_sandbox_service,
|
sandbox_service=mock_sandbox_service,
|
||||||
sandbox_spec_service=MagicMock(),
|
sandbox_spec_service=MagicMock(),
|
||||||
jwt_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_info_service=mock_info_service,
|
||||||
app_conversation_start_task_service=mock_start_task_service,
|
app_conversation_start_task_service=mock_start_task_service,
|
||||||
event_callback_service=MagicMock(),
|
event_callback_service=MagicMock(),
|
||||||
|
event_service=MagicMock(),
|
||||||
sandbox_service=mock_sandbox_service,
|
sandbox_service=mock_sandbox_service,
|
||||||
sandbox_spec_service=MagicMock(),
|
sandbox_spec_service=MagicMock(),
|
||||||
jwt_service=MagicMock(),
|
jwt_service=MagicMock(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user