mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
12 Commits
openhands/
...
openhands/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9392c5a60e | ||
|
|
9b292694ed | ||
|
|
c4400d97a2 | ||
|
|
409ec72088 | ||
|
|
c522afd568 | ||
|
|
c70133295a | ||
|
|
bcd5983583 | ||
|
|
e21837a205 | ||
|
|
7ca3fb4e1a | ||
|
|
71185b2773 | ||
|
|
827c21f865 | ||
|
|
8c5631ba48 |
15
.openhands/TASKS.md
Normal file
15
.openhands/TASKS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Task List
|
||||
|
||||
1. ✅ Analyze all spinner implementations and their usage
|
||||
|
||||
2. ✅ Create a unified spinner component to replace all duplicates
|
||||
|
||||
3. ✅ Replace LoadingSpinner usages with unified spinner
|
||||
|
||||
4. ✅ Replace local LoadingSpinner in diff-viewer with unified spinner
|
||||
|
||||
5. ✅ Replace HeroUI Spinner usages with unified spinner
|
||||
|
||||
6. ✅ Remove duplicate spinner components
|
||||
|
||||
7. 🔄 Test that all spinner replacements work correctly
|
||||
@@ -22,7 +22,7 @@ import { ActionSuggestions } from "./action-suggestions";
|
||||
import { ScrollProvider } from "#/context/scroll-context";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { downloadTrajectory } from "#/utils/download-trajectory";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
@@ -208,7 +208,7 @@ export function ChatInterface() {
|
||||
>
|
||||
{isLoadingMessages && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
|
||||
export function LoadingMicroagentBody() {
|
||||
@@ -9,7 +9,7 @@ export function LoadingMicroagentBody() {
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
</h2>
|
||||
<Spinner size="lg" />
|
||||
<Spinner size="large" />
|
||||
<p>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</p>
|
||||
</ModalBody>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
@@ -36,7 +36,7 @@ export function MicroagentStatusIndicator({
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return <Spinner size="sm" />;
|
||||
return <Spinner size="small" />;
|
||||
case MicroagentStatus.COMPLETED:
|
||||
return <SuccessIndicator status="success" />;
|
||||
case MicroagentStatus.ERROR:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
@@ -17,7 +17,7 @@ function ConversationCreatedToast({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="small" />
|
||||
<div>
|
||||
{t("MICROAGENT$ADDING_CONTEXT")}
|
||||
<br />
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "./confirm-stop-modal";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { ExitConversationModal } from "./exit-conversation-modal";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { Provider } from "#/types/settings";
|
||||
@@ -132,7 +132,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
>
|
||||
{isFetching && conversations.length === 0 && (
|
||||
<div className="w-full h-full absolute flex justify-center items-center">
|
||||
<LoadingSpinner size="small" />
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
@@ -183,7 +183,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
{/* Loading indicator for fetching more conversations */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-4">
|
||||
<LoadingSpinner size="small" />
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,26 +8,7 @@ import { getLanguageFromPath } from "#/utils/get-language-from-path";
|
||||
import { cn } from "#/utils/utils";
|
||||
import ChevronUp from "#/icons/chveron-up.svg?react";
|
||||
import { useGitDiff } from "#/hooks/query/use-get-diff";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// TODO: Move out of this file and replace the current spinner with this one
|
||||
function LoadingSpinner({ className }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"animate-spin rounded-full border-4 border-gray-200 border-t-blue-500",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
|
||||
const STATUS_MAP: Record<GitChangeStatus, string | IconType> = {
|
||||
A: LuFilePlus,
|
||||
@@ -144,7 +125,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
|
||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||
>
|
||||
<span className="text-sm w-full text-content flex items-center gap-2">
|
||||
{isFetchingData && <LoadingSpinner className="w-5 h-5" />}
|
||||
{isFetchingData && <Spinner size="small" />}
|
||||
{!isFetchingData && statusIcon}
|
||||
<strong className="w-full truncate">{filePath}</strong>
|
||||
<button data-testid="collapse" type="button">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface BranchLoadingStateProps {
|
||||
@@ -18,7 +18,7 @@ export function BranchLoadingState({
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="small" />
|
||||
<span className="text-sm">{t("HOME$LOADING_BRANCHES")}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
|
||||
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
|
||||
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
|
||||
@@ -86,7 +86,7 @@ export function MicroagentManagementRepoMicroagents({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pb-4 flex justify-center">
|
||||
<Spinner size="sm" data-testid="loading-spinner" />
|
||||
<Spinner size="small" data-testid="loading-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
|
||||
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
@@ -65,7 +65,7 @@ export function MicroagentManagementSidebar({
|
||||
<MicroagentManagementSidebarHeader />
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 flex-1">
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="small" />
|
||||
<span className="text-sm text-white">
|
||||
{t("HOME$LOADING_REPOSITORIES")}
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useSelector } from "react-redux";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { code } from "../markdown/code";
|
||||
import { ul, ol } from "../markdown/list";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
@@ -46,7 +46,10 @@ export function MicroagentManagementViewMicroagentContent() {
|
||||
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner size="lg" data-testid="loading-microagent-content-spinner" />
|
||||
<Spinner
|
||||
size="large"
|
||||
data-testid="loading-microagent-content-spinner"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "#/utils/utils";
|
||||
import MoneyIcon from "#/icons/money.svg?react";
|
||||
import { SettingsInput } from "../settings/settings-input";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
|
||||
@@ -54,7 +54,7 @@ export function PaymentForm() {
|
||||
{!isLoading && (
|
||||
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
{isLoading && <Spinner size="small" />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -79,7 +79,7 @@ export function PaymentForm() {
|
||||
>
|
||||
{t(I18nKey.PAYMENT$ADD_CREDIT)}
|
||||
</BrandButton>
|
||||
{isPending && <LoadingSpinner size="small" />}
|
||||
{isPending && <Spinner size="small" />}
|
||||
<PoweredByStripeTag />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { FaTrash, FaEye, FaEyeSlash, FaCopy } from "react-icons/fa6";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys";
|
||||
import {
|
||||
displayErrorToast,
|
||||
@@ -64,7 +64,7 @@ function LlmApiKeyManager({
|
||||
isDisabled={refreshLlmApiKey.isPending}
|
||||
>
|
||||
{refreshLlmApiKey.isPending ? (
|
||||
<LoadingSpinner size="small" />
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
t(I18nKey.SETTINGS$REFRESH_LLM_API_KEY)
|
||||
)}
|
||||
@@ -148,7 +148,7 @@ function ApiKeysTable({ apiKeys, isLoading, onDeleteKey }: ApiKeysTableProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-4">
|
||||
<LoadingSpinner size="large" />
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { CreateApiKeyResponse } from "#/api/api-keys";
|
||||
import {
|
||||
displayErrorToast,
|
||||
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
|
||||
isDisabled={createApiKeyMutation.isPending || !newKeyName.trim()}
|
||||
>
|
||||
{createApiKeyMutation.isPending ? (
|
||||
<LoadingSpinner size="small" />
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
t(I18nKey.BUTTON$CREATE)
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { ApiKey } from "#/api/api-keys";
|
||||
import {
|
||||
displayErrorToast,
|
||||
@@ -49,7 +49,7 @@ export function DeleteApiKeyModal({
|
||||
isDisabled={deleteApiKeyMutation.isPending}
|
||||
>
|
||||
{deleteApiKeyMutation.isPending ? (
|
||||
<LoadingSpinner size="small" />
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
t(I18nKey.BUTTON$DELETE)
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import ProfileIcon from "#/icons/profile.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Avatar } from "./avatar";
|
||||
@@ -35,7 +35,7 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
className="text-[#9099AC]"
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
{isLoading && <Spinner size="small" data-testid="loading-spinner" />}
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink } from "react-router";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BetaBadge } from "./beta-badge";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
|
||||
interface NavTabProps {
|
||||
to: string;
|
||||
@@ -40,7 +40,7 @@ export function NavTab({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{rightContent}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
{isLoading && <Spinner size="small" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
|
||||
// Lazy load all tab components
|
||||
const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
@@ -32,7 +32,7 @@ export function TabContent({ conversationPath }: TabContentProps) {
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="large" />
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import LoadingSpinnerOuter from "#/icons/loading-outer.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size: "small" | "large";
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size }: LoadingSpinnerProps) {
|
||||
const sizeStyle =
|
||||
size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]";
|
||||
|
||||
return (
|
||||
<div data-testid="loading-spinner" className={cn("relative", sizeStyle)}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full border-4 border-[#525252] absolute",
|
||||
sizeStyle,
|
||||
)}
|
||||
/>
|
||||
<LoadingSpinnerOuter className={cn("absolute animate-spin", sizeStyle)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "../../loading-spinner";
|
||||
import { Spinner } from "#/components/shared/spinner";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { SettingsForm } from "./settings-form";
|
||||
import { Settings } from "#/types/settings";
|
||||
@@ -43,7 +43,7 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
|
||||
|
||||
{aiConfigOptions.isLoading && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
{aiConfigOptions.data && (
|
||||
|
||||
32
frontend/src/components/shared/spinner.tsx
Normal file
32
frontend/src/components/shared/spinner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface SpinnerProps {
|
||||
size?: "small" | "medium" | "large";
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
small: "w-5 h-5",
|
||||
medium: "w-8 h-8",
|
||||
large: "w-12 h-12",
|
||||
};
|
||||
|
||||
export function Spinner({
|
||||
size = "medium",
|
||||
className,
|
||||
"data-testid": testId = "spinner",
|
||||
}: SpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"inline-block animate-spin rounded-full border-4 border-gray-200 border-t-blue-500",
|
||||
SIZE_CLASSES[size],
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -17,6 +19,7 @@ from openhands.cli.settings import (
|
||||
modify_llm_settings_basic,
|
||||
modify_search_api_settings,
|
||||
)
|
||||
from openhands.cli.spinner import show_loading_spinner
|
||||
from openhands.cli.tui import (
|
||||
COLOR_GREY,
|
||||
UsageMetrics,
|
||||
@@ -50,6 +53,9 @@ from openhands.events.action import (
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.conversation.file_conversation_store import FileConversationStore
|
||||
from openhands.storage.local import LocalFileStore
|
||||
from openhands.storage.settings.file_settings_store import FileSettingsStore
|
||||
|
||||
|
||||
@@ -165,6 +171,8 @@ async def handle_commands(
|
||||
)
|
||||
elif command == '/mcp':
|
||||
await handle_mcp_command(config)
|
||||
elif command == '/conversations':
|
||||
await handle_conv_command(config)
|
||||
else:
|
||||
close_repl = True
|
||||
action = MessageAction(content=command)
|
||||
@@ -882,3 +890,291 @@ async def remove_mcp_server(config: OpenHandsConfig) -> None:
|
||||
restart_cli()
|
||||
else:
|
||||
print_formatted_text(f'Failed to remove {server_type} server "{identifier}".')
|
||||
|
||||
|
||||
async def handle_conv_command(config: OpenHandsConfig) -> None:
|
||||
"""Handle the /conversations command to view conversation history."""
|
||||
# Show loading animation while getting conversations
|
||||
stop_event = threading.Event()
|
||||
spinner_thread = threading.Thread(target=show_loading_spinner, args=(stop_event,))
|
||||
spinner_thread.start()
|
||||
|
||||
try:
|
||||
# Get conversation folders sorted by creation time
|
||||
conversation_folders = get_conversation_folders_by_time(config)
|
||||
finally:
|
||||
# Stop the spinner
|
||||
stop_event.set()
|
||||
spinner_thread.join()
|
||||
|
||||
if not conversation_folders:
|
||||
print_formatted_text(HTML('<ansired>No conversations found.</ansired>'))
|
||||
return
|
||||
|
||||
# Start with first page
|
||||
current_page = 0
|
||||
page_size = 10
|
||||
|
||||
while True:
|
||||
# Display current page of conversations
|
||||
start_idx = current_page * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_conversations = conversation_folders[start_idx:end_idx]
|
||||
|
||||
if not page_conversations:
|
||||
print_formatted_text(HTML('<ansired>No more conversations.</ansired>'))
|
||||
current_page = max(0, current_page - 1)
|
||||
continue
|
||||
|
||||
# Display conversations for current page
|
||||
await display_conversation_page(config, page_conversations, current_page + 1)
|
||||
|
||||
# Show navigation options
|
||||
options = []
|
||||
if current_page > 0:
|
||||
options.append('Previous page')
|
||||
if end_idx < len(conversation_folders):
|
||||
options.append('Next page')
|
||||
options.extend(
|
||||
[
|
||||
'View conversation details',
|
||||
'Go back',
|
||||
]
|
||||
)
|
||||
|
||||
action = cli_confirm(
|
||||
config,
|
||||
f'Conversation History (Page {current_page + 1})',
|
||||
options,
|
||||
)
|
||||
|
||||
if 'Next page' in options and action == options.index('Next page'):
|
||||
current_page += 1
|
||||
elif 'Previous page' in options and action == options.index('Previous page'):
|
||||
current_page -= 1
|
||||
elif action == options.index('View conversation details'):
|
||||
await view_conversation_details(config)
|
||||
else: # Go back
|
||||
break
|
||||
|
||||
|
||||
def get_conversation_folders_by_time(
|
||||
config: OpenHandsConfig,
|
||||
) -> list[tuple[str, float]]:
|
||||
"""Get conversation folders sorted by creation time (most recent first)."""
|
||||
try:
|
||||
# Get the sessions directory path
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
|
||||
# Only works with LocalFileStore that has a root attribute
|
||||
if not isinstance(file_store, LocalFileStore):
|
||||
return []
|
||||
|
||||
sessions_path = Path(file_store.root) / 'sessions'
|
||||
|
||||
if not sessions_path.exists():
|
||||
return []
|
||||
|
||||
# Get all conversation folders with their creation times
|
||||
conversation_folders = []
|
||||
for folder in sessions_path.iterdir():
|
||||
if folder.is_dir():
|
||||
try:
|
||||
# Use folder creation time (or modification time as fallback)
|
||||
creation_time = folder.stat().st_ctime
|
||||
conversation_folders.append((folder.name, creation_time))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Sort by creation time (most recent first)
|
||||
conversation_folders.sort(key=lambda x: x[1], reverse=True)
|
||||
return conversation_folders
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def display_conversation_page(
|
||||
config: OpenHandsConfig, conversations: list[tuple[str, float]], page_num: int
|
||||
) -> None:
|
||||
"""Display a page of conversations with their details."""
|
||||
print_formatted_text(f'Recent Conversations (Page {page_num}):')
|
||||
print()
|
||||
|
||||
for i, (conversation_id, creation_time) in enumerate(conversations, 1):
|
||||
# Get initial user message
|
||||
initial_message = await get_initial_user_message(config, conversation_id)
|
||||
if initial_message:
|
||||
truncated_message = truncate_message(initial_message, 100)
|
||||
else:
|
||||
truncated_message = '[No user message found]'
|
||||
|
||||
# Format creation time
|
||||
created_time = datetime.fromtimestamp(creation_time).isoformat()
|
||||
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<ansigreen>{i}.</ansigreen> '
|
||||
f'<ansiyellow>[{conversation_id}]</ansiyellow> '
|
||||
f'<ansiwhite>[Created: {created_time}]</ansiwhite>\n'
|
||||
f' Initial message: {truncated_message}\n'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def list_conversations(
|
||||
config: OpenHandsConfig, page_id: str | None = None
|
||||
) -> None:
|
||||
"""List recent conversations with pagination."""
|
||||
try:
|
||||
conversation_store = await FileConversationStore.get_instance(config, None)
|
||||
result = await conversation_store.search(page_id=page_id, limit=10)
|
||||
|
||||
if not result.results:
|
||||
print_formatted_text('No conversations found.')
|
||||
return
|
||||
|
||||
print_formatted_text('\n📋 Recent Conversations:')
|
||||
print_formatted_text('=' * 80)
|
||||
|
||||
for i, conv in enumerate(result.results, 1):
|
||||
# Get initial user message
|
||||
initial_message = await get_initial_user_message(
|
||||
config, conv.conversation_id
|
||||
)
|
||||
truncated_message = truncate_message(initial_message, 100)
|
||||
|
||||
created_at = conv.created_at or 'Unknown'
|
||||
print_formatted_text(
|
||||
f'{i:2d}. [{conv.conversation_id}] [{created_at}] '
|
||||
f'Initial user message: {truncated_message}'
|
||||
)
|
||||
|
||||
print_formatted_text('=' * 80)
|
||||
|
||||
# Handle pagination
|
||||
if result.next_page_id:
|
||||
action = cli_confirm(
|
||||
config,
|
||||
'More conversations available',
|
||||
['Next page', 'Previous page', 'Go back'],
|
||||
)
|
||||
if action == 0: # Next page
|
||||
await list_conversations(config, result.next_page_id)
|
||||
elif action == 1: # Previous page (simplified - just go back to first page)
|
||||
await list_conversations(config, None)
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(f'❌ Error listing conversations: {e}')
|
||||
|
||||
|
||||
async def view_conversation_details(config: OpenHandsConfig) -> None:
|
||||
"""View details of a specific conversation."""
|
||||
conversation_id = await collect_input(config, 'Enter conversation ID:')
|
||||
if not conversation_id:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get all user messages from the conversation
|
||||
user_messages = await get_user_messages_from_conversation(
|
||||
config, conversation_id
|
||||
)
|
||||
|
||||
if not user_messages:
|
||||
print_formatted_text(
|
||||
f'No user messages found in conversation {conversation_id}'
|
||||
)
|
||||
return
|
||||
|
||||
print_formatted_text(f'\n💬 User Messages in Conversation {conversation_id}:')
|
||||
print_formatted_text('=' * 80)
|
||||
|
||||
for i, message in enumerate(user_messages, 1):
|
||||
timestamp = message.get('timestamp', 'Unknown')
|
||||
content = message.get('content', '')
|
||||
# Replace newlines with visual symbols for better display
|
||||
content = content.replace('\n', '↵').replace('\r', '↵')
|
||||
print_formatted_text(f'{i:2d}. [{timestamp}] {content}')
|
||||
|
||||
print_formatted_text('=' * 80)
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(f'❌ Error viewing conversation details: {e}')
|
||||
|
||||
|
||||
async def get_initial_user_message(
|
||||
config: OpenHandsConfig, conversation_id: str
|
||||
) -> str:
|
||||
"""Get the initial user message from a conversation."""
|
||||
try:
|
||||
from openhands.events import EventSource
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.storage import get_file_store
|
||||
|
||||
file_store = get_file_store(
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
)
|
||||
|
||||
event_store = EventStore(
|
||||
sid=conversation_id,
|
||||
file_store=file_store,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
# Search for the first user message
|
||||
for event in event_store.search_events(start_id=0, limit=50):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
return event.content
|
||||
|
||||
return 'No initial message found'
|
||||
except Exception:
|
||||
return 'Error loading message'
|
||||
|
||||
|
||||
async def get_user_messages_from_conversation(
|
||||
config: OpenHandsConfig, conversation_id: str
|
||||
) -> list[dict]:
|
||||
"""Get all user messages from a conversation."""
|
||||
try:
|
||||
from openhands.events import EventSource
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.storage import get_file_store
|
||||
|
||||
file_store = get_file_store(
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
)
|
||||
|
||||
event_store = EventStore(
|
||||
sid=conversation_id,
|
||||
file_store=file_store,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
user_messages = []
|
||||
for event in event_store.search_events(start_id=0):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
user_messages.append(
|
||||
{
|
||||
'timestamp': event.timestamp if event.timestamp else 'Unknown',
|
||||
'content': event.content,
|
||||
}
|
||||
)
|
||||
|
||||
return user_messages
|
||||
except Exception as e:
|
||||
print_formatted_text(f'Error loading messages: {e}')
|
||||
return []
|
||||
|
||||
|
||||
def truncate_message(message: str, max_length: int) -> str:
|
||||
"""Truncate a message to a maximum length and replace newlines with visual symbols."""
|
||||
# Replace actual newlines with visual newline symbols
|
||||
message = message.replace('\n', '↵').replace('\r', '↵')
|
||||
|
||||
if len(message) <= max_length:
|
||||
return message
|
||||
return message[:max_length] + '...'
|
||||
|
||||
@@ -22,6 +22,7 @@ from openhands.cli.shell_config import (
|
||||
aliases_exist_in_shell_config,
|
||||
mark_alias_setup_declined,
|
||||
)
|
||||
from openhands.cli.spinner import display_initialization_animation
|
||||
from openhands.cli.tui import (
|
||||
UsageMetrics,
|
||||
cli_confirm,
|
||||
@@ -29,7 +30,6 @@ from openhands.cli.tui import (
|
||||
display_banner,
|
||||
display_event,
|
||||
display_initial_user_prompt,
|
||||
display_initialization_animation,
|
||||
display_runtime_initialization_message,
|
||||
display_welcome_message,
|
||||
read_confirmation_input,
|
||||
|
||||
104
openhands/cli/spinner.py
Normal file
104
openhands/cli/spinner.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Unified spinner utility for CLI operations."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class Spinner:
|
||||
"""A unified spinner utility that works with both threading and asyncio events."""
|
||||
|
||||
FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
def __init__(self, text: str = 'Loading...', use_ansi_colors: bool = False):
|
||||
"""Initialize spinner with text and optional ANSI color formatting.
|
||||
|
||||
Args:
|
||||
text: Text to display alongside the spinner
|
||||
use_ansi_colors: Whether to use ANSI color codes for formatting
|
||||
"""
|
||||
self.text = text
|
||||
self.use_ansi_colors = use_ansi_colors
|
||||
self._frame_index = 0
|
||||
|
||||
def _get_formatted_text(self) -> str:
|
||||
"""Get the formatted spinner text."""
|
||||
frame = self.FRAMES[self._frame_index % len(self.FRAMES)]
|
||||
if self.use_ansi_colors:
|
||||
return f'\033[s\033[J\033[38;2;255;215;0m[{frame}] {self.text}\033[0m\033[u\033[1A'
|
||||
else:
|
||||
return f'\r{frame} {self.text}'
|
||||
|
||||
def _clear_line(self) -> None:
|
||||
"""Clear the spinner line."""
|
||||
if self.use_ansi_colors:
|
||||
sys.stdout.write('\r' + ' ' * (len(self.text) + 10) + '\r')
|
||||
else:
|
||||
print('\r' + ' ' * (len(self.text) + 10) + '\r', end='', flush=True)
|
||||
|
||||
def show_with_threading_event(self, stop_event: threading.Event) -> None:
|
||||
"""Show spinner using a threading.Event for control.
|
||||
|
||||
Args:
|
||||
stop_event: Threading event to signal when to stop the spinner
|
||||
"""
|
||||
self._frame_index = 0
|
||||
while not stop_event.is_set():
|
||||
if self.use_ansi_colors:
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(self._get_formatted_text())
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
print(self._get_formatted_text(), end='', flush=True)
|
||||
|
||||
time.sleep(0.1)
|
||||
self._frame_index += 1
|
||||
|
||||
self._clear_line()
|
||||
sys.stdout.flush()
|
||||
|
||||
def show_with_asyncio_event(self, is_loaded: asyncio.Event) -> None:
|
||||
"""Show spinner using an asyncio.Event for control.
|
||||
|
||||
Args:
|
||||
is_loaded: Asyncio event to signal when to stop the spinner
|
||||
"""
|
||||
self._frame_index = 0
|
||||
while not is_loaded.is_set():
|
||||
if self.use_ansi_colors:
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(self._get_formatted_text())
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
print(self._get_formatted_text(), end='', flush=True)
|
||||
|
||||
time.sleep(0.1)
|
||||
self._frame_index += 1
|
||||
|
||||
self._clear_line()
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def show_loading_spinner(
|
||||
stop_event: threading.Event, text: str = 'Loading conversations...'
|
||||
) -> None:
|
||||
"""Show a loading spinner animation using threading.Event.
|
||||
|
||||
Args:
|
||||
stop_event: Threading event to signal when to stop the spinner
|
||||
text: Text to display alongside the spinner
|
||||
"""
|
||||
spinner = Spinner(text, use_ansi_colors=False)
|
||||
spinner.show_with_threading_event(stop_event)
|
||||
|
||||
|
||||
def display_initialization_animation(text: str, is_loaded: asyncio.Event) -> None:
|
||||
"""Display initialization animation using asyncio.Event.
|
||||
|
||||
Args:
|
||||
text: Text to display alongside the spinner
|
||||
is_loaded: Asyncio event to signal when to stop the spinner
|
||||
"""
|
||||
spinner = Spinner(text, use_ansi_colors=True)
|
||||
spinner.show_with_asyncio_event(is_loaded)
|
||||
@@ -84,6 +84,7 @@ COMMANDS = {
|
||||
'/settings': 'Display and modify current settings',
|
||||
'/resume': 'Resume the agent when paused',
|
||||
'/mcp': 'Manage MCP server configuration and view errors',
|
||||
'/conversations': 'View conversation history and user messages',
|
||||
}
|
||||
|
||||
print_lock = threading.Lock()
|
||||
@@ -129,23 +130,6 @@ def display_runtime_initialization_message(runtime: str) -> None:
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_initialization_animation(text: str, is_loaded: asyncio.Event) -> None:
|
||||
ANIMATION_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
i = 0
|
||||
while not is_loaded.is_set():
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(
|
||||
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def display_banner(session_id: str) -> None:
|
||||
print_formatted_text(
|
||||
HTML(r"""<gold>
|
||||
|
||||
@@ -4,8 +4,12 @@ import pytest
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands.cli.commands import (
|
||||
display_conversation_page,
|
||||
display_mcp_servers,
|
||||
get_conversation_folders_by_time,
|
||||
get_initial_user_message,
|
||||
handle_commands,
|
||||
handle_conv_command,
|
||||
handle_exit_command,
|
||||
handle_help_command,
|
||||
handle_init_command,
|
||||
@@ -14,6 +18,8 @@ from openhands.cli.commands import (
|
||||
handle_resume_command,
|
||||
handle_settings_command,
|
||||
handle_status_command,
|
||||
truncate_message,
|
||||
view_conversation_details,
|
||||
)
|
||||
from openhands.cli.tui import UsageMetrics
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
@@ -160,6 +166,20 @@ class TestHandleCommands:
|
||||
assert reload_microagents is False
|
||||
assert new_session is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.commands.handle_conv_command')
|
||||
async def test_handle_conversations_command(
|
||||
self, mock_handle_conv, mock_dependencies
|
||||
):
|
||||
close_repl, reload_microagents, new_session, _ = await handle_commands(
|
||||
'/conversations', **mock_dependencies
|
||||
)
|
||||
|
||||
mock_handle_conv.assert_called_once_with(mock_dependencies['config'])
|
||||
assert close_repl is False
|
||||
assert reload_microagents is False
|
||||
assert new_session is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_unknown_command(self, mock_dependencies):
|
||||
user_message = 'Hello, this is not a command'
|
||||
@@ -635,3 +655,165 @@ class TestMCPErrorHandling:
|
||||
handle_mcp_errors_command()
|
||||
|
||||
mock_display_errors.assert_called_once()
|
||||
|
||||
|
||||
class TestConversationCommands:
|
||||
"""Test conversation history commands."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.commands.get_conversation_folders_by_time')
|
||||
@patch('openhands.cli.commands.print_formatted_text')
|
||||
async def test_handle_conv_command_no_conversations(
|
||||
self, mock_print, mock_get_folders
|
||||
):
|
||||
"""Test handle_conv_command with no conversations found."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
mock_get_folders.return_value = []
|
||||
|
||||
await handle_conv_command(config)
|
||||
|
||||
mock_get_folders.assert_called_once_with(config)
|
||||
# Check that the function was called with the expected message
|
||||
calls = [call.args[0] for call in mock_print.call_args_list]
|
||||
assert any('No conversations found' in str(call) for call in calls)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.commands.get_conversation_folders_by_time')
|
||||
@patch('openhands.cli.commands.display_conversation_page')
|
||||
@patch('openhands.cli.commands.cli_confirm')
|
||||
@patch('openhands.cli.commands.print_formatted_text')
|
||||
async def test_handle_conv_command_with_conversations(
|
||||
self, mock_print, mock_cli_confirm, mock_display_page, mock_get_folders
|
||||
):
|
||||
"""Test handle_conv_command with conversations and go back action."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
mock_get_folders.return_value = [
|
||||
('conv-1', 1640995200.0),
|
||||
('conv-2', 1640995100.0),
|
||||
]
|
||||
mock_cli_confirm.return_value = 1 # Go back (assuming it's the last option)
|
||||
|
||||
await handle_conv_command(config)
|
||||
|
||||
mock_get_folders.assert_called_once_with(config)
|
||||
mock_display_page.assert_called_once()
|
||||
mock_cli_confirm.assert_called_once()
|
||||
|
||||
def test_get_conversation_folders_by_time_success(self):
|
||||
"""Test get_conversation_folders_by_time with successful folder retrieval."""
|
||||
# This is more of an integration test - we'll just test that the function
|
||||
# handles the case where no sessions directory exists gracefully
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
config.file_store = 'local'
|
||||
config.file_store_path = '/nonexistent/path'
|
||||
|
||||
result = get_conversation_folders_by_time(config)
|
||||
|
||||
# Since the path doesn't exist, it should return an empty list
|
||||
assert result == []
|
||||
|
||||
@patch('openhands.storage.get_file_store')
|
||||
def test_get_conversation_folders_by_time_no_sessions(self, mock_get_file_store):
|
||||
"""Test get_conversation_folders_by_time with no sessions directory."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
mock_get_file_store.side_effect = Exception('No sessions')
|
||||
|
||||
result = get_conversation_folders_by_time(config)
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.commands.get_initial_user_message')
|
||||
@patch('openhands.cli.commands.print_formatted_text')
|
||||
async def test_display_conversation_page(self, mock_print, mock_get_message):
|
||||
"""Test display_conversation_page with conversation data."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
conversations = [('conv-1', 1640995200.0), ('conv-2', 1640995100.0)]
|
||||
mock_get_message.side_effect = ['Hello world', 'Fix this bug']
|
||||
|
||||
await display_conversation_page(config, conversations, 1)
|
||||
|
||||
assert mock_get_message.call_count == 2
|
||||
assert mock_print.call_count >= 3 # Header + 2 conversations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.commands.collect_input')
|
||||
@patch('openhands.cli.commands.get_user_messages_from_conversation')
|
||||
@patch('openhands.cli.commands.print_formatted_text')
|
||||
async def test_view_conversation_details_success(
|
||||
self, mock_print, mock_get_messages, mock_collect_input
|
||||
):
|
||||
"""Test view_conversation_details with successful data retrieval."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
mock_collect_input.return_value = 'conv-123'
|
||||
mock_get_messages.return_value = [
|
||||
('2023-01-01T10:00:00Z', 'Hello'),
|
||||
('2023-01-01T10:05:00Z', 'How are you?'),
|
||||
]
|
||||
|
||||
await view_conversation_details(config)
|
||||
|
||||
mock_collect_input.assert_called_once_with(config, 'Enter conversation ID:')
|
||||
mock_get_messages.assert_called_once_with(config, 'conv-123')
|
||||
assert mock_print.call_count >= 3 # Header + 2 messages
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.commands.collect_input')
|
||||
async def test_view_conversation_details_cancelled(self, mock_collect_input):
|
||||
"""Test view_conversation_details when user cancels input."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
mock_collect_input.return_value = None # User cancelled
|
||||
|
||||
await view_conversation_details(config)
|
||||
|
||||
mock_collect_input.assert_called_once_with(config, 'Enter conversation ID:')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_initial_user_message_success(self):
|
||||
"""Test get_initial_user_message with successful message retrieval."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
config.file_store = 'local'
|
||||
config.file_store_path = '/tmp/test'
|
||||
conversation_id = 'conv-123'
|
||||
|
||||
result = await get_initial_user_message(config, conversation_id)
|
||||
|
||||
# Since we don't have actual conversation data, it should return the error message
|
||||
assert result in ['No initial message found', 'Error loading message']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.storage.get_file_store')
|
||||
async def test_get_initial_user_message_error(self, mock_get_file_store):
|
||||
"""Test get_initial_user_message with error handling."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
conversation_id = 'conv-123'
|
||||
|
||||
mock_get_file_store.side_effect = Exception('File not found')
|
||||
|
||||
result = await get_initial_user_message(config, conversation_id)
|
||||
|
||||
assert result == 'Error loading message'
|
||||
|
||||
def test_truncate_message_short(self):
|
||||
"""Test truncate_message with short message."""
|
||||
message = 'Hello world'
|
||||
result = truncate_message(message, 100)
|
||||
assert result == 'Hello world'
|
||||
|
||||
def test_truncate_message_long(self):
|
||||
"""Test truncate_message with long message."""
|
||||
message = 'This is a very long message that should be truncated'
|
||||
result = truncate_message(message, 20)
|
||||
assert result == 'This is a very long ...'
|
||||
assert len(result) == 23 # 20 + '...'
|
||||
|
||||
def test_truncate_message_with_newlines(self):
|
||||
"""Test truncate_message with newlines."""
|
||||
message = 'Hello\nworld\rtest'
|
||||
result = truncate_message(message, 100)
|
||||
assert result == 'Hello↵world↵test'
|
||||
|
||||
# Test with truncation and newlines
|
||||
message = 'Hello\nworld\nthis is a long message'
|
||||
result = truncate_message(message, 15)
|
||||
assert result == 'Hello↵world↵thi...'
|
||||
|
||||
Reference in New Issue
Block a user