Compare commits

..

42 Commits

Author SHA1 Message Date
Chuck Butkus
60c8480dbd Try 2025-06-05 23:14:45 -04:00
Chuck Butkus
313276207b Update to use toast messages 2025-06-05 15:46:36 -04:00
Chuck Butkus
7e34240d49 Fix email input box 2025-06-05 15:33:59 -04:00
chuckbutkus
76be0ffff9 Merge branch 'main' into allow-email-change 2025-06-05 15:26:33 -04:00
Chuck Butkus
60eb68bd91 User setting refactor 2025-06-05 15:23:04 -04:00
Chuck Butkus
686eb45fae User setting refactor 2025-06-05 15:23:04 -04:00
chuckbutkus
8566cd6ed2 Merge branch 'main' into allow-email-change 2025-06-05 12:19:24 -04:00
chuckbutkus
854e926bac Merge branch 'main' into allow-email-change 2025-06-04 23:15:49 -04:00
openhands
f981a8a254 Update email placeholder to show 'Loading...' instead of 'Email not available' 2025-06-05 03:00:40 +00:00
openhands
3f47187f2f Update buttons to have black text when disabled 2025-06-05 02:43:02 +00:00
openhands
19c4296b07 Update button styles to match the Launch button style 2025-06-05 02:31:05 +00:00
Chuck Butkus
0929936045 Fix lint 2025-06-04 22:10:41 -04:00
openhands
6765673523 Remove default value for email verification restriction message 2025-06-05 01:56:02 +00:00
openhands
846999202d Update email verification success message and add translations 2025-06-05 01:53:35 +00:00
openhands
523d2ff170 Add background polling for email verification status on user settings page 2025-06-05 01:34:56 +00:00
chuckbutkus
edf2269f13 Merge branch 'main' into allow-email-change 2025-06-04 17:21:14 -04:00
openhands
a0bdd4101c Fix settings-with-payment test by adding user settings route and mocking email verification 2025-06-04 19:50:51 +00:00
chuckbutkus
c7ca81f85c Merge branch 'main' into allow-email-change 2025-06-04 15:28:13 -04:00
chuckbutkus
bff22652cb Merge branch 'main' into allow-email-change 2025-06-04 14:27:26 -04:00
Chuck Butkus
330d5a75e7 Fix lint errors 2025-06-04 12:57:46 -04:00
Chuck Butkus
42885c0288 Fix lint errors 2025-06-04 12:48:23 -04:00
Chuck Butkus
8805f34af0 Remove duplication 2025-06-04 02:38:06 -04:00
openhands
45bb6877e6 Update remaining files for EMAIL_VERIFIED restriction 2025-06-04 06:25:38 +00:00
openhands
703efd17ab Restrict app to only show user settings page when EMAIL_VERIFIED is false 2025-06-04 06:17:37 +00:00
openhands
b8884ed447 Add email verification UI improvements: hide resend button when verified and show warning message when not verified 2025-06-04 01:55:52 +00:00
Chuck Butkus
8cfac66cc9 Another email_verified change 2025-06-03 21:40:03 -04:00
Chuck Butkus
bcdec805e2 Add email_verified to settings 2025-06-03 21:35:01 -04:00
Chuck Butkus
2138eeb556 Update 2025-06-03 01:15:17 -04:00
openhands
e00b00b372 Set withCredentials only in user-settings.tsx instead of globally 2025-06-03 04:36:45 +00:00
openhands
5f1f3b1e2d Enable withCredentials to allow cookies to be set from API responses 2025-06-03 04:35:17 +00:00
openhands
45ffac0b78 Add translations for resend verification email functionality 2025-06-02 22:09:42 +00:00
openhands
70a8e1bc0a Move save button to be before resend verification button on the same line 2025-06-02 21:40:22 +00:00
openhands
e74b354137 Add resend verification email button to user settings 2025-06-02 21:06:57 +00:00
Chuck Butkus
56ed63088f Update 2025-06-02 16:23:01 -04:00
openhands
489e32c2c0 Fix email update to use /api/settings endpoint 2025-05-31 19:10:03 +00:00
openhands
c189012f0a Fix email update to use query parameter instead of form data 2025-05-31 19:05:02 +00:00
openhands
2407420e17 Make email field editable and add save button in user settings 2025-05-31 18:59:14 +00:00
chuckbutkus
bb0c47c41a Merge branch 'main' into display-email 2025-05-31 01:14:24 -04:00
Chuck Butkus
83e5276de5 Update User Setting tab 2025-05-31 01:13:38 -04:00
openhands
816082a55b Update User tab to display email from settings instead of git user 2025-05-31 04:12:04 +00:00
Chuck Butkus
82d72b145d Add email to Setting class 2025-05-30 23:59:47 -04:00
Chuck Butkus
f8c3470c91 Add get_user_email from UserAuth 2025-05-30 15:43:08 -04:00
64 changed files with 991 additions and 1409 deletions

View File

@@ -313,8 +313,6 @@ jobs:
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
@@ -380,8 +378,6 @@ jobs:
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge

View File

@@ -74,11 +74,5 @@ jobs:
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/test_windows_bash.py
env:
DEBUG: "1"
- name: Run Windows runtime tests with LocalRuntime
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
env:
TEST_RUNTIME: local
DEBUG: "1"

View File

@@ -1,107 +0,0 @@
---
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,6 +6,21 @@ 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 () => {
@@ -20,6 +35,7 @@ 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;
@@ -47,6 +63,10 @@ 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

@@ -236,26 +236,6 @@ class OpenHands {
return data;
}
static async startConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/start`,
);
return data;
}
static async stopConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/stop`,
);
return data;
}
/**
* Get the settings from the server or use the default settings if not found
*/

View File

@@ -84,7 +84,7 @@ export function AgentStatusBar() {
setStatusMessage(t(I18nKey.STATUS$STARTING_RUNTIME));
setIndicatorColor(IndicatorColor.RED);
} else if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage(t(I18nKey.STATUS$WEBSOCKET_CLOSED));
setStatusMessage(t(I18nKey.STATUS$CONNECTED)); // Using STATUS$CONNECTED instead of STATUS$CONNECTING
setIndicatorColor(IndicatorColor.RED);
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);

View File

@@ -122,7 +122,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
modifiedEditor.onDidContentSizeChange(updateEditorHeight);
};
const status = (type === "U" ? STATUS_MAP.A : STATUS_MAP[type]) || "?";
const status = type === "U" ? STATUS_MAP.A : STATUS_MAP[type];
let statusIcon: React.ReactNode;
if (typeof status === "string") {

View File

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

View File

@@ -8,11 +8,13 @@ import { cn } from "#/utils/utils";
interface ConversationPanelButtonProps {
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}
export function ConversationPanelButton({
isOpen,
onClick,
disabled = false,
}: ConversationPanelButtonProps) {
const { t } = useTranslation();
@@ -22,10 +24,14 @@ 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]")}
className={cn(
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}
/>
</TooltipButton>
);

View File

@@ -3,15 +3,24 @@ import DocsIcon from "#/icons/academy.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
export function DocsButton() {
interface DocsButtonProps {
disabled?: boolean;
}
export function DocsButton({ disabled = false }: DocsButtonProps) {
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]" />
<DocsIcon
width={28}
height={28}
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
/>
</TooltipButton>
);
}

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
disabled?: boolean;
}
export function TooltipButton({
@@ -23,9 +24,10 @@ export function TooltipButton({
ariaLabel,
testId,
className,
disabled = false,
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
if (onClick && !disabled) {
onClick();
e.preventDefault();
}
@@ -37,7 +39,12 @@ export function TooltipButton({
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
className={cn("hover:opacity-80", className)}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
disabled={disabled}
>
{children}
</button>
@@ -45,7 +52,7 @@ export function TooltipButton({
let content;
if (navLinkTo) {
if (navLinkTo && !disabled) {
content = (
<NavLink
to={navLinkTo}
@@ -63,7 +70,24 @@ export function TooltipButton({
{children}
</NavLink>
);
} else if (href) {
} 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) {
content = (
<a
href={href}
@@ -76,6 +100,19 @@ 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

@@ -150,8 +150,7 @@ export function WsClientProvider({
const { providers } = useUserProviders();
const messageRateHandler = useRate({ threshold: 250 });
const { data: conversation, refetch: refetchConversation } =
useActiveConversation();
const { data: conversation } = useActiveConversation();
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
@@ -270,11 +269,14 @@ export function WsClientProvider({
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data);
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
setErrorMessage(
hasValidMessageProperty(data)
? data.message
: "The WebSocket connection was closed.",
);
}
function handleError(data: unknown) {
// set status
setStatus(WsClientProviderStatus.DISCONNECTED);
updateStatusWhenErrorMessagePresent(data);
@@ -283,9 +285,6 @@ export function WsClientProvider({
? data.message
: "An unknown error occurred on the WebSocket connection.",
);
// check if something went wrong with the conversation.
refetchConversation();
}
React.useEffect(() => {
@@ -301,19 +300,12 @@ export function WsClientProvider({
if (!conversationId) {
throw new Error("No conversation ID provided");
}
if (
!conversation ||
["STOPPED", "STARTING"].includes(conversation.status)
) {
if (!conversation || conversation.status === "STARTING") {
return () => undefined; // conversation not yet loaded
}
let sio = sioRef.current;
if (sio?.connected) {
sio.disconnect();
}
const lastEvent = lastEventRef.current;
const query = {
latest_event_id: lastEvent?.id ?? -1,

View File

@@ -9,7 +9,7 @@ export const useActiveConversation = () => {
const { conversationId } = useConversationId();
const userConversation = useUserConversation(conversationId, (query) => {
if (query.state.data?.status === "STARTING") {
return 3000; // 3 seconds
return 2000; // 2 seconds
}
return FIVE_MINUTES;
});

View File

@@ -27,7 +27,8 @@ 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

@@ -0,0 +1,116 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { AxiosError } from "axios";
import { openHands } from "#/api/open-hands-axios";
import { Settings } from "#/types/settings";
import { useConfig } from "#/hooks/query/use-config";
/**
* Hook to handle email verification errors (403 with "Email has not been verified" message)
* This hook sets up an axios interceptor that will reload settings and navigate to the user settings page
* when a 403 error with the specific message is encountered.
*/
export const useHandleEmailVerification = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: config } = useConfig();
const appMode = config?.APP_MODE;
console.log(`config: ${config}`);
console.log(`AppMode: ${appMode}`);
useEffect(() => {
// Add response interceptor
const interceptorId = openHands.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
console.log(
`Received error ${error.response?.status} with message ${error.response?.data}`,
);
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
// check for email verification error message no matter how it is returned.
const isEmailNotVerified = (() => {
const data = error.response?.data;
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;
})();
// Check if it's a 403 error with the specific message
if (error.response?.status === 403 && isEmailNotVerified) {
console.log("EMAIL VERIFICATION ERROR");
// Only handle this in SAAS mode
console.log(`config1: ${config}`);
console.log(`AppMode1: ${appMode}`);
if (appMode === "saas") {
// Update settings to mark email as unverified
queryClient.setQueryData(
["settings"],
(oldData: Settings | undefined) => {
if (oldData) {
console.log("ADDING EMAIL_VERIFIED is FALSE");
return {
...oldData,
EMAIL_VERIFIED: false,
};
}
console.log("NO CHANGES TO SETTINGS");
return oldData;
},
);
// Invalidate settings to reload them
queryClient.invalidateQueries({ queryKey: ["settings"] });
// Navigate to settings/user page
// The EmailVerificationGuard will handle the redirect
console.log("NAVIGATING to /settings/user");
navigate("/settings/user");
}
} else {
console.log("NOT EMAIL VERIFICATION ERROR");
console.log(typeof error.response?.data);
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);
// Clean up interceptor when component unmounts
return () => {
openHands.interceptors.response.eject(interceptorId);
};
}, [queryClient, navigate]);
};

View File

@@ -1,6 +1,5 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
HOME$READ_THIS = "HOME$READ_THIS",
AUTH$LOGGING_BACK_IN = "AUTH$LOGGING_BACK_IN",
@@ -556,4 +555,18 @@ 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

@@ -1,20 +1,4 @@
{
"STATUS$WEBSOCKET_CLOSED": {
"en": "The WebSocket connection was closed.",
"ja": "WebSocket接続が閉じられました。",
"zh-CN": "WebSocket连接已关闭。",
"zh-TW": "WebSocket連接已關閉。",
"ko-KR": "WebSocket 연결이 닫혔습니다.",
"no": "WebSocket-tilkoblingen ble lukket.",
"it": "La connessione WebSocket è stata chiusa.",
"pt": "A conexão WebSocket foi fechada.",
"es": "La conexión WebSocket se ha cerrado.",
"ar": "تم إغلاق اتصال WebSocket.",
"fr": "La connexion WebSocket a été fermée.",
"tr": "WebSocket bağlantısı kapatıldı.",
"de": "Die WebSocket-Verbindung wurde geschlossen.",
"uk": "З'єднання WebSocket було закрито."
},
"HOME$LAUNCH_FROM_SCRATCH": {
"en": "Launch from Scratch",
"ja": "ゼロから始める",
@@ -8894,5 +8878,229 @@
"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$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,6 +12,7 @@ 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

@@ -43,7 +43,7 @@ function AppContent() {
const { t } = useTranslation();
const { data: settings } = useSettings();
const { conversationId } = useConversationId();
const { data: conversation, isFetched, refetch } = useActiveConversation();
const { data: conversation, isFetched } = useActiveConversation();
const { data: isAuthed } = useIsAuthed();
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -61,13 +61,8 @@ function AppContent() {
"This conversation does not exist, or you do not have permission to access it.",
);
navigate("/");
} else if (conversation?.status === "STOPPED") {
// start the conversation if the state is stopped on initial load
OpenHands.startConversation(conversation.conversation_id).then(() =>
refetch(),
);
}
}, [conversation?.conversation_id, isFetched, isAuthed]);
}, [conversation, isFetched, isAuthed]);
React.useEffect(() => {
dispatch(clearTerminal());

View File

@@ -24,7 +24,9 @@ import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { useHandleEmailVerification } from "#/hooks/use-handle-email-verification";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
export function ErrorBoundary() {
const error = useRouteError();
@@ -92,6 +94,9 @@ export default function MainApp() {
// Handle authentication callback and set login method after successful authentication
useAuthCallback();
// Set up interceptor for email verification errors
useHandleEmailVerification();
React.useEffect(() => {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.LANGUAGE) {
@@ -204,7 +209,9 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
<Outlet />
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
</div>
{renderAuthModal && (

View File

@@ -15,6 +15,7 @@ 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") },
@@ -33,10 +34,11 @@ function SettingsScreen() {
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/git");
navigate("/settings/user");
}
} else {
const noEnteringPaths = [
"/settings/user",
"/settings/billing",
"/settings/credits",
"/settings/api-keys",

View File

@@ -0,0 +1,206 @@
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";
function EmailInputSection({
email,
onEmailChange,
onSaveEmail,
onResendVerification,
isSaving,
isResendingVerification,
isEmailChanged,
emailVerified,
children,
}: {
email: string;
onEmailChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSaveEmail: () => void;
onResendVerification: () => void;
isSaving: boolean;
isResendingVerification: boolean;
isEmailChanged: boolean;
emailVerified?: 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 border-tertiary flex-grow focus:outline-none focus:border-transparent focus:ring-0"
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
data-testid="email-input"
/>
</div>
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={onSaveEmail}
disabled={!isEmailChanged || isSaving}
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 queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(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>) => {
setEmail(e.target.value);
};
const handleSaveEmail = async () => {
if (email === originalEmail) 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}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
</EmailInputSection>
)}
</div>
</div>
);
}
export default UserSettingsScreen;

View File

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

View File

@@ -1,6 +1,7 @@
import asyncio
import logging
import os
import sys
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
@@ -453,37 +454,6 @@ 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:

View File

@@ -1,549 +0,0 @@
"""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()

View File

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

View File

@@ -1,76 +0,0 @@
"""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

@@ -1,63 +0,0 @@
"""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

@@ -1,69 +0,0 @@
"""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,26 +744,13 @@ 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()
# 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()
args = parser.parse_args()
if args.version:
print(f'OpenHands version: {__version__}')

View File

@@ -505,7 +505,10 @@ class GitHubService(BaseGitService, GitService):
)
# Return the HTML URL of the created PR
return response['html_url']
if 'html_url' in response:
return response['html_url']
else:
return f'PR created but URL not found in response: {response}'

View File

@@ -500,8 +500,12 @@ class GitLabService(BaseGitService, GitService):
url=url, params=payload, method=RequestMethod.POST
)
# Return the web URL of the created MR
if 'web_url' in response:
return response['web_url']
else:
return f'MR created but URL not found in response: {response}'
return response['web_url']

View File

@@ -15,3 +15,4 @@ When you're done, make sure to
2. Use the `create_pr` tool to open a new PR
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
4. The PR description should mention that it "fixes" or "closes" the issue number
5. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`

View File

@@ -9,3 +9,4 @@ When you're done, make sure to
1. Use the `create_pr` tool to open a new PR
2. The PR description should mention that it "fixes" or "closes" the issue number
3. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`

View File

@@ -15,3 +15,4 @@ When you're done, make sure to
2. Use the `create_mr` tool to open a new MR
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
4. The MR description should mention that it "fixes" or "closes" the issue number
5. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`

View File

@@ -9,3 +9,4 @@ When you're done, make sure to
1. Use the `create_mr` tool to open a new MR
2. The MR description should mention that it "fixes" or "closes" the issue number
3. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`

View File

@@ -5,3 +5,7 @@ These are a list of text messages attached in order of most recent.
{{ message }}
{% if not loop.last %}\n\n{% endif %}
{% endfor %}
If you opened a pull request, please leave the following comment at the end your summary and pull request description
`{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`

View File

@@ -65,6 +65,7 @@ from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.file_viewer_server import start_file_viewer_server
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.async_bash import AsyncBashSession
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.log_capture import capture_logs
@@ -253,10 +254,12 @@ class ActionExecutor:
# If we get here, the browser is ready
logger.debug('Browser is ready')
def _create_bash_session(self, cwd: str | None = None):
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
if sys.platform == 'win32':
return WindowsPowershellSession( # type: ignore[name-defined]
work_dir=cwd or self._initial_cwd,
self.bash_session = WindowsPowershellSession( # type: ignore[name-defined]
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
@@ -264,21 +267,15 @@ class ActionExecutor:
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
else:
bash_session = BashSession(
work_dir=cwd or self._initial_cwd,
self.bash_session = BashSession(
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
),
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
bash_session.initialize()
return bash_session
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
self.bash_session = self._create_bash_session()
self.bash_session.initialize()
logger.debug('Bash session initialized')
# Start browser initialization in the background
@@ -391,11 +388,18 @@ class ActionExecutor:
self, action: CmdRunAction
) -> CmdOutputObservation | ErrorObservation:
try:
bash_session = self.bash_session
if action.is_static:
bash_session = self._create_bash_session(action.cwd)
assert bash_session is not None
obs = await call_sync_from_async(bash_session.execute, action)
path = action.cwd or self._initial_cwd
result = await AsyncBashSession.execute(action.command, path)
obs = CmdOutputObservation(
content=result.content,
exit_code=result.exit_code,
command=action.command,
)
return obs
assert self.bash_session is not None
obs = await call_sync_from_async(self.bash_session.execute, action)
return obs
except Exception as e:
logger.error(f'Error running command: {e}')

View File

@@ -400,7 +400,7 @@ class Runtime(FileEditRuntimeMixin):
'No repository selected. Initializing a new git repository in the workspace.'
)
action = CmdRunAction(
command=f'git init && git config --global --add safe.directory {self.workspace_root}'
command='git init',
)
self.run_action(action)
else:
@@ -952,9 +952,6 @@ fi
exit_code = 0
content = ''
if isinstance(obs, ErrorObservation):
exit_code = -1
if hasattr(obs, 'exit_code'):
exit_code = obs.exit_code
if hasattr(obs, 'content'):

View File

@@ -406,7 +406,7 @@ class ActionExecutionClient(Runtime):
'POST',
f'{self.action_execution_server_url}/update_mcp_server',
json=stdio_tools,
timeout=60,
timeout=10,
)
result = response.json()
if response.status_code != 200:
@@ -464,9 +464,7 @@ class ActionExecutionClient(Runtime):
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(
updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid
)
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state

View File

@@ -0,0 +1,54 @@
import asyncio
import os
from openhands.runtime.base import CommandResult
class AsyncBashSession:
@staticmethod
async def execute(command: str, work_dir: str) -> CommandResult:
"""Execute a command in the bash session asynchronously."""
work_dir = os.path.abspath(work_dir)
if not os.path.exists(work_dir):
raise ValueError(f'Work directory {work_dir} does not exist.')
command = command.strip()
if not command:
return CommandResult(content='', exit_code=0)
try:
process = await asyncio.subprocess.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=work_dir,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=30
)
output = stdout.decode('utf-8')
if stderr:
output = stderr.decode('utf-8')
print(f'!##! Error running command: {stderr.decode("utf-8")}')
return CommandResult(content=output, exit_code=process.returncode or 0)
except asyncio.TimeoutError:
process.terminate()
# Allow a brief moment for cleanup
try:
await asyncio.wait_for(process.wait(), timeout=1.0)
except asyncio.TimeoutError:
process.kill() # Force kill if it doesn't terminate cleanly
return CommandResult(content='Command timed out.', exit_code=-1)
except Exception as e:
return CommandResult(
content=f'Error running command: {str(e)}', exit_code=-1
)

View File

@@ -44,7 +44,7 @@ class GitHandler:
Returns:
bool: True if inside a Git repository, otherwise False.
"""
cmd = 'git --no-pager rev-parse --is-inside-work-tree'
cmd = 'git rev-parse --is-inside-work-tree'
output = self.execute(cmd, self.cwd)
return output.content.strip() == 'true'
@@ -71,7 +71,7 @@ class GitHandler:
Returns:
bool: True if the reference exists, otherwise False.
"""
cmd = f'git --no-pager rev-parse --verify {ref}'
cmd = f'git rev-parse --verify {ref}'
output = self.execute(cmd, self.cwd)
return output.exit_code == 0
@@ -86,9 +86,9 @@ class GitHandler:
default_branch = self._get_default_branch()
ref_current_branch = f'origin/{current_branch}'
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = 'origin/' + default_branch
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
refs = [
ref_current_branch,
@@ -116,7 +116,7 @@ class GitHandler:
if not ref:
return ''
cmd = f'git --no-pager show {ref}:{file_path}'
cmd = f'git show {ref}:{file_path}'
output = self.execute(cmd, self.cwd)
return output.content if output.exit_code == 0 else ''
@@ -127,7 +127,7 @@ class GitHandler:
Returns:
str: The name of the primary branch.
"""
cmd = 'git --no-pager remote show origin | grep "HEAD branch"'
cmd = 'git remote show origin | grep "HEAD branch"'
output = self.execute(cmd, self.cwd)
return output.content.split()[-1].strip()
@@ -138,7 +138,7 @@ class GitHandler:
Returns:
str: The name of the current branch.
"""
cmd = 'git --no-pager rev-parse --abbrev-ref HEAD'
cmd = 'git rev-parse --abbrev-ref HEAD'
output = self.execute(cmd, self.cwd)
return output.content.strip()
@@ -153,12 +153,8 @@ class GitHandler:
if not ref:
return []
diff_cmd = f'git --no-pager diff --name-status {ref}'
diff_cmd = f'git diff --name-status {ref}'
output = self.execute(diff_cmd, self.cwd)
if output.exit_code != 0:
raise RuntimeError(
f'Failed to get diff for ref {ref} in {self.cwd}. Command output: {output.content}'
)
return output.content.splitlines()
def _get_untracked_files(self) -> list[dict[str, str]]:
@@ -168,7 +164,7 @@ class GitHandler:
Returns:
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
"""
cmd = 'git --no-pager ls-files --others --exclude-standard'
cmd = 'git ls-files --others --exclude-standard'
output = self.execute(cmd, self.cwd)
obs_list = output.content.splitlines()
return (

View File

@@ -281,23 +281,7 @@ class DockerNestedConversationManager(ConversationManager):
raise ValueError('unsupported_operation')
async def close_session(self, sid: str):
# First try to graceful stop server.
try:
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
except docker.errors.NotFound as e:
return
try:
nested_url = self.get_nested_url_for_container(container)
async with httpx.AsyncClient(
headers={
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
}
) as client:
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
response.raise_for_status()
except Exception:
logger.exception("error_stopping_container")
container.stop()
stop_all_containers(f'openhands-runtime-{sid}')
async def get_agent_loop_info(self, user_id: str | None = None, filter_to_sids: set[str] | None = None) -> list[AgentLoopInfo]:
results = []

View File

@@ -369,9 +369,7 @@ class StandaloneConversationManager(ConversationManager):
f'removing connections: {connection_ids_to_remove}',
extra={'session_id': sid},
)
# Perform a graceful shutdown of each connection
for connection_id in connection_ids_to_remove:
await self.sio.disconnect(connection_id)
self._local_connection_id_to_session_id.pop(connection_id, None)
session = self._local_agent_loops_by_sid.pop(sid, None)

View File

@@ -12,7 +12,6 @@ from openhands.events.action import (
)
from openhands.events.action.agent import RecallAction
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
from openhands.events.event_store import EventStore
from openhands.events.observation import (
NullObservation,
)
@@ -125,48 +124,6 @@ async def connect(connection_id: str, environ: dict) -> None:
f'User {user_id} is allowed to connect to conversation {conversation_id}'
)
try:
event_store = EventStore(
conversation_id, conversation_manager.file_store, user_id
)
except FileNotFoundError as e:
logger.error(
f'Failed to create EventStore for conversation {conversation_id}: {e}'
)
raise ConnectionRefusedError(f'Failed to access conversation events: {e}')
logger.info(
f'Replaying event stream for conversation {conversation_id} with connection_id {connection_id}...'
)
agent_state_changed = None
# Create an async store to replay events
async_store = AsyncEventStoreWrapper(event_store, latest_event_id + 1)
# Process all available events
async for event in async_store:
logger.debug(f'oh_event: {event.__class__.__name__}')
if isinstance(
event,
(NullAction, NullObservation, RecallAction),
):
continue
elif isinstance(event, AgentStateChangedObservation):
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
# Send the agent state changed event last if we have one
if agent_state_changed:
await sio.emit(
'oh_event', event_to_dict(agent_state_changed), to=connection_id
)
logger.info(
f'Finished replaying event stream for conversation {conversation_id}'
)
conversation_init_data = await setup_init_convo_settings(
user_id, conversation_id, providers_set
)
@@ -176,12 +133,32 @@ async def connect(connection_id: str, environ: dict) -> None:
conversation_init_data,
user_id,
)
logger.info(
f'Connected to conversation {conversation_id} with connection_id {connection_id}. Replaying event stream...'
)
agent_state_changed = None
if agent_loop_info is None:
raise ConnectionRefusedError('Failed to join conversation')
async_store = AsyncEventStoreWrapper(
agent_loop_info.event_store, latest_event_id + 1
)
async for event in async_store:
logger.debug(f'oh_event: {event.__class__.__name__}')
if isinstance(
event,
(NullAction, NullObservation, RecallAction),
):
continue
elif isinstance(event, AgentStateChangedObservation):
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
if agent_state_changed:
await sio.emit(
'oh_event', event_to_dict(agent_state_changed), to=connection_id
)
logger.info(
f'Successfully joined conversation {conversation_id} with connection_id {connection_id}'
f'Finished replaying event stream for conversation {conversation_id}'
)
except ConnectionRefusedError:
# Close the broken connection after sending an error message

View File

@@ -1,4 +1,3 @@
import os
import re
from typing import Annotated
@@ -11,10 +10,9 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import GitService, ProviderType
from openhands.integrations.service_types import ProviderType
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import ConversationStoreImpl, config, server_config
from openhands.server.types import AppMode
from openhands.server.shared import ConversationStoreImpl, config
from openhands.server.user_auth import (
get_access_token,
get_provider_tokens,
@@ -22,31 +20,7 @@ from openhands.server.user_auth import (
)
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
mcp_server = FastMCP(
'mcp', stateless_http=True, dependencies=get_dependencies(), mask_error_details=True
)
HOST = f'https://{os.getenv("WEB_HOST", "app.all-hands.dev").strip()}'
CONVO_URL = HOST + '/{}'
async def get_convo_link(service: GitService, conversation_id: str, body: str) -> str:
"""
Appends a followup link, in the PR body, to the OpenHands conversation that opened the PR
"""
if server_config.app_mode != AppMode.SAAS:
return body
user = await service.get_user()
username = user.login
convo_url = CONVO_URL.format(conversation_id)
convo_link = (
f'@{username} can click here to [continue refining the PR]({convo_url})'
)
body += f'\n\n{convo_link}'
return body
mcp_server = FastMCP('mcp', stateless_http=True, dependencies=get_dependencies(), mask_error_details=True)
async def save_pr_metadata(
user_id: str, conversation_id: str, tool_result: str
@@ -110,11 +84,6 @@ async def create_pr(
base_domain=github_token.host,
)
try:
body = await get_convo_link(github_service, conversation_id, body or '')
except Exception as e:
logger.warning(f'Failed to append convo link: {e}')
try:
response = await github_service.create_pr(
repo_name=repo_name,
@@ -128,7 +97,7 @@ async def create_pr(
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
error = f'Error creating pull request: {e}'
error = f"Error creating pull request: {e}"
raise ToolError(str(error))
return response
@@ -163,7 +132,7 @@ async def create_mr(
else ProviderToken()
)
gitlab_service = GitLabServiceImpl(
github_service = GitLabServiceImpl(
user_id=github_token.user_id,
external_auth_id=user_id,
external_auth_token=access_token,
@@ -172,14 +141,7 @@ async def create_mr(
)
try:
description = await get_convo_link(
gitlab_service, conversation_id, description or ''
)
except Exception as e:
logger.warning(f'Failed to append convo link: {e}')
try:
response = await gitlab_service.create_mr(
response = await github_service.create_mr(
id=id,
source_branch=source_branch,
target_branch=target_branch,
@@ -191,7 +153,7 @@ async def create_mr(
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
error = f'Error creating merge request: {e}'
error = f"Error creating merge request: {e}"
raise ToolError(str(error))
return response

View File

@@ -15,6 +15,7 @@ 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
@@ -35,10 +36,9 @@ 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

@@ -124,8 +124,8 @@ async def create_new_conversation(
image_urls=image_urls or [],
)
if attach_convo_id:
logger.warning('Attaching convo ID is deprecated, skipping process')
if attach_convo_id and conversation_instructions:
conversation_instructions = conversation_instructions.format(conversation_id)
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
conversation_id,

View File

@@ -25,6 +25,10 @@ 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,6 +38,10 @@ 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

@@ -12,7 +12,7 @@ async def get_conversation_store(request: Request) -> ConversationStore | None:
)
if conversation_store:
return conversation_store
user_id = await get_user_id(request)
user_id = get_user_id(request)
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
request.state.conversation_store = conversation_store
return conversation_store

View File

@@ -40,6 +40,8 @@ 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,

119
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -373,7 +373,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13\""
files = [
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"},
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"},
@@ -2969,8 +2969,8 @@ files = [
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
proto-plus = [
{version = ">=1.22.3,<2.0.0dev"},
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
@@ -2992,8 +2992,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0"
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
requests = ">=2.18.0,<3.0.0"
@@ -3211,8 +3211,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
proto-plus = [
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
@@ -6456,106 +6456,102 @@ et-xmlfile = "*"
[[package]]
name = "opentelemetry-api"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Python API"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_api-1.34.0-py3-none-any.whl", hash = "sha256:390b81984affe4453180820ca518de55e3be051111e70cc241bb3b0071ca3a2c"},
{file = "opentelemetry_api-1.34.0.tar.gz", hash = "sha256:48d167589134799093005b7f7f347c69cc67859c693b17787f334fbe8871279f"},
{file = "opentelemetry_api-1.25.0-py3-none-any.whl", hash = "sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737"},
{file = "opentelemetry_api-1.25.0.tar.gz", hash = "sha256:77c4985f62f2614e42ce77ee4c9da5fa5f0bc1e1821085e9a47533a9323ae869"},
]
[package.dependencies]
importlib-metadata = ">=6.0,<8.8.0"
typing-extensions = ">=4.5.0"
deprecated = ">=1.2.6"
importlib-metadata = ">=6.0,<=7.1"
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Protobuf encoding"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_common-1.34.0-py3-none-any.whl", hash = "sha256:a5ab7a9b7c3c7ba957c8ddcb08c0c93b1d732e066f544682a250ecf4d7a9ceef"},
{file = "opentelemetry_exporter_otlp_proto_common-1.34.0.tar.gz", hash = "sha256:5916d9ceda8c733adbec5e9cecf654fbf359e9f619ff43214277076fba888557"},
{file = "opentelemetry_exporter_otlp_proto_common-1.25.0-py3-none-any.whl", hash = "sha256:15637b7d580c2675f70246563363775b4e6de947871e01d0f4e3881d1848d693"},
{file = "opentelemetry_exporter_otlp_proto_common-1.25.0.tar.gz", hash = "sha256:c93f4e30da4eee02bacd1e004eb82ce4da143a2f8e15b987a9f603e0a85407d3"},
]
[package.dependencies]
opentelemetry-proto = "1.34.0"
opentelemetry-proto = "1.25.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_grpc-1.34.0-py3-none-any.whl", hash = "sha256:31c41017af85833242d49beb07bde7341b0a145f0b898ee383f3e3019037afb1"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.34.0.tar.gz", hash = "sha256:a634425340f506d5ebf641c92d88eb873754d4c5259b5b816afb234c6f87b37d"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.25.0-py3-none-any.whl", hash = "sha256:3131028f0c0a155a64c430ca600fd658e8e37043cb13209f0109db5c1a3e4eb4"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.25.0.tar.gz", hash = "sha256:c0b1661415acec5af87625587efa1ccab68b873745ca0ee96b69bb1042087eac"},
]
[package.dependencies]
deprecated = ">=1.2.6"
googleapis-common-protos = ">=1.52,<2.0"
grpcio = [
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
]
grpcio = ">=1.0.0,<2.0.0"
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.34.0"
opentelemetry-proto = "1.34.0"
opentelemetry-sdk = ">=1.34.0,<1.35.0"
typing-extensions = ">=4.5.0"
opentelemetry-exporter-otlp-proto-common = "1.25.0"
opentelemetry-proto = "1.25.0"
opentelemetry-sdk = ">=1.25.0,<1.26.0"
[[package]]
name = "opentelemetry-proto"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Python Proto"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_proto-1.34.0-py3-none-any.whl", hash = "sha256:ffb1f1b27552fda5a1cd581e34243cc0b6f134fb14c1c2a33cc3b4b208c9bf97"},
{file = "opentelemetry_proto-1.34.0.tar.gz", hash = "sha256:73e40509b692630a47192888424f7e0b8fb19d9ecf2f04e6f708170cd3346dfe"},
{file = "opentelemetry_proto-1.25.0-py3-none-any.whl", hash = "sha256:f07e3341c78d835d9b86665903b199893befa5e98866f63d22b00d0b7ca4972f"},
{file = "opentelemetry_proto-1.25.0.tar.gz", hash = "sha256:35b6ef9dc4a9f7853ecc5006738ad40443701e52c26099e197895cbda8b815a3"},
]
[package.dependencies]
protobuf = ">=5.0,<6.0"
protobuf = ">=3.19,<5.0"
[[package]]
name = "opentelemetry-sdk"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Python SDK"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_sdk-1.34.0-py3-none-any.whl", hash = "sha256:7850bcd5b5c95f9aae48603d6592bdad5c7bdef50c03e06393f8f457d891fe32"},
{file = "opentelemetry_sdk-1.34.0.tar.gz", hash = "sha256:719559622afcd515c2aec462ccb749ba2e70075a01df45837623643814d33716"},
{file = "opentelemetry_sdk-1.25.0-py3-none-any.whl", hash = "sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9"},
{file = "opentelemetry_sdk-1.25.0.tar.gz", hash = "sha256:ce7fc319c57707ef5bf8b74fb9f8ebdb8bfafbe11898410e0d2a761d08a98ec7"},
]
[package.dependencies]
opentelemetry-api = "1.34.0"
opentelemetry-semantic-conventions = "0.55b0"
typing-extensions = ">=4.5.0"
opentelemetry-api = "1.25.0"
opentelemetry-semantic-conventions = "0.46b0"
typing-extensions = ">=3.7.4"
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.55b0"
version = "0.46b0"
description = "OpenTelemetry Semantic Conventions"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions-0.55b0-py3-none-any.whl", hash = "sha256:63bb15b67377700e51c422d0d24092ca6ce9f3a4cb6f032375aa8af1fc2aab65"},
{file = "opentelemetry_semantic_conventions-0.55b0.tar.gz", hash = "sha256:933d2e20c2dbc0f9b2f4f52138282875b4b14c66c491f5273bcdef1781368e9c"},
{file = "opentelemetry_semantic_conventions-0.46b0-py3-none-any.whl", hash = "sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07"},
{file = "opentelemetry_semantic_conventions-0.46b0.tar.gz", hash = "sha256:fbc982ecbb6a6e90869b15c1673be90bd18c8a56ff1cffc0864e38e2edffaefa"},
]
[package.dependencies]
opentelemetry-api = "1.34.0"
typing-extensions = ">=4.5.0"
opentelemetry-api = "1.25.0"
[[package]]
name = "overrides"
@@ -7175,23 +7171,23 @@ testing = ["google-api-core (>=1.31.5)"]
[[package]]
name = "protobuf"
version = "5.29.5"
version = "4.25.8"
description = ""
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
files = [
{file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"},
{file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"},
{file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"},
{file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"},
{file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"},
{file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"},
{file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"},
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
{file = "protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0"},
{file = "protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9"},
{file = "protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f"},
{file = "protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7"},
{file = "protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0"},
{file = "protobuf-4.25.8-cp38-cp38-win32.whl", hash = "sha256:27d498ffd1f21fb81d987a041c32d07857d1d107909f5134ba3350e1ce80a4af"},
{file = "protobuf-4.25.8-cp38-cp38-win_amd64.whl", hash = "sha256:d552c53d0415449c8d17ced5c341caba0d89dbf433698e1436c8fa0aae7808a3"},
{file = "protobuf-4.25.8-cp39-cp39-win32.whl", hash = "sha256:077ff8badf2acf8bc474406706ad890466274191a48d0abd3bd6987107c9cde5"},
{file = "protobuf-4.25.8-cp39-cp39-win_amd64.whl", hash = "sha256:f4510b93a3bec6eba8fd8f1093e9d7fb0d4a24d1a81377c10c0e5bbfe9e4ed24"},
{file = "protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59"},
{file = "protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd"},
]
[[package]]
@@ -9339,6 +9335,7 @@ files = [
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
]
markers = {evaluation = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
@@ -9581,7 +9578,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13\""
files = [
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
@@ -9598,7 +9595,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13\""
files = [
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
@@ -11760,4 +11757,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "d9f6c24fa80dd191f180af0c802ea11ecf514d86aaa421cb19a9bb497362c101"
content-hash = "eaa84e30dbafb061a75b4b173a8ba16542c4a03ab74583c55ab282cd6119e430"

View File

@@ -20,12 +20,12 @@ packages = [
[tool.poetry.dependencies]
python = "^3.12,<3.14"
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
termcolor = "*"
docker = "*"
fastapi = "*"
@@ -34,7 +34,7 @@ types-toml = "*"
uvicorn = "*"
numpy = "*"
json-repair = "*"
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
html2text = "*"
e2b = ">=1.0.5,<1.4.0"
pexpect = "*"
@@ -48,11 +48,10 @@ 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"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = ">=0.66.26,<0.78.0"
runloop-api-client = "0.33.0"
libtmux = ">=0.37,<0.40"

View File

@@ -46,40 +46,28 @@ class TestGitHandler(unittest.TestCase):
def _setup_git_repos(self):
"""Set up real git repositories for testing."""
# Set up origin repository
self._execute_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
'git --no-pager init --initial-branch=main', self.origin_dir
)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.origin_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.origin_dir
"git config user.email 'test@example.com'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Create a file and commit it
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Original content')
self._execute_command('git --no-pager add file1.txt', self.origin_dir)
self._execute_command(
"git --no-pager commit -m 'Initial commit'", self.origin_dir
)
self._execute_command('git add file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
# Clone the origin repository to local
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
self._execute_command(
f'git --no-pager clone {self.origin_dir} {self.local_dir}'
)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.local_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.local_dir
"git config user.email 'test@example.com'", self.local_dir
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
# Create a feature branch in the local repository
self._execute_command(
'git --no-pager checkout -b feature-branch', self.local_dir
)
self._execute_command('git checkout -b feature-branch', self.local_dir)
# Modify a file and create a new file
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
@@ -89,40 +77,32 @@ class TestGitHandler(unittest.TestCase):
f.write('New file content')
# Add and commit file1.txt changes to create a baseline
self._execute_command('git --no-pager add file1.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Update file1.txt'", self.local_dir
)
self._execute_command('git add file1.txt', self.local_dir)
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
# Add and commit file2.txt, then modify it
self._execute_command('git --no-pager add file2.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file2.txt'", self.local_dir
)
self._execute_command('git add file2.txt', self.local_dir)
self._execute_command("git commit -m 'Add file2.txt'", self.local_dir)
# Modify file2.txt and stage it
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('Modified new file content')
self._execute_command('git --no-pager add file2.txt', self.local_dir)
self._execute_command('git add file2.txt', self.local_dir)
# Create a file that will be deleted
with open(os.path.join(self.local_dir, 'file3.txt'), 'w') as f:
f.write('File to be deleted')
self._execute_command('git --no-pager add file3.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file3.txt'", self.local_dir
)
self._execute_command('git --no-pager rm file3.txt', self.local_dir)
self._execute_command('git add file3.txt', self.local_dir)
self._execute_command("git commit -m 'Add file3.txt'", self.local_dir)
self._execute_command('git rm file3.txt', self.local_dir)
# Modify file1.txt again but don't stage it (unstaged change)
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content again')
# Push the feature branch to origin
self._execute_command(
'git --no-pager push -u origin feature-branch', self.local_dir
)
self._execute_command('git push -u origin feature-branch', self.local_dir)
def test_is_git_repo(self):
"""Test that _is_git_repo returns True for a git repository."""
@@ -131,7 +111,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager rev-parse --is-inside-work-tree'
cmd == 'git rev-parse --is-inside-work-tree'
for cmd, _ in self.executed_commands
)
)
@@ -144,7 +124,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager remote show origin | grep "HEAD branch"'
cmd == 'git remote show origin | grep "HEAD branch"'
for cmd, _ in self.executed_commands
)
)
@@ -153,12 +133,11 @@ class TestGitHandler(unittest.TestCase):
"""Test that _get_current_branch returns the correct branch name."""
branch = self.git_handler._get_current_branch()
self.assertEqual(branch, 'feature-branch')
print('executed commands:', self.executed_commands)
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager rev-parse --abbrev-ref HEAD'
cmd == 'git rev-parse --abbrev-ref HEAD'
for cmd, _ in self.executed_commands
)
)
@@ -173,7 +152,7 @@ class TestGitHandler(unittest.TestCase):
verify_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager rev-parse --verify')
if cmd.startswith('git rev-parse --verify')
]
# First should check origin/feature-branch (current branch)
@@ -183,17 +162,13 @@ class TestGitHandler(unittest.TestCase):
self.assertEqual(ref, 'origin/feature-branch')
# Verify the ref exists
result = self._execute_command(
f'git --no-pager rev-parse --verify {ref}', self.local_dir
)
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
self.assertEqual(result.exit_code, 0)
def test_get_valid_ref_without_origin_current_branch(self):
"""Test that _get_valid_ref falls back to default branch when current branch doesn't exist in origin."""
# Create a new branch that doesn't exist in origin
self._execute_command(
'git --no-pager checkout -b new-local-branch', self.local_dir
)
self._execute_command('git checkout -b new-local-branch', self.local_dir)
# Clear the executed commands to start fresh
self.executed_commands = []
@@ -205,7 +180,7 @@ class TestGitHandler(unittest.TestCase):
verify_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager rev-parse --verify')
if cmd.startswith('git rev-parse --verify')
]
# Should have tried origin/new-local-branch first (which doesn't exist)
@@ -218,9 +193,7 @@ class TestGitHandler(unittest.TestCase):
self.assertTrue(ref == 'origin/main' or 'merge-base' in ref)
# Verify the ref exists
result = self._execute_command(
f'git --no-pager rev-parse --verify {ref}', self.local_dir
)
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
self.assertEqual(result.exit_code, 0)
def test_get_valid_ref_without_origin(self):
@@ -230,21 +203,15 @@ class TestGitHandler(unittest.TestCase):
os.makedirs(no_origin_dir, exist_ok=True)
# Initialize git repo without origin
self._execute_command('git --no-pager init', no_origin_dir)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", no_origin_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", no_origin_dir
)
self._execute_command('git init', no_origin_dir)
self._execute_command("git config user.email 'test@example.com'", no_origin_dir)
self._execute_command("git config user.name 'Test User'", no_origin_dir)
# Create a file and commit it
with open(os.path.join(no_origin_dir, 'file1.txt'), 'w') as f:
f.write('Content in repo without origin')
self._execute_command('git --no-pager add file1.txt', no_origin_dir)
self._execute_command(
"git --no-pager commit -m 'Initial commit'", no_origin_dir
)
self._execute_command('git add file1.txt', no_origin_dir)
self._execute_command("git commit -m 'Initial commit'", no_origin_dir)
# Create a custom GitHandler with a modified _get_default_branch method for this test
class TestGitHandler(GitHandler):
@@ -267,20 +234,19 @@ class TestGitHandler(unittest.TestCase):
# Verify that git commands were executed
self.assertTrue(
any(
cmd.startswith('git --no-pager rev-parse --verify')
cmd.startswith('git rev-parse --verify')
for cmd, _ in self.executed_commands
)
)
# Should have fallen back to the empty tree ref
self.assertEqual(
ref,
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)',
ref, '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
)
# Verify the ref exists (the empty tree ref always exists)
result = self._execute_command(
'git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
'git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
no_origin_dir,
)
self.assertEqual(result.exit_code, 0)
@@ -292,9 +258,7 @@ class TestGitHandler(unittest.TestCase):
# Should have called _get_valid_ref and then git show
show_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager show')
cmd for cmd, _ in self.executed_commands if cmd.startswith('git show')
]
self.assertTrue(any('file1.txt' in cmd for cmd in show_commands))
@@ -313,7 +277,7 @@ class TestGitHandler(unittest.TestCase):
# Let's create a new file to ensure it shows up in the diff
with open(os.path.join(self.local_dir, 'new_file.txt'), 'w') as f:
f.write('New file content')
self._execute_command('git --no-pager add new_file.txt', self.local_dir)
self._execute_command('git add new_file.txt', self.local_dir)
files = self.git_handler._get_changed_files()
self.assertTrue(files)
@@ -327,9 +291,7 @@ class TestGitHandler(unittest.TestCase):
# Should have called _get_valid_ref and then git diff
diff_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager diff')
cmd for cmd, _ in self.executed_commands if cmd.startswith('git diff')
]
self.assertTrue(diff_commands)
@@ -347,7 +309,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager ls-files --others --exclude-standard'
cmd == 'git ls-files --others --exclude-standard'
for cmd, _ in self.executed_commands
)
)
@@ -361,7 +323,7 @@ class TestGitHandler(unittest.TestCase):
# Create a new file and stage it
with open(os.path.join(self.local_dir, 'new_file2.txt'), 'w') as f:
f.write('New file 2 content')
self._execute_command('git --no-pager add new_file2.txt', self.local_dir)
self._execute_command('git add new_file2.txt', self.local_dir)
changes = self.git_handler.get_git_changes()
self.assertIsNotNone(changes)
@@ -391,7 +353,7 @@ class TestGitHandler(unittest.TestCase):
)
self.assertTrue(
any(
'git --no-pager show' in cmd and 'file1.txt' in cmd
'git show' in cmd and 'file1.txt' in cmd
for cmd, _ in self.executed_commands
)
)

View File

@@ -1,86 +0,0 @@
from unittest.mock import AsyncMock, patch
import pytest
from openhands.integrations.service_types import GitService
from openhands.server.routes.mcp import get_convo_link
from openhands.server.types import AppMode
@pytest.mark.asyncio
async def test_get_convo_link_non_saas_mode():
"""Test get_convo_link in non-SAAS mode."""
# Mock GitService
mock_service = AsyncMock(spec=GitService)
# Test with non-SAAS mode
with patch('openhands.server.routes.mcp.server_config') as mock_config:
mock_config.app_mode = AppMode.OSS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body='Original body'
)
# Verify the result
assert result == 'Original body'
# Verify that get_user was not called
mock_service.get_user.assert_not_called()
@pytest.mark.asyncio
async def test_get_convo_link_saas_mode():
"""Test get_convo_link in SAAS mode."""
# Mock GitService and user
mock_service = AsyncMock(spec=GitService)
mock_user = AsyncMock()
mock_user.login = 'testuser'
mock_service.get_user.return_value = mock_user
# Test with SAAS mode
with (
patch('openhands.server.routes.mcp.server_config') as mock_config,
patch('openhands.server.routes.mcp.CONVO_URL', 'https://test.example.com/{}'),
):
mock_config.app_mode = AppMode.SAAS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body='Original body'
)
# Verify the result
expected_link = '@testuser can click here to [continue refining the PR](https://test.example.com/test-convo-id)'
assert result == f'Original body\n\n{expected_link}'
# Verify that get_user was called
mock_service.get_user.assert_called_once()
@pytest.mark.asyncio
async def test_get_convo_link_empty_body():
"""Test get_convo_link with an empty body."""
# Mock GitService and user
mock_service = AsyncMock(spec=GitService)
mock_user = AsyncMock()
mock_user.login = 'testuser'
mock_service.get_user.return_value = mock_user
# Test with SAAS mode and empty body
with (
patch('openhands.server.routes.mcp.server_config') as mock_config,
patch('openhands.server.routes.mcp.CONVO_URL', 'https://test.example.com/{}'),
):
mock_config.app_mode = AppMode.SAAS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body=''
)
# Verify the result
expected_link = '@testuser can click here to [continue refining the PR](https://test.example.com/test-convo-id)'
assert result == f'\n\n{expected_link}'
# Verify that get_user was called
mock_service.get_user.assert_called_once()

View File

@@ -28,6 +28,9 @@ 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

@@ -234,10 +234,7 @@ async def test_clone_or_init_repo_no_repo_with_user_id(temp_dir):
# Verify that git init was called
assert len(runtime.run_action_calls) == 1
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert (
runtime.run_action_calls[0].command
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
)
assert runtime.run_action_calls[0].command == 'git init'
assert result == ''
@@ -258,10 +255,7 @@ async def test_clone_or_init_repo_no_repo_no_user_id_no_workspace_base(temp_dir)
# Verify that git init was called
assert len(runtime.run_action_calls) == 1
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert (
runtime.run_action_calls[0].command
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
)
assert runtime.run_action_calls[0].command == 'git init'
assert result == ''

View File

@@ -27,6 +27,9 @@ 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

@@ -167,7 +167,6 @@ async def test_add_to_local_event_stream():
@pytest.mark.asyncio
async def test_cleanup_session_connections():
sio = get_mock_sio()
sio.disconnect = AsyncMock() # Mock the disconnect method
async with StandaloneConversationManager(
sio, OpenHandsConfig(), InMemoryFileStore(), MonitoringListener()
) as conversation_manager:
@@ -182,7 +181,6 @@ async def test_cleanup_session_connections():
await conversation_manager._close_session('session1')
# Check that connections were removed from the dictionary
remaining_connections = conversation_manager._local_connection_id_to_session_id
assert 'conn1' not in remaining_connections
assert 'conn2' not in remaining_connections
@@ -190,8 +188,3 @@ async def test_cleanup_session_connections():
assert 'conn4' in remaining_connections
assert remaining_connections['conn3'] == 'session2'
assert remaining_connections['conn4'] == 'session2'
# Check that disconnect was called for each connection
assert sio.disconnect.await_count == 2
sio.disconnect.assert_any_call('conn1')
sio.disconnect.assert_any_call('conn2')