Compare commits

..

9 Commits

Author SHA1 Message Date
openhands
9c1484ecf3 Add custom help messages for team CLI subcommands 2025-06-06 02:47:13 +00:00
openhands
33c80d816e Simplify argument handling for team CLI 2025-06-06 02:46:29 +00:00
openhands
c164063d19 Fix help message handling for team CLI subcommands 2025-06-06 02:42:02 +00:00
openhands
30f09f4aec Directly import and run team CLI module 2025-06-06 02:40:46 +00:00
openhands
cb87340cb8 Fix subcommand handling in team CLI 2025-06-06 02:39:24 +00:00
openhands
15886acc3f Change --team flag to team subcommand for better argument handling 2025-06-06 02:28:00 +00:00
openhands
c64eef19df Update default URL to staging.all-hands.dev 2025-06-06 02:20:26 +00:00
openhands
be0bb3f388 Simplify team CLI interface and fix argument handling 2025-06-06 02:19:08 +00:00
openhands
c020268f5b Add team CLI interface for OpenHands API 2025-06-06 02:10:17 +00:00
50 changed files with 1282 additions and 868 deletions

0
.devcontainer/setup.sh Executable file → Normal file
View File

View File

@@ -109,14 +109,6 @@ OpenHands requires an API key to access most language models. Here's how to get
</Accordion>
<Accordion title="Google (Gemini)">
1. Create a Google account if you don't already have one.
2. [Generate an API key](https://aistudio.google.com/apikey).
3. [Set up billing](https://aistudio.google.com/usage?tab=billing).
</Accordion>
</AccordionGroup>
Consider setting usage limits to control costs.

107
docs/usage/team-cli.mdx Normal file
View File

@@ -0,0 +1,107 @@
---
title: Team CLI
---
# OpenHands Team CLI
The Team CLI provides a command-line interface for interacting with the OpenHands HTTP and WebSocket APIs. It allows you to create conversations, list existing conversations, and join conversations to interact with the agent.
## Getting Started
To use the Team CLI, you need to have OpenHands installed. You can then use the `team` command to access the Team CLI:
```bash
openhands team [command] [options]
```
## Configuration
The Team CLI uses the following environment variables for configuration:
- `OPENHANDS_API_URL`: The base URL for the OpenHands API (default: `https://staging.all-hands.dev`)
- `OPENHANDS_API_KEY`: The API key for authentication (if required)
You can also specify these values using command-line options:
```bash
openhands team --url https://app.all-hands.dev --api-key your-api-key [command] [options]
```
## Commands
### List Conversations
List all available conversations:
```bash
openhands team list [options]
```
Options:
- `-l, --limit`: Maximum number of conversations to list (default: 20)
### Create a Conversation
Create a new conversation:
```bash
openhands team create [options]
```
Options:
- `-r, --repository`: Repository name (format: owner/repo)
- `-g, --git-provider`: Git provider (github or gitlab)
- `-b, --branch`: Branch name
- `-m, --message`: Initial user message
- `-i, --instructions`: Conversation instructions
- `-j, --join`: Join the conversation after creation
### Join a Conversation
Join an existing conversation:
```bash
openhands team join [conversation_id]
```
## Examples
List all conversations:
```bash
openhands team list
```
Create a new conversation with a GitHub repository:
```bash
openhands team create -r All-Hands-AI/OpenHands -m "Help me understand the codebase"
```
Create a conversation and join it immediately:
```bash
openhands team create -m "Let's build a web app" -j
```
Join an existing conversation:
```bash
openhands team join abc123def456
```
## Using with a Remote Server
To use the Team CLI with a remote OpenHands server:
```bash
export OPENHANDS_API_URL="https://app.all-hands.dev"
export OPENHANDS_API_KEY="your-api-key"
openhands team list
```
Or specify the URL and API key directly:
```bash
openhands team --url https://app.all-hands.dev --api-key your-api-key list
```

View File

@@ -6,21 +6,6 @@ import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as useSettingsModule from "#/hooks/query/use-settings";
// Mock the useSettings hook
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
return {
...actual,
useSettings: vi.fn().mockReturnValue({
data: {
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
},
isLoading: false,
}),
};
});
// Mock the i18next hook
vi.mock("react-i18next", async () => {
@@ -35,7 +20,6 @@ vi.mock("react-i18next", async () => {
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$NAV_USER": "User",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
@@ -63,10 +47,6 @@ describe("Settings Billing", () => {
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
],
},
]);

View File

@@ -1,60 +1,5 @@
import axios, { AxiosError, AxiosResponse } from "axios";
import axios from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
// Helper function to check if a response contains an email verification error
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const checkForEmailVerificationError = (data: any): boolean => {
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
if (typeof data === "string") {
return data.includes(EMAIL_NOT_VERIFIED);
}
if (typeof data === "object" && data !== null) {
if ("message" in data) {
const { message } = data;
if (typeof message === "string") {
return message.includes(EMAIL_NOT_VERIFIED);
}
if (Array.isArray(message)) {
return message.some(
(msg) => typeof msg === "string" && msg.includes(EMAIL_NOT_VERIFIED),
);
}
}
// Search any values in object in case message key is different
return Object.values(data).some(
(value) =>
(typeof value === "string" && value.includes(EMAIL_NOT_VERIFIED)) ||
(Array.isArray(value) &&
value.some(
(v) => typeof v === "string" && v.includes(EMAIL_NOT_VERIFIED),
)),
);
}
return false;
};
// Set up the global interceptor
openHands.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
// Check if it's a 403 error with the email verification message
if (
error.response?.status === 403 &&
checkForEmailVerificationError(error.response?.data)
) {
if (window.location.pathname !== "/settings/user") {
window.location.reload();
}
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);

View File

@@ -1,32 +0,0 @@
import React from "react";
import { useLocation, useNavigate } from "react-router";
import { useSettings } from "#/hooks/query/use-settings";
/**
* A component that restricts access to routes based on email verification status.
* If EMAIL_VERIFIED is false, only allows access to the /settings/user page.
*/
export function EmailVerificationGuard({
children,
}: {
children: React.ReactNode;
}) {
const { data: settings, isLoading } = useSettings();
const navigate = useNavigate();
const { pathname } = useLocation();
React.useEffect(() => {
// If settings are still loading, don't do anything yet
if (isLoading) return;
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
if (settings?.EMAIL_VERIFIED === false) {
// Allow access to /settings/user but redirect from any other page
if (pathname !== "/settings/user") {
navigate("/settings/user", { replace: true });
}
}
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
return children;
}

View File

@@ -69,21 +69,16 @@ export function Sidebar() {
<div className="flex items-center justify-center">
<AllHandsLogoButton />
</div>
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
<NewProjectButton />
<ConversationPanelButton
isOpen={conversationPanelIsOpen}
onClick={() =>
settings?.EMAIL_VERIFIED === false
? null
: setConversationPanelIsOpen((prev) => !prev)
}
disabled={settings?.EMAIL_VERIFIED === false}
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
/>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<DocsButton disabled={settings?.EMAIL_VERIFIED === false} />
<SettingsButton disabled={settings?.EMAIL_VERIFIED === false} />
<DocsButton />
<SettingsButton />
<UserActions
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined

View File

@@ -8,13 +8,11 @@ import { cn } from "#/utils/utils";
interface ConversationPanelButtonProps {
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}
export function ConversationPanelButton({
isOpen,
onClick,
disabled = false,
}: ConversationPanelButtonProps) {
const { t } = useTranslation();
@@ -24,14 +22,10 @@ export function ConversationPanelButton({
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
onClick={onClick}
disabled={disabled}
>
<FaListUl
size={22}
className={cn(
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
/>
</TooltipButton>
);

View File

@@ -3,24 +3,15 @@ import DocsIcon from "#/icons/academy.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
interface DocsButtonProps {
disabled?: boolean;
}
export function DocsButton({ disabled = false }: DocsButtonProps) {
export function DocsButton() {
const { t } = useTranslation();
return (
<TooltipButton
tooltip={t(I18nKey.SIDEBAR$DOCS)}
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
href="https://docs.all-hands.dev"
disabled={disabled}
>
<DocsIcon
width={28}
height={28}
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
/>
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
</TooltipButton>
);
}

View File

@@ -3,11 +3,7 @@ import { I18nKey } from "#/i18n/declaration";
import PlusIcon from "#/icons/plus.svg?react";
import { TooltipButton } from "./tooltip-button";
interface NewProjectButtonProps {
disabled?: boolean;
}
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
export function NewProjectButton() {
const { t } = useTranslation();
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
return (
@@ -16,7 +12,6 @@ export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
ariaLabel={startNewProject}
navLinkTo="/"
testId="new-project-button"
disabled={disabled}
>
<PlusIcon width={28} height={28} />
</TooltipButton>

View File

@@ -5,13 +5,9 @@ import { I18nKey } from "#/i18n/declaration";
interface SettingsButtonProps {
onClick?: () => void;
disabled?: boolean;
}
export function SettingsButton({
onClick,
disabled = false,
}: SettingsButtonProps) {
export function SettingsButton({ onClick }: SettingsButtonProps) {
const { t } = useTranslation();
return (
@@ -21,7 +17,6 @@ export function SettingsButton({
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo="/settings"
disabled={disabled}
>
<SettingsIcon width={28} height={28} />
</TooltipButton>

View File

@@ -12,7 +12,6 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
disabled?: boolean;
}
export function TooltipButton({
@@ -24,10 +23,9 @@ export function TooltipButton({
ariaLabel,
testId,
className,
disabled = false,
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick && !disabled) {
if (onClick) {
onClick();
e.preventDefault();
}
@@ -39,12 +37,7 @@ export function TooltipButton({
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
disabled={disabled}
className={cn("hover:opacity-80", className)}
>
{children}
</button>
@@ -52,7 +45,7 @@ export function TooltipButton({
let content;
if (navLinkTo && !disabled) {
if (navLinkTo) {
content = (
<NavLink
to={navLinkTo}
@@ -70,24 +63,7 @@ export function TooltipButton({
{children}
</NavLink>
);
} else if (navLinkTo && disabled) {
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn(
"text-[#9099AC]",
"opacity-50 cursor-not-allowed",
className,
)}
disabled
>
{children}
</button>
);
} else if (href && !disabled) {
} else if (href) {
content = (
<a
href={href}
@@ -100,19 +76,6 @@ export function TooltipButton({
{children}
</a>
);
} else if (href && disabled) {
// If disabled and has href, render a button that looks like a link but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn("opacity-50 cursor-not-allowed", className)}
disabled
>
{children}
</button>
);
} else {
content = buttonContent;
}

View File

@@ -17,10 +17,6 @@ export const useActiveConversation = () => {
useEffect(() => {
const conversation = userConversation.data;
OpenHands.setCurrentConversation(conversation || null);
}, [
conversationId,
userConversation.isFetched,
userConversation?.data?.status,
]);
}, [conversationId, userConversation.isFetched]);
return userConversation;
};

View File

@@ -27,8 +27,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
apiSettings.enable_proactive_conversation_starters,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
SEARCH_API_KEY: apiSettings.search_api_key || "",
EMAIL: apiSettings.email || "",
EMAIL_VERIFIED: apiSettings.email_verified,
MCP_CONFIG: apiSettings.mcp_config,
IS_NEW_USER: false,
};

View File

@@ -556,18 +556,4 @@ export enum I18nKey {
TIPS$PROTIP = "TIPS$PROTIP",
FEEDBACK$SUBMITTING_LABEL = "FEEDBACK$SUBMITTING_LABEL",
FEEDBACK$SUBMITTING_MESSAGE = "FEEDBACK$SUBMITTING_MESSAGE",
SETTINGS$NAV_USER = "SETTINGS$NAV_USER",
SETTINGS$USER_TITLE = "SETTINGS$USER_TITLE",
SETTINGS$USER_EMAIL = "SETTINGS$USER_EMAIL",
SETTINGS$USER_EMAIL_LOADING = "SETTINGS$USER_EMAIL_LOADING",
SETTINGS$SAVE = "SETTINGS$SAVE",
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY = "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY",
SETTINGS$FAILED_TO_SAVE_EMAIL = "SETTINGS$FAILED_TO_SAVE_EMAIL",
SETTINGS$SENDING = "SETTINGS$SENDING",
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
}

View File

@@ -8894,245 +8894,5 @@
"tr": "Geri bildirim gönderiliyor, lütfen bekleyin...",
"de": "Feedback senden, bitte warten...",
"uk": "Відправляємо відгук, будь ласка, почекайте..."
},
"SETTINGS$NAV_USER": {
"en": "User",
"ja": "ユーザー",
"zh-CN": "用户",
"zh-TW": "用戶",
"ko-KR": "사용자",
"no": "Bruker",
"it": "Utente",
"pt": "Usuário",
"es": "Usuario",
"ar": "المستخدم",
"fr": "Utilisateur",
"tr": "Kullanıcı",
"de": "Benutzer",
"uk": "Користувач"
},
"SETTINGS$USER_TITLE": {
"en": "User Information",
"ja": "ユーザー情報",
"zh-CN": "用户信息",
"zh-TW": "用戶信息",
"ko-KR": "사용자 정보",
"no": "Brukerinformasjon",
"it": "Informazioni utente",
"pt": "Informações do usuário",
"es": "Información del usuario",
"ar": "معلومات المستخدم",
"fr": "Informations utilisateur",
"tr": "Kullanıcı Bilgileri",
"de": "Benutzerinformationen",
"uk": "Інформація про користувача"
},
"SETTINGS$USER_EMAIL": {
"en": "Email",
"ja": "メール",
"zh-CN": "邮箱",
"zh-TW": "郵箱",
"ko-KR": "이메일",
"no": "E-post",
"it": "Email",
"pt": "Email",
"es": "Correo electrónico",
"ar": "البريد الإلكتروني",
"fr": "Email",
"tr": "E-posta",
"de": "E-Mail",
"uk": "Електронна пошта"
},
"SETTINGS$USER_EMAIL_LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
"zh-CN": "加载中...",
"zh-TW": "加載中...",
"ko-KR": "로딩 중...",
"no": "Laster...",
"it": "Caricamento...",
"pt": "Carregando...",
"es": "Cargando...",
"ar": "جار التحميل...",
"fr": "Chargement...",
"tr": "Yükleniyor...",
"de": "Wird geladen...",
"uk": "Завантаження..."
},
"SETTINGS$SAVE": {
"en": "Save",
"ja": "保存",
"zh-CN": "保存",
"zh-TW": "儲存",
"ko-KR": "저장",
"no": "Lagre",
"it": "Salva",
"pt": "Salvar",
"es": "Guardar",
"ar": "حفظ",
"fr": "Enregistrer",
"tr": "Kaydet",
"de": "Speichern",
"uk": "Зберегти"
},
"SETTINGS$EMAIL_SAVED_SUCCESSFULLY": {
"en": "Email saved successfully",
"ja": "メールが正常に保存されました",
"zh-CN": "邮箱保存成功",
"zh-TW": "郵箱儲存成功",
"ko-KR": "이메일이 성공적으로 저장되었습니다",
"no": "E-post lagret",
"it": "Email salvata con successo",
"pt": "Email salvo com sucesso",
"es": "Correo electrónico guardado con éxito",
"ar": "تم حفظ البريد الإلكتروني بنجاح",
"fr": "Email enregistré avec succès",
"tr": "E-posta başarıyla kaydedildi",
"de": "E-Mail erfolgreich gespeichert",
"uk": "Електронну пошту успішно збережено"
},
"SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": {
"en": "Your email has been verified successfully!",
"ja": "メールアドレスの確認が完了しました!",
"zh-CN": "您的邮箱已成功验证!",
"zh-TW": "您的郵箱已成功驗證!",
"ko-KR": "이메일이 성공적으로 인증되었습니다!",
"no": "E-posten din er bekreftet!",
"it": "La tua email è stata verificata con successo!",
"pt": "Seu email foi verificado com sucesso!",
"es": "¡Tu correo electrónico ha sido verificado con éxito!",
"ar": "تم التحقق من بريدك الإلكتروني بنجاح!",
"fr": "Votre email a été vérifié avec succès !",
"tr": "E-postanız başarıyla doğrulandı!",
"de": "Ihre E-Mail wurde erfolgreich verifiziert!",
"uk": "Вашу електронну пошту успішно підтверджено!"
},
"SETTINGS$FAILED_TO_SAVE_EMAIL": {
"en": "Failed to save email",
"ja": "メールの保存に失敗しました",
"zh-CN": "保存邮箱失败",
"zh-TW": "儲存郵箱失敗",
"ko-KR": "이메일 저장 실패",
"no": "Kunne ikke lagre e-post",
"it": "Impossibile salvare l'email",
"pt": "Falha ao salvar email",
"es": "Error al guardar el correo electrónico",
"ar": "فشل في حفظ البريد الإلكتروني",
"fr": "Échec de l'enregistrement de l'email",
"tr": "E-posta kaydedilemedi",
"de": "E-Mail konnte nicht gespeichert werden",
"uk": "Не вдалося зберегти електронну пошту"
},
"SETTINGS$SENDING": {
"en": "Sending",
"ja": "送信中",
"zh-CN": "发送中",
"zh-TW": "發送中",
"ko-KR": "전송 중",
"no": "Sender",
"it": "Invio in corso",
"pt": "Enviando",
"es": "Enviando",
"ar": "جاري الإرسال",
"fr": "Envoi en cours",
"tr": "Gönderiliyor",
"de": "Wird gesendet",
"uk": "Надсилання"
},
"SETTINGS$VERIFICATION_EMAIL_SENT": {
"en": "Verification email sent",
"ja": "確認メールを送信しました",
"zh-CN": "验证邮件已发送",
"zh-TW": "驗證郵件已發送",
"ko-KR": "인증 이메일이 전송되었습니다",
"no": "Bekreftelsese-post sendt",
"it": "Email di verifica inviata",
"pt": "Email de verificação enviado",
"es": "Correo de verificación enviado",
"ar": "تم إرسال بريد التحقق",
"fr": "Email de vérification envoyé",
"tr": "Doğrulama e-postası gönderildi",
"de": "Bestätigungs-E-Mail gesendet",
"uk": "Лист підтвердження надіслано"
},
"SETTINGS$EMAIL_VERIFICATION_REQUIRED": {
"en": "You must verify your email address before using All Hands",
"ja": "All Handsを使用する前にメールアドレスを確認する必要があります",
"zh-CN": "使用All Hands前您必须验证您的电子邮件地址",
"zh-TW": "使用All Hands前您必須驗證您的電子郵件地址",
"ko-KR": "All Hands를 사용하기 전에 이메일 주소를 확인해야 합니다",
"no": "Du må bekrefte e-postadressen din før du bruker All Hands",
"it": "Devi verificare il tuo indirizzo email prima di utilizzare All Hands",
"pt": "Você deve verificar seu endereço de e-mail antes de usar o All Hands",
"es": "Debe verificar su dirección de correo electrónico antes de usar All Hands",
"ar": "يجب عليك التحقق من عنوان بريدك الإلكتروني قبل استخدام All Hands",
"fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands",
"tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor",
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
},
"SETTINGS$INVALID_EMAIL_FORMAT": {
"en": "Please enter a valid email address",
"ja": "有効なメールアドレスを入力してください",
"zh-CN": "请输入有效的电子邮件地址",
"zh-TW": "請輸入有效的電子郵件地址",
"ko-KR": "유효한 이메일 주소를 입력하세요",
"no": "Vennligst skriv inn en gyldig e-postadresse",
"it": "Inserisci un indirizzo email valido",
"pt": "Por favor, insira um endereço de e-mail válido",
"es": "Por favor, introduzca una dirección de correo electrónico válida",
"ar": "الرجاء إدخال عنوان بريد إلكتروني صالح",
"fr": "Veuillez entrer une adresse e-mail valide",
"tr": "Lütfen geçerli bir e-posta adresi girin",
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"uk": "Будь ласка, введіть дійсну електронну адресу"
},
"SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": {
"en": "Your access is limited until your email is verified. You can only access this settings page.",
"ja": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",
"zh-CN": "在验证您的电子邮件之前,您的访问权限受到限制。您只能访问此设置页面。",
"zh-TW": "在驗證您的電子郵件之前,您的訪問權限受到限制。您只能訪問此設置頁面。",
"ko-KR": "이메일이 확인될 때까지 액세스가 제한됩니다. 이 설정 페이지만 액세스할 수 있습니다.",
"no": "Din tilgang er begrenset til e-posten din er bekreftet. Du kan bare få tilgang til denne innstillingssiden.",
"it": "Il tuo accesso è limitato fino a quando la tua email non viene verificata. Puoi accedere solo a questa pagina delle impostazioni.",
"pt": "Seu acesso é limitado até que seu e-mail seja verificado. Você só pode acessar esta página de configurações.",
"es": "Su acceso es limitado hasta que se verifique su correo electrónico. Solo puede acceder a esta página de configuración.",
"ar": "وصولك محدود حتى يتم التحقق من بريدك الإلكتروني. يمكنك فقط الوصول إلى صفحة الإعدادات هذه.",
"fr": "Votre accès est limité jusqu'à ce que votre e-mail soit vérifié. Vous ne pouvez accéder qu'à cette page de paramètres.",
"tr": "E-postanız doğrulanana kadar erişiminiz sınırlıdır. Yalnızca bu ayarlar sayfasına erişebilirsiniz.",
"de": "Ihr Zugriff ist eingeschränkt, bis Ihre E-Mail-Adresse bestätigt wurde. Sie können nur auf diese Einstellungsseite zugreifen.",
"uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань."
},
"SETTINGS$RESEND_VERIFICATION": {
"en": "Resend verification",
"ja": "確認メールを再送信",
"zh-CN": "重新发送验证",
"zh-TW": "重新發送驗證",
"ko-KR": "인증 재전송",
"no": "Send bekreftelse på nytt",
"it": "Rinvia verifica",
"pt": "Reenviar verificação",
"es": "Reenviar verificación",
"ar": "إعادة إرسال التحقق",
"fr": "Renvoyer la vérification",
"tr": "Doğrulamayı yeniden gönder",
"de": "Bestätigung erneut senden",
"uk": "Надіслати підтвердження повторно"
},
"SETTINGS$FAILED_TO_RESEND_VERIFICATION": {
"en": "Failed to resend verification email",
"ja": "確認メールの再送信に失敗しました",
"zh-CN": "重新发送验证邮件失败",
"zh-TW": "重新發送驗證郵件失敗",
"ko-KR": "인증 이메일 재전송 실패",
"no": "Kunne ikke sende bekreftelsese-post på nytt",
"it": "Impossibile rinviare l'email di verifica",
"pt": "Falha ao reenviar email de verificação",
"es": "Error al reenviar el correo de verificación",
"ar": "فشل في إعادة إرسال بريد التحقق",
"fr": "Échec du renvoi de l'email de vérification",
"tr": "Doğrulama e-postası yeniden gönderilemedi",
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
"uk": "Не вдалося повторно надіслати лист підтвердження"
}
}

View File

@@ -12,7 +12,6 @@ export default [
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("mcp", "routes/mcp-settings.tsx"),
route("user", "routes/user-settings.tsx"),
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),

View File

@@ -25,7 +25,6 @@ import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
export function ErrorBoundary() {
const error = useRouteError();
@@ -205,9 +204,7 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
<Outlet />
</div>
{renderAuthModal && (

View File

@@ -15,7 +15,6 @@ function SettingsScreen() {
const isSaas = config?.APP_MODE === "saas";
const saasNavItems = [
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
@@ -34,11 +33,10 @@ function SettingsScreen() {
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/user");
navigate("/settings/git");
}
} else {
const noEnteringPaths = [
"/settings/user",
"/settings/billing",
"/settings/credits",
"/settings/api-keys",

View File

@@ -1,226 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function EmailInputSection({
email,
onEmailChange,
onSaveEmail,
onResendVerification,
isSaving,
isResendingVerification,
isEmailChanged,
emailVerified,
isEmailValid,
children,
}: {
email: string;
onEmailChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSaveEmail: () => void;
onResendVerification: () => void;
isSaving: boolean;
isResendingVerification: boolean;
isEmailChanged: boolean;
emailVerified?: boolean;
isEmailValid: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm">{t("SETTINGS$USER_EMAIL")}</label>
<div className="flex items-center gap-3">
<input
type="email"
value={email}
onChange={onEmailChange}
className={`text-base text-white p-2 bg-base-tertiary rounded border ${
isEmailChanged && !isEmailValid
? "border-red-500"
: "border-tertiary"
} flex-grow focus:outline-none focus:border-transparent focus:ring-0`}
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
data-testid="email-input"
/>
</div>
{isEmailChanged && !isEmailValid && (
<div className="text-red-500 text-sm mt-1" data-testid="email-validation-error">
{t("SETTINGS$INVALID_EMAIL_FORMAT")}
</div>
)}
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={onSaveEmail}
disabled={!isEmailChanged || isSaving || !isEmailValid}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="save-email-button"
>
{isSaving ? t("SETTINGS$SAVING") : t("SETTINGS$SAVE")}
</button>
{emailVerified === false && (
<button
type="button"
onClick={onResendVerification}
disabled={isResendingVerification}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="resend-verification-button"
>
{isResendingVerification
? t("SETTINGS$SENDING")
: t("SETTINGS$RESEND_VERIFICATION")}
</button>
)}
</div>
{children}
</div>
</div>
);
}
function VerificationAlert() {
const { t } = useTranslation();
return (
<div
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mt-4"
role="alert"
>
<p className="font-bold">{t("SETTINGS$EMAIL_VERIFICATION_REQUIRED")}</p>
<p className="text-sm">
{t("SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE")}
</p>
</div>
);
}
// These components have been replaced with toast notifications
function UserSettingsScreen() {
const { t } = useTranslation();
const { data: settings, isLoading, refetch } = useSettings();
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [isEmailValid, setIsEmailValid] = useState(true);
const queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
}
}, [settings?.EMAIL]);
useEffect(() => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (
prevVerificationStatusRef.current === false &&
settings?.EMAIL_VERIFIED === true
) {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
}, 2000);
}
prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
if (settings?.EMAIL_VERIFIED === false) {
pollingIntervalRef.current = window.setInterval(() => {
refetch();
}, 5000);
}
return () => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value;
setEmail(newEmail);
setIsEmailValid(EMAIL_REGEX.test(newEmail));
};
const handleSaveEmail = async () => {
if (email === originalEmail || !isEmailValid) return;
try {
setIsSaving(true);
await openHands.post("/api/email", { email }, { withCredentials: true });
setOriginalEmail(email);
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
} finally {
setIsSaving(false);
}
};
const handleResendVerification = async () => {
try {
setIsResendingVerification(true);
await openHands.put("/api/email/verify", {}, { withCredentials: true });
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
} finally {
setIsResendingVerification(false);
}
};
const isEmailChanged = email !== originalEmail;
return (
<div data-testid="user-settings-screen" className="flex flex-col h-full">
<div className="p-9 flex flex-col gap-6">
{isLoading ? (
<div className="animate-pulse h-8 w-64 bg-tertiary rounded" />
) : (
<EmailInputSection
email={email}
onEmailChange={handleEmailChange}
onSaveEmail={handleSaveEmail}
onResendVerification={handleResendVerification}
isSaving={isSaving}
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
emailVerified={settings?.EMAIL_VERIFIED}
isEmailValid={isEmailValid}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
</EmailInputSection>
)}
</div>
</div>
);
}
export default UserSettingsScreen;

View File

@@ -19,8 +19,6 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
EMAIL: "",
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
MCP_CONFIG: {
sse_servers: [],
stdio_servers: [],

View File

@@ -45,8 +45,6 @@ export type Settings = {
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
EMAIL?: string;
EMAIL_VERIFIED?: boolean;
};
export type ApiSettings = {
@@ -70,8 +68,6 @@ export type ApiSettings = {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
};
email?: string;
email_verified?: boolean;
};
export type PostSettings = Settings & {

View File

@@ -1,7 +1,6 @@
import asyncio
import logging
import os
import sys
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
@@ -454,6 +453,37 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
def main():
args = parse_arguments()
# Check if team command is used
if hasattr(args, 'command') and args.command == 'team':
# Import and run the team CLI directly
import sys
from openhands.cli.team import main as team_main
# Get arguments after 'team'
team_args = []
if len(sys.argv) > 2:
# Pass all arguments after 'team'
team_args = sys.argv[2:]
if not team_args:
# If no additional arguments, show help message
print('OpenHands Team CLI')
print('=================')
print('To use the team CLI, run one of the following commands:')
print(' openhands team list - List all conversations')
print(' openhands team create - Create a new conversation')
print(' openhands team join <id> - Join an existing conversation')
print()
print("For more information, run 'openhands team --help'")
return
# Run the team CLI with the arguments
team_main(team_args)
return
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:

549
openhands/cli/team.py Normal file
View File

@@ -0,0 +1,549 @@
"""Team CLI interface for OpenHands.
This module provides a CLI interface for interacting with the OpenHands HTTP and WebSocket APIs.
It allows creating conversations and showing the current list of conversations/statuses.
"""
import argparse
import asyncio
import os
import sys
from datetime import datetime
from typing import Any, Optional
import aiohttp
import socketio
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.shortcuts import clear
from rich.console import Console
from rich.table import Table
from openhands.cli.tui import (
display_banner,
display_event,
display_welcome_message,
read_prompt_input,
)
from openhands.core.schema import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization import event_from_dict, event_to_dict
class TeamClient:
"""Client for interacting with the OpenHands HTTP and WebSocket APIs."""
def __init__(self, base_url: str, api_key: Optional[str] = None):
"""Initialize the TeamClient.
Args:
base_url: The base URL for the OpenHands API.
api_key: Optional API key for authentication.
"""
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.sio = socketio.AsyncClient()
self.console = Console()
self.headers = {}
if api_key:
self.headers['Authorization'] = f'Bearer {api_key}'
async def list_conversations(self, limit: int = 20) -> list[dict[str, Any]]:
"""List conversations.
Args:
limit: Maximum number of conversations to return.
Returns:
List of conversation objects.
"""
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.get(
f'{self.base_url}/api/conversations?limit={limit}'
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to list conversations: {error_text}')
data = await response.json()
return data.get('results', [])
async def create_conversation(
self,
repository: Optional[str] = None,
git_provider: Optional[str] = None,
selected_branch: Optional[str] = None,
initial_user_msg: Optional[str] = None,
conversation_instructions: Optional[str] = None,
) -> str:
"""Create a new conversation.
Args:
repository: Optional repository name (owner/repo).
git_provider: Optional git provider (github or gitlab).
selected_branch: Optional branch name.
initial_user_msg: Optional initial user message.
conversation_instructions: Optional conversation instructions.
Returns:
The conversation ID.
"""
payload = {
'repository': repository,
'git_provider': git_provider,
'selected_branch': selected_branch,
'initial_user_msg': initial_user_msg,
'conversation_instructions': conversation_instructions,
}
# Remove None values
payload = {k: v for k, v in payload.items() if v is not None}
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.post(
f'{self.base_url}/api/conversations', json=payload
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to create conversation: {error_text}')
data = await response.json()
return data.get('conversation_id')
async def get_conversation(self, conversation_id: str) -> dict[str, Any]:
"""Get conversation details.
Args:
conversation_id: The conversation ID.
Returns:
Conversation details.
"""
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.get(
f'{self.base_url}/api/conversations/{conversation_id}'
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to get conversation: {error_text}')
return await response.json()
async def connect_to_conversation(
self, conversation_id: str, latest_event_id: int = -1
) -> None:
"""Connect to a conversation via WebSocket.
Args:
conversation_id: The conversation ID.
latest_event_id: The latest event ID to start from.
"""
# Set up event handlers
@self.sio.event
async def connect():
self.console.print('[green]Connected to conversation[/green]')
@self.sio.event
async def disconnect():
self.console.print('[yellow]Disconnected from conversation[/yellow]')
@self.sio.event
async def oh_event(data):
event = event_from_dict(data)
# Create a dummy config object to satisfy the type checker
from openhands.core.config import OpenHandsConfig
dummy_config = OpenHandsConfig()
display_event(event, dummy_config)
# Connect to the WebSocket
query = {
'conversation_id': conversation_id,
'latest_event_id': str(latest_event_id),
}
if self.api_key:
query['session_api_key'] = self.api_key
await self.sio.connect(
f'{self.base_url}',
headers=self.headers,
transports=['websocket'],
socketio_path='socket.io',
wait_timeout=10,
query=query,
)
async def send_message(self, message: str) -> None:
"""Send a message to the conversation.
Args:
message: The message to send.
"""
event = MessageAction(content=message)
event_dict = event_to_dict(event)
await self.sio.emit('oh_user_action', event_dict)
async def disconnect(self) -> None:
"""Disconnect from the WebSocket."""
await self.sio.disconnect()
async def list_conversations_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""List conversations command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
conversations = await client.list_conversations(limit=args.limit)
if not conversations:
print('No conversations found.')
return
table = Table(title='Conversations')
table.add_column('ID', style='cyan')
table.add_column('Title', style='green')
table.add_column('Status', style='magenta')
table.add_column('Repository', style='blue')
table.add_column('Last Updated', style='yellow')
table.add_column('Created', style='yellow')
for convo in conversations:
# Format dates
created_at = datetime.fromisoformat(convo['created_at'].replace('Z', '+00:00'))
last_updated_at = datetime.fromisoformat(
convo['last_updated_at'].replace('Z', '+00:00')
)
created_str = created_at.strftime('%Y-%m-%d %H:%M:%S')
updated_str = last_updated_at.strftime('%Y-%m-%d %H:%M:%S')
# Add row to table
table.add_row(
convo['conversation_id'],
convo['title'],
convo['status'],
convo.get('selected_repository', ''),
updated_str,
created_str,
)
client.console.print(table)
async def create_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""Create a conversation command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
initial_message = args.message
# If no message provided, prompt for one
if not initial_message:
print_formatted_text(HTML('<green>Enter your initial message:</green>'))
initial_message = input('> ')
try:
conversation_id = await client.create_conversation(
repository=args.repository,
git_provider=args.git_provider,
selected_branch=args.branch,
initial_user_msg=initial_message,
conversation_instructions=args.instructions,
)
print_formatted_text(
HTML(f'<green>Conversation created with ID: {conversation_id}</green>')
)
if args.join:
await join_conversation_cmd(
client, argparse.Namespace(conversation_id=conversation_id)
)
except Exception as e:
print_formatted_text(HTML(f'<red>Error creating conversation: {str(e)}</red>'))
async def join_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""Join a conversation command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
conversation_id = args.conversation_id
try:
# Get conversation details
conversation = await client.get_conversation(conversation_id)
# Clear screen and show banner
clear()
display_banner(session_id=conversation_id)
# Show conversation title
title = conversation.get('title', 'Untitled Conversation')
display_welcome_message(f'Joined conversation: {title}')
# Connect to the WebSocket
await client.connect_to_conversation(conversation_id)
# Main conversation loop
try:
while True:
next_message = await read_prompt_input(
AgentState.AWAITING_USER_INPUT.value
)
if not next_message.strip():
continue
if next_message.lower() in ['exit', 'quit', '/exit', '/quit']:
break
await client.send_message(next_message)
except KeyboardInterrupt:
print('\nDisconnecting...')
finally:
await client.disconnect()
except Exception as e:
print_formatted_text(HTML(f'<red>Error joining conversation: {str(e)}</red>'))
def get_base_url() -> str:
"""Get the base URL for the OpenHands API.
Returns:
The base URL.
"""
# Check environment variables first
base_url = os.environ.get('OPENHANDS_API_URL')
if base_url:
return base_url
# Default to staging server
return 'https://staging.all-hands.dev'
def get_api_key() -> Optional[str]:
"""Get the API key for authentication.
Returns:
The API key, or None if not found.
"""
return os.environ.get('OPENHANDS_API_KEY')
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the team CLI.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(description='OpenHands Team CLI')
parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Server configuration
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
subparsers = parser.add_subparsers(dest='command', help='Command to run')
# List conversations command
list_parser = subparsers.add_parser(
'list',
help='List conversations',
description='List all available conversations',
)
list_parser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
# Add help formatter
list_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Create conversation command
create_parser = subparsers.add_parser(
'create',
help='Create a new conversation',
description='Create a new conversation with optional repository and message',
)
create_parser.add_argument(
'-r', '--repository', help='Repository name (owner/repo)'
)
create_parser.add_argument(
'-g', '--git-provider', help='Git provider (github or gitlab)'
)
create_parser.add_argument('-b', '--branch', help='Branch name')
create_parser.add_argument('-m', '--message', help='Initial user message')
create_parser.add_argument('-i', '--instructions', help='Conversation instructions')
create_parser.add_argument(
'-j', '--join', action='store_true', help='Join the conversation after creation'
)
# Add help formatter
create_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Join conversation command
join_parser = subparsers.add_parser(
'join',
help='Join an existing conversation',
description='Join an existing conversation by ID',
)
join_parser.add_argument('conversation_id', help='Conversation ID')
# Add help formatter
join_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
return parser
async def main_async(args: argparse.Namespace) -> None:
"""Main async function for the team CLI.
Args:
args: Command line arguments.
"""
# Get base URL and API key
base_url = args.url or get_base_url()
api_key = args.api_key or get_api_key()
# Create client
client = TeamClient(base_url, api_key)
# Run command
if args.command == 'list':
await list_conversations_cmd(client, args)
elif args.command == 'create':
await create_conversation_cmd(client, args)
elif args.command == 'join':
await join_conversation_cmd(client, args)
else:
print('No command specified. Use --help for usage information.')
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the team CLI.
Args:
args: Command line arguments.
"""
parser = setup_parser()
# If no arguments provided, show help
if not args or len(args) == 0:
parser.print_help()
return
# Special case for subcommand help
if (
len(args) >= 2
and args[0] in ['list', 'create', 'join']
and args[1] in ['-h', '--help']
):
# Create a new parser just for this subcommand
if args[0] == 'list':
subparser = argparse.ArgumentParser(
description='List all available conversations',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
elif args[0] == 'create':
subparser = argparse.ArgumentParser(
description='Create a new conversation with optional repository and message',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument(
'-r',
'--repository',
help='Repository name (owner/repo)',
)
subparser.add_argument(
'-g',
'--git-provider',
help='Git provider (github or gitlab)',
)
subparser.add_argument('-b', '--branch', help='Branch name')
subparser.add_argument('-m', '--message', help='Initial user message')
subparser.add_argument(
'-i',
'--instructions',
help='Conversation instructions',
)
subparser.add_argument(
'-j',
'--join',
action='store_true',
help='Join the conversation after creation',
)
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
elif args[0] == 'join':
subparser = argparse.ArgumentParser(
description='Join an existing conversation by ID',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument('conversation_id', help='Conversation ID')
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
try:
parsed_args = parser.parse_args(args)
# If no command specified, show help
if not parsed_args.command:
parser.print_help()
return
# Run the command
asyncio.run(main_async(parsed_args))
except KeyboardInterrupt:
print('\nOperation cancelled by user.')
except Exception as e:
print(f'Error: {str(e)}')
sys.exit(1)
if __name__ == '__main__':
main()

7
openhands/cli/team_cli.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
# Get the Python executable
PYTHON_EXE=$(which python)
# Run the team CLI
$PYTHON_EXE -m openhands.cli.team "$@"

View File

@@ -0,0 +1,76 @@
"""Create conversation command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the create command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='Create a new conversation with optional repository and message',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument('-r', '--repository', help='Repository name (owner/repo)')
parser.add_argument('-g', '--git-provider', help='Git provider (github or gitlab)')
parser.add_argument('-b', '--branch', help='Branch name')
parser.add_argument('-m', '--message', help='Initial user message')
parser.add_argument('-i', '--instructions', help='Conversation instructions')
parser.add_argument(
'-j', '--join', action='store_true', help='Join the conversation after creation'
)
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def create_conversation(args: argparse.Namespace) -> None:
"""Create a conversation command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# Create conversation
await client.create_conversation(
repository=args.repository,
git_provider=args.git_provider,
selected_branch=args.branch,
initial_user_msg=args.message,
conversation_instructions=args.instructions,
)
except Exception as e:
print(f'Error creating conversation: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the create command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(create_conversation(parsed_args))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,63 @@
"""Join conversation command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient, join_conversation_cmd
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the join command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='Join an existing conversation by ID',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument('conversation_id', help='Conversation ID')
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def join_conversation(args: argparse.Namespace) -> None:
"""Join a conversation command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# Join conversation
await join_conversation_cmd(client, args)
except Exception as e:
print(f'Error joining conversation: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the join command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(join_conversation(parsed_args))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,69 @@
"""List conversations command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the list command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='List all available conversations',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def list_conversations(args: argparse.Namespace) -> None:
"""List conversations command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# List conversations
await client.list_conversations(limit=args.limit)
except Exception as e:
print(f'Error listing conversations: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the list command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(list_conversations(parsed_args))
if __name__ == '__main__':
main()

View File

@@ -744,13 +744,26 @@ def get_parser() -> argparse.ArgumentParser:
type=bool,
default=False,
)
# Add team subcommand
subparsers = parser.add_subparsers(dest='command')
subparsers.add_parser(
'team', help='Use team mode to interact with the OpenHands API'
)
# We'll handle the team subcommands separately
return parser
def parse_arguments() -> argparse.Namespace:
"""Parse command line arguments."""
parser = get_parser()
args = parser.parse_args()
# Check if 'team' command is present
if len(sys.argv) > 1 and sys.argv[1] == 'team':
# Only parse known arguments, ignoring any team-specific arguments
args, _ = parser.parse_known_args()
else:
# Parse all arguments normally
args = parser.parse_args()
if args.version:
print(f'OpenHands version: {__version__}')

View File

@@ -1,12 +1,13 @@
import asyncio
import datetime
from contextlib import AsyncExitStack
from typing import Optional
from fastmcp import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from mcp import McpError
from mcp.types import CallToolResult
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from pydantic import BaseModel, Field
from openhands.core.config.mcp_config import MCPSHTTPServerConfig, MCPSSEServerConfig
from openhands.core.logger import openhands_logger as logger
from openhands.mcp.tool import MCPClientTool
@@ -16,7 +17,8 @@ class MCPClient(BaseModel):
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
"""
client: Optional[Client] = None
session: Optional[ClientSession] = None
exit_stack: AsyncExitStack = AsyncExitStack()
description: str = 'MCP client tools for server interaction'
tools: list[MCPClientTool] = Field(default_factory=list)
tool_map: dict[str, MCPClientTool] = Field(default_factory=dict)
@@ -24,87 +26,189 @@ class MCPClient(BaseModel):
class Config:
arbitrary_types_allowed = True
async def connect_sse(
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using SSE transport.
Args:
server_url: The URL of the SSE server to connect to.
timeout: Connection timeout in seconds. Default is 30 seconds.
"""
if not server_url:
raise ValueError('Server URL is required.')
if self.session:
await self.disconnect()
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Convert float timeout to datetime.timedelta for consistency
timeout_delta = datetime.timedelta(seconds=timeout)
streams_context = sse_client(
url=server_url,
headers=headers if headers else None,
timeout=timeout,
)
streams = await self.exit_stack.enter_async_context(streams_context)
# For SSE client, we only get read_stream and write_stream (2 values)
read_stream, write_stream = streams
self.session = await self.exit_stack.enter_async_context(
ClientSession(
read_stream, write_stream, read_timeout_seconds=timeout_delta
)
)
await self._initialize_and_list_tools()
# Apply timeout to the entire connection process
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
except asyncio.TimeoutError:
logger.error(
f'Connection to {server_url} timed out after {timeout} seconds'
)
await self.disconnect() # Clean up resources
raise # Re-raise the TimeoutError
except Exception as e:
logger.error(f'Error connecting to {server_url}: {str(e)}')
await self.disconnect() # Clean up resources
raise
async def _initialize_and_list_tools(self) -> None:
"""Initialize session and populate tool map."""
if not self.client:
if not self.session:
raise RuntimeError('Session not initialized.')
async with self.client:
tools = await self.client.list_tools()
await self.session.initialize()
response = await self.session.list_tools()
# Clear existing tools
self.tools = []
# Create proper tool objects for each server tool
for tool in tools:
for tool in response.tools:
server_tool = MCPClientTool(
name=tool.name,
description=tool.description,
inputSchema=tool.inputSchema,
session=self.client,
session=self.session,
)
self.tool_map[tool.name] = server_tool
self.tools.append(server_tool)
logger.info(f'Connected to server with tools: {[tool.name for tool in tools]}')
logger.info(
f'Connected to server with tools: {[tool.name for tool in response.tools]}'
)
async def connect_http(
self,
server: MCPSSEServerConfig | MCPSHTTPServerConfig,
conversation_id: str | None = None,
timeout: float = 30.0,
):
"""Connect to MCP server using SHTTP or SSE transport"""
server_url = server.url
api_key = server.api_key
if not server_url:
raise ValueError('Server URL is required.')
try:
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Instantiate custom transports due to custom headers
if isinstance(server, MCPSHTTPServerConfig):
transport = StreamableHttpTransport(
url=server_url,
headers=headers if headers else None,
)
else:
transport = SSETransport(
url=server_url,
headers=headers if headers else None,
)
self.client = Client(transport, timeout=timeout)
await self._initialize_and_list_tools()
except McpError as e:
logger.error(f'McpError connecting to {server_url}: {e}')
raise # Re-raise the error
except Exception as e:
logger.error(f'Error connecting to {server_url}: {e}')
raise
async def call_tool(self, tool_name: str, args: dict) -> CallToolResult:
async def call_tool(self, tool_name: str, args: dict):
"""Call a tool on the MCP server."""
if tool_name not in self.tool_map:
raise ValueError(f'Tool {tool_name} not found.')
# The MCPClientTool is primarily for metadata; use the session to call the actual tool.
if not self.client:
if not self.session:
raise RuntimeError('Client session is not available.')
return await self.session.call_tool(name=tool_name, arguments=args)
async with self.client:
return await self.client.call_tool_mcp(name=tool_name, arguments=args)
async def connect_shttp(
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using StreamableHTTP transport.
Args:
server_url: The URL of the StreamableHTTP server to connect to.
api_key: Optional API key for authentication.
conversation_id: Optional conversation ID for session tracking.
timeout: Connection timeout in seconds. Default is 30 seconds.
"""
if not server_url:
raise ValueError('Server URL is required.')
if self.session:
await self.disconnect()
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Convert float timeout to datetime.timedelta
timeout_delta = datetime.timedelta(seconds=timeout)
sse_read_timeout_delta = datetime.timedelta(
seconds=timeout * 10
) # 10x longer for read timeout
streams_context = streamablehttp_client(
url=server_url,
headers=headers if headers else None,
timeout=timeout_delta,
sse_read_timeout=sse_read_timeout_delta,
)
streams = await self.exit_stack.enter_async_context(streams_context)
# For StreamableHTTP client, we get read_stream, write_stream, and get_session_id (3 values)
read_stream, write_stream, _ = streams
self.session = await self.exit_stack.enter_async_context(
ClientSession(
read_stream, write_stream, read_timeout_seconds=timeout_delta
)
)
await self._initialize_and_list_tools()
# Apply timeout to the entire connection process
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
except asyncio.TimeoutError:
logger.error(
f'Connection to {server_url} timed out after {timeout} seconds'
)
await self.disconnect() # Clean up resources
raise # Re-raise the TimeoutError
except Exception as e:
logger.error(f'Error connecting to {server_url}: {str(e)}')
await self.disconnect() # Clean up resources
raise
async def disconnect(self) -> None:
"""Disconnect from the MCP server and clean up resources."""
if self.session:
try:
# Close the session first
if hasattr(self.session, 'close'):
await self.session.close()
# Then close the exit stack
await self.exit_stack.aclose()
except Exception as e:
logger.error(f'Error during disconnect: {str(e)}')
finally:
self.session = None
self.tools = []
logger.info('Disconnected from MCP server')

View File

@@ -72,22 +72,38 @@ async def create_mcp_clients(
mcp_clients = []
for server in servers:
is_shttp = isinstance(server, MCPSHTTPServerConfig)
connection_type = 'SHTTP' if is_shttp else 'SSE'
is_sse = isinstance(server, MCPSSEServerConfig)
connection_type = 'SSE' if is_sse else 'SHTTP'
logger.info(
f'Initializing MCP agent for {server} with {connection_type} connection...'
)
client = MCPClient()
try:
await client.connect_http(server, conversation_id=conversation_id)
if is_sse:
await client.connect_sse(
server.url,
api_key=server.api_key,
conversation_id=conversation_id,
)
else:
await client.connect_shttp(
server.url,
api_key=server.api_key,
conversation_id=conversation_id,
)
# Only add the client to the list after a successful connection
mcp_clients.append(client)
except Exception as e:
logger.error(f'Failed to connect to {server}: {str(e)}', exc_info=True)
try:
await client.disconnect()
except Exception as disconnect_error:
logger.error(
f'Error during disconnect after failed connection: {str(disconnect_error)}'
)
return mcp_clients
@@ -127,6 +143,13 @@ async def fetch_mcp_tools_from_config(
# Convert tools to the format expected by the agent
mcp_tools = convert_mcp_clients_to_tools(mcp_clients)
# Always disconnect clients to clean up resources
for mcp_client in mcp_clients:
try:
await mcp_client.disconnect()
except Exception as disconnect_error:
logger.error(f'Error disconnecting MCP client: {str(disconnect_error)}')
except Exception as e:
logger.error(f'Error fetching MCP tools: {str(e)}')
return []

View File

@@ -471,6 +471,11 @@ class ActionExecutionClient(Runtime):
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
result = await call_tool_mcp_handler(mcp_clients, action)
# Reset client state (no active connections to worry about)
for client in mcp_clients:
await client.disconnect()
return result
def close(self) -> None:

View File

@@ -17,7 +17,6 @@ from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
@@ -380,7 +379,9 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
command_output = self._get_command_output(
command,
@@ -413,7 +414,9 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command timed out after {timeout} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
command_output = self._get_command_output(
command,

View File

@@ -1,7 +0,0 @@
# Common timeout message that can be used across different timeout scenarios
TIMEOUT_MESSAGE_TEMPLATE = (
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'send keys to interrupt/kill the command, '
'or use the timeout parameter in execute_bash for future commands.'
)

View File

@@ -20,7 +20,6 @@ from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
pythonnet.load('coreclr')
@@ -560,7 +559,9 @@ class WindowsPowershellSession:
else:
metadata.suffix = (
f'\n[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
return CmdOutputObservation(
@@ -1330,7 +1331,9 @@ class WindowsPowershellSession:
# Align suffix with bash.py timeout message
suffix = (
f'\n[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
elif shutdown_requested:
# Align suffix with bash.py equivalent (though bash.py might not have specific shutdown message)

View File

@@ -15,7 +15,6 @@ from openhands.server.shared import config
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets_store,
get_user_settings,
get_user_settings_store,
)
from openhands.storage.data_models.settings import Settings
@@ -36,9 +35,10 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
async def load_settings(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
settings_store: SettingsStore = Depends(get_user_settings_store),
settings: Settings = Depends(get_user_settings),
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> GETSettingsModel | JSONResponse:
settings = await settings_store.load()
try:
if not settings:
return JSONResponse(

View File

@@ -25,10 +25,6 @@ class DefaultUserAuth(UserAuth):
"""The default implementation does not support multi tenancy, so user_id is always None"""
return None
async def get_user_email(self) -> str | None:
"""The default implementation does not support multi tenancy, so email is always None"""
return None
async def get_access_token(self) -> SecretStr | None:
"""The default implementation does not support multi tenancy, so access_token is always None"""
return None

View File

@@ -38,10 +38,6 @@ class UserAuth(ABC):
async def get_user_id(self) -> str | None:
"""Get the unique identifier for the current user"""
@abstractmethod
async def get_user_email(self) -> str | None:
"""Get the email for the current user"""
@abstractmethod
async def get_access_token(self) -> SecretStr | None:
"""Get the access token for the current user"""

View File

@@ -40,8 +40,6 @@ class Settings(BaseModel):
sandbox_runtime_container_image: str | None = None
mcp_config: MCPConfig | None = None
search_api_key: SecretStr | None = None
email: str | None = None
email_verified: bool | None = None
model_config = {
'validate_assignment': True,

View File

@@ -48,6 +48,7 @@ dirhash = "*"
tornado = "*"
python-dotenv = "*"
rapidfuzz = "^3.9.0"
rich = "^13.7.0"
whatthepatch = "^1.0.6"
protobuf = "^5.0.0,<6.0.0" # Updated to support newer opentelemetry
opentelemetry-api = "^1.33.1"

View File

@@ -16,16 +16,6 @@ from openhands.events.action import CmdRunAction
from openhands.events.observation import CmdOutputObservation, ErrorObservation
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected timeout suffix."""
return (
f'[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
# ============================================================================================================================
# Bash-specific tests
@@ -66,7 +56,10 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
if runtime_cls == CLIRuntime:
assert '[The command timed out after 1.0 seconds.]' in obs.metadata.suffix
else:
assert get_timeout_suffix(1.0) in obs.metadata.suffix
assert (
"[The command timed out after 1.0 seconds. You may wait longer to see additional output by sending empty command '', send other commands to interact with the current process, or send keys to interrupt/kill the command.]"
in obs.metadata.suffix
)
action = CmdRunAction(command='C-c', is_input=True)
action.set_hard_timeout(30)

View File

@@ -589,7 +589,7 @@
"working_dir": null,
"py_interpreter_path": null,
"prefix": "",
"suffix": "\n[The command has no new output after 30 seconds. You may wait longer to see additional output by sending empty command '', send other commands to interact with the current process, send keys to interrupt/kill the command, or use the timeout parameter in execute_bash for future commands.]"
"suffix": "\n[The command has no new output after 30 seconds. You may wait longer to see additional output by sending empty command '', send other commands to interact with the current process, or send keys to interrupt/kill the command.]"
},
"hidden": false
},

View File

@@ -5,15 +5,6 @@ import time
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import CmdRunAction
from openhands.runtime.utils.bash import BashCommandStatus, BashSession
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_no_change_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected no-change timeout suffix."""
return (
f'\n[The command has no new output after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
def test_session_initialization():
@@ -92,7 +83,12 @@ def test_long_running_command_follow_by_execute():
assert '1' in obs.content # First number should appear before timeout
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.prefix == ''
# Continue watching output
@@ -100,7 +96,12 @@ def test_long_running_command_follow_by_execute():
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '2' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
@@ -141,7 +142,12 @@ def test_interactive_command():
assert 'Enter name:' in obs.content
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.prefix == ''
# Send input
@@ -158,21 +164,36 @@ def test_interactive_command():
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.prefix == ''
obs = session.execute(CmdRunAction('line 1', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('line 2', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('EOF', is_input=True))
@@ -195,7 +216,12 @@ def test_ctrl_c():
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'looping' in obs.content
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 seconds. '
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT

View File

@@ -0,0 +1,49 @@
import asyncio
from contextlib import asynccontextmanager
from unittest import mock
import pytest
from openhands.mcp.client import MCPClient
@pytest.mark.asyncio
async def test_connect_sse_timeout():
"""Test that connect_sse properly times out when server_url is invalid."""
client = MCPClient()
# Create a mock async context manager that simulates a timeout
@asynccontextmanager
async def mock_slow_context(*args, **kwargs):
# This will hang for longer than our timeout
await asyncio.sleep(10.0)
yield (mock.AsyncMock(), mock.AsyncMock())
# Patch the sse_client function to return our slow context manager
with mock.patch(
'openhands.mcp.client.sse_client', return_value=mock_slow_context()
):
# Test with a very short timeout
with pytest.raises(asyncio.TimeoutError):
await client.connect_sse('http://example.com', timeout=0.1)
@pytest.mark.asyncio
async def test_connect_streamable_http_timeout():
"""Test that connect_streamable_http properly times out when server_url is invalid."""
client = MCPClient()
# Create a mock async context manager that simulates a timeout
@asynccontextmanager
async def mock_slow_context(*args, **kwargs):
# This will hang for longer than our timeout
await asyncio.sleep(10.0)
yield (mock.AsyncMock(), mock.AsyncMock(), mock.AsyncMock())
# Patch the streamablehttp_client function to return our slow context manager
with mock.patch(
'openhands.mcp.client.streamablehttp_client', return_value=mock_slow_context()
):
# Test with a very short timeout
with pytest.raises(asyncio.TimeoutError):
await client.connect_shttp('http://example.com', timeout=0.1)

View File

@@ -2,7 +2,6 @@ import asyncio
import pytest
from openhands.core.config.mcp_config import MCPSSEServerConfig
from openhands.mcp.client import MCPClient
from openhands.mcp.utils import create_mcp_clients
@@ -11,24 +10,22 @@ from openhands.mcp.utils import create_mcp_clients
async def test_create_mcp_clients_timeout_with_invalid_url():
"""Test that create_mcp_clients properly times out when given an invalid URL."""
# Use a non-existent domain that should cause a connection timeout
server = MCPSSEServerConfig(
url='http://non-existent-domain-that-will-timeout.invalid'
)
invalid_url = 'http://non-existent-domain-that-will-timeout.invalid'
# Temporarily modify the default timeout for the MCPClient.connect_http method
original_connect_connect_http = MCPClient.connect_http
# Temporarily modify the default timeout for the MCPClient.connect_sse method
original_connect_sse = MCPClient.connect_sse
# Create a wrapper that calls the original method but with a shorter timeout
async def connect_http_with_short_timeout(self, server_url, timeout=30.0):
return await original_connect_connect_http(self, server_url, timeout=0.5)
async def connect_sse_with_short_timeout(self, server_url, timeout=30.0):
return await original_connect_sse(self, server_url, timeout=0.5)
try:
# Replace the method with our wrapper
MCPClient.connect_http = connect_http_with_short_timeout
MCPClient.connect_sse = connect_sse_with_short_timeout
# Call create_mcp_clients with the invalid URL
start_time = asyncio.get_event_loop().time()
clients = await create_mcp_clients([server], [])
clients = await create_mcp_clients([invalid_url], [])
end_time = asyncio.get_event_loop().time()
# Verify that no clients were successfully connected
@@ -41,7 +38,7 @@ async def test_create_mcp_clients_timeout_with_invalid_url():
)
finally:
# Restore the original method
MCPClient.connect_http = original_connect_connect_http
MCPClient.connect_sse = original_connect_sse
@pytest.mark.asyncio
@@ -51,16 +48,16 @@ async def test_create_mcp_clients_with_unreachable_host():
# This IP is in the TEST-NET-1 range (192.0.2.0/24) reserved for documentation and examples
unreachable_url = 'http://192.0.2.1:8080'
# Temporarily modify the default timeout for the MCPClient.connect_http method
original_connect_http = MCPClient.connect_http
# Temporarily modify the default timeout for the MCPClient.connect_sse method
original_connect_sse = MCPClient.connect_sse
# Create a wrapper that calls the original method but with a shorter timeout
async def connect_http_with_short_timeout(self, server_url, timeout=30.0):
return await original_connect_http(self, server_url, timeout=1.0)
async def connect_sse_with_short_timeout(self, server_url, timeout=30.0):
return await original_connect_sse(self, server_url, timeout=1.0)
try:
# Replace the method with our wrapper
MCPClient.connect_http = connect_http_with_short_timeout
MCPClient.connect_sse = connect_sse_with_short_timeout
# Call create_mcp_clients with the unreachable URL
start_time = asyncio.get_event_loop().time()
@@ -76,4 +73,4 @@ async def test_create_mcp_clients_with_unreachable_host():
)
finally:
# Restore the original method
MCPClient.connect_http = original_connect_http
MCPClient.connect_sse = original_connect_sse

View File

@@ -13,12 +13,12 @@ async def test_sse_connection_timeout():
# Create a mock MCPClient
mock_client = mock.MagicMock(spec=MCPClient)
# Configure the mock to raise a TimeoutError when connect_http is called
async def mock_connect_http(*args, **kwargs):
# Configure the mock to raise a TimeoutError when connect_sse is called
async def mock_connect_sse(*args, **kwargs):
await asyncio.sleep(0.1) # Simulate some delay
raise asyncio.TimeoutError('Connection timed out')
mock_client.connect_http.side_effect = mock_connect_http
mock_client.connect_sse.side_effect = mock_connect_sse
mock_client.disconnect = mock.AsyncMock()
# Mock the MCPClient constructor to return our mock
@@ -35,8 +35,11 @@ async def test_sse_connection_timeout():
# Verify that no clients were successfully connected
assert len(clients) == 0
# Verify that connect_http was called for each server
assert mock_client.connect_http.call_count == 2
# Verify that connect_sse was called for each server
assert mock_client.connect_sse.call_count == 2
# Verify that disconnect was called for each failed connection
assert mock_client.disconnect.call_count == 2
@pytest.mark.asyncio

View File

@@ -24,7 +24,7 @@ async def test_create_mcp_clients_success(mock_mcp_client):
# Setup mock
mock_client_instance = AsyncMock()
mock_mcp_client.return_value = mock_client_instance
mock_client_instance.connect_http = AsyncMock()
mock_client_instance.connect_sse = AsyncMock()
# Test with two servers
server_configs = [
@@ -38,12 +38,12 @@ async def test_create_mcp_clients_success(mock_mcp_client):
assert len(clients) == 2
assert mock_mcp_client.call_count == 2
# Check that connect_http was called with correct parameters
mock_client_instance.connect_http.assert_any_call(
server_configs[0], conversation_id=None
# Check that connect_sse was called with correct parameters
mock_client_instance.connect_sse.assert_any_call(
'http://server1:8080', api_key=None, conversation_id=None
)
mock_client_instance.connect_http.assert_any_call(
server_configs[1], conversation_id=None
mock_client_instance.connect_sse.assert_any_call(
'http://server2:8080', api_key='test-key', conversation_id=None
)
@@ -56,10 +56,11 @@ async def test_create_mcp_clients_connection_failure(mock_mcp_client):
mock_mcp_client.return_value = mock_client_instance
# First connection succeeds, second fails
mock_client_instance.connect_http.side_effect = [
mock_client_instance.connect_sse.side_effect = [
None, # Success
Exception('Connection failed'), # Failure
]
mock_client_instance.disconnect = AsyncMock()
server_configs = [
MCPSSEServerConfig(url='http://server1:8080'),
@@ -70,6 +71,7 @@ async def test_create_mcp_clients_connection_failure(mock_mcp_client):
# Verify only one client was successfully created
assert len(clients) == 1
assert mock_client_instance.disconnect.call_count == 1
def test_convert_mcp_clients_to_tools_empty():

View File

@@ -28,9 +28,6 @@ class MockUserAuth(UserAuth):
async def get_user_id(self) -> str | None:
return 'test-user'
async def get_user_email(self) -> str | None:
return 'test-email@whatever.com'
async def get_access_token(self) -> SecretStr | None:
return SecretStr('test-token')

View File

@@ -27,9 +27,6 @@ class MockUserAuth(UserAuth):
async def get_user_id(self) -> str | None:
return 'test-user'
async def get_user_email(self) -> str | None:
return 'test-email@whatever.com'
async def get_access_token(self) -> SecretStr | None:
return SecretStr('test-token')

View File

@@ -12,16 +12,6 @@ from openhands.events.observation import ErrorObservation
from openhands.events.observation.commands import (
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected timeout suffix."""
return (
f'[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
# Skip all tests in this module if not running on Windows
pytestmark = pytest.mark.skipif(
@@ -178,7 +168,10 @@ def test_long_running_command(windows_bash_session):
# Verify the initial output was captured
assert 'Serving HTTP on' in result.content
# Check for timeout specific metadata
assert get_timeout_suffix(1.0) in result.metadata.suffix
assert (
"[The command timed out after 1.0 seconds. You may wait longer to see additional output by sending empty command '', send other commands to interact with the current process, or send keys to interrupt/kill the command.]"
in result.metadata.suffix
)
assert result.exit_code == -1
# The action timed out, but the command should be still running