mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
15 Commits
test-redir
...
feature/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1484ecf3 | ||
|
|
33c80d816e | ||
|
|
c164063d19 | ||
|
|
30f09f4aec | ||
|
|
cb87340cb8 | ||
|
|
15886acc3f | ||
|
|
c64eef19df | ||
|
|
be0bb3f388 | ||
|
|
c020268f5b | ||
|
|
aa54a25241 | ||
|
|
0813c113f0 | ||
|
|
19fcf427ba | ||
|
|
336b22bea4 | ||
|
|
959268b45a | ||
|
|
309c086976 |
4
.github/workflows/ghcr-build.yml
vendored
4
.github/workflows/ghcr-build.yml
vendored
@@ -313,6 +313,8 @@ 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:
|
||||
@@ -378,6 +380,8 @@ 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
|
||||
|
||||
6
.github/workflows/py-unit-tests.yml
vendored
6
.github/workflows/py-unit-tests.yml
vendored
@@ -74,5 +74,11 @@ 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"
|
||||
|
||||
|
||||
107
docs/usage/team-cli.mdx
Normal file
107
docs/usage/team-cli.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Team CLI
|
||||
---
|
||||
|
||||
# OpenHands Team CLI
|
||||
|
||||
The Team CLI provides a command-line interface for interacting with the OpenHands HTTP and WebSocket APIs. It allows you to create conversations, list existing conversations, and join conversations to interact with the agent.
|
||||
|
||||
## Getting Started
|
||||
|
||||
To use the Team CLI, you need to have OpenHands installed. You can then use the `team` command to access the Team CLI:
|
||||
|
||||
```bash
|
||||
openhands team [command] [options]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The Team CLI uses the following environment variables for configuration:
|
||||
|
||||
- `OPENHANDS_API_URL`: The base URL for the OpenHands API (default: `https://staging.all-hands.dev`)
|
||||
- `OPENHANDS_API_KEY`: The API key for authentication (if required)
|
||||
|
||||
You can also specify these values using command-line options:
|
||||
|
||||
```bash
|
||||
openhands team --url https://app.all-hands.dev --api-key your-api-key [command] [options]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### List Conversations
|
||||
|
||||
List all available conversations:
|
||||
|
||||
```bash
|
||||
openhands team list [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-l, --limit`: Maximum number of conversations to list (default: 20)
|
||||
|
||||
### Create a Conversation
|
||||
|
||||
Create a new conversation:
|
||||
|
||||
```bash
|
||||
openhands team create [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-r, --repository`: Repository name (format: owner/repo)
|
||||
- `-g, --git-provider`: Git provider (github or gitlab)
|
||||
- `-b, --branch`: Branch name
|
||||
- `-m, --message`: Initial user message
|
||||
- `-i, --instructions`: Conversation instructions
|
||||
- `-j, --join`: Join the conversation after creation
|
||||
|
||||
### Join a Conversation
|
||||
|
||||
Join an existing conversation:
|
||||
|
||||
```bash
|
||||
openhands team join [conversation_id]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
List all conversations:
|
||||
|
||||
```bash
|
||||
openhands team list
|
||||
```
|
||||
|
||||
Create a new conversation with a GitHub repository:
|
||||
|
||||
```bash
|
||||
openhands team create -r All-Hands-AI/OpenHands -m "Help me understand the codebase"
|
||||
```
|
||||
|
||||
Create a conversation and join it immediately:
|
||||
|
||||
```bash
|
||||
openhands team create -m "Let's build a web app" -j
|
||||
```
|
||||
|
||||
Join an existing conversation:
|
||||
|
||||
```bash
|
||||
openhands team join abc123def456
|
||||
```
|
||||
|
||||
## Using with a Remote Server
|
||||
|
||||
To use the Team CLI with a remote OpenHands server:
|
||||
|
||||
```bash
|
||||
export OPENHANDS_API_URL="https://app.all-hands.dev"
|
||||
export OPENHANDS_API_KEY="your-api-key"
|
||||
openhands team list
|
||||
```
|
||||
|
||||
Or specify the URL and API key directly:
|
||||
|
||||
```bash
|
||||
openhands team --url https://app.all-hands.dev --api-key your-api-key list
|
||||
```
|
||||
@@ -6,21 +6,6 @@ import { renderWithProviders } from "test-utils";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import * as useSettingsModule from "#/hooks/query/use-settings";
|
||||
|
||||
// Mock the useSettings hook
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -35,7 +20,6 @@ vi.mock("react-i18next", async () => {
|
||||
"SETTINGS$NAV_CREDITS": "Credits",
|
||||
"SETTINGS$NAV_API_KEYS": "API Keys",
|
||||
"SETTINGS$NAV_LLM": "LLM",
|
||||
"SETTINGS$NAV_USER": "User",
|
||||
"SETTINGS$TITLE": "Settings"
|
||||
};
|
||||
return translations[key] || key;
|
||||
@@ -63,10 +47,6 @@ describe("Settings Billing", () => {
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -236,6 +236,26 @@ 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
|
||||
*/
|
||||
|
||||
@@ -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$CONNECTED)); // Using STATUS$CONNECTED instead of STATUS$CONNECTING
|
||||
setStatusMessage(t(I18nKey.STATUS$WEBSOCKET_CLOSED));
|
||||
setIndicatorColor(IndicatorColor.RED);
|
||||
} else {
|
||||
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
|
||||
/**
|
||||
* A component that restricts access to routes based on email verification status.
|
||||
* If EMAIL_VERIFIED is false, only allows access to the /settings/user page.
|
||||
*/
|
||||
export function EmailVerificationGuard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
React.useEffect(() => {
|
||||
// If settings are still loading, don't do anything yet
|
||||
if (isLoading) return;
|
||||
|
||||
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
|
||||
if (settings?.EMAIL_VERIFIED === false) {
|
||||
// Allow access to /settings/user but redirect from any other page
|
||||
if (pathname !== "/settings/user") {
|
||||
navigate("/settings/user", { replace: true });
|
||||
}
|
||||
}
|
||||
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -69,21 +69,16 @@ export function Sidebar() {
|
||||
<div className="flex items-center justify-center">
|
||||
<AllHandsLogoButton />
|
||||
</div>
|
||||
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<NewProjectButton />
|
||||
<ConversationPanelButton
|
||||
isOpen={conversationPanelIsOpen}
|
||||
onClick={() =>
|
||||
settings?.EMAIL_VERIFIED === false
|
||||
? null
|
||||
: setConversationPanelIsOpen((prev) => !prev)
|
||||
}
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
<DocsButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<SettingsButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<DocsButton />
|
||||
<SettingsButton />
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
|
||||
@@ -8,13 +8,11 @@ import { cn } from "#/utils/utils";
|
||||
interface ConversationPanelButtonProps {
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationPanelButton({
|
||||
isOpen,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: ConversationPanelButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -24,14 +22,10 @@ export function ConversationPanelButton({
|
||||
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
|
||||
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FaListUl
|
||||
size={22}
|
||||
className={cn(
|
||||
isOpen ? "text-white" : "text-[#9099AC]",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
|
||||
/>
|
||||
</TooltipButton>
|
||||
);
|
||||
|
||||
@@ -3,24 +3,15 @@ import DocsIcon from "#/icons/academy.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
interface DocsButtonProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DocsButton({ disabled = false }: DocsButtonProps) {
|
||||
export function DocsButton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipButton
|
||||
tooltip={t(I18nKey.SIDEBAR$DOCS)}
|
||||
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
|
||||
href="https://docs.all-hands.dev"
|
||||
disabled={disabled}
|
||||
>
|
||||
<DocsIcon
|
||||
width={28}
|
||||
height={28}
|
||||
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
|
||||
/>
|
||||
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import PlusIcon from "#/icons/plus.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
interface NewProjectButtonProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
|
||||
export function NewProjectButton() {
|
||||
const { t } = useTranslation();
|
||||
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
|
||||
return (
|
||||
@@ -16,7 +12,6 @@ export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
|
||||
ariaLabel={startNewProject}
|
||||
navLinkTo="/"
|
||||
testId="new-project-button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
|
||||
@@ -5,13 +5,9 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsButton({
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: SettingsButtonProps) {
|
||||
export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -21,7 +17,6 @@ export function SettingsButton({
|
||||
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
|
||||
onClick={onClick}
|
||||
navLinkTo="/settings"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingsIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface TooltipButtonProps {
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TooltipButton({
|
||||
@@ -24,10 +23,9 @@ export function TooltipButton({
|
||||
ariaLabel,
|
||||
testId,
|
||||
className,
|
||||
disabled = false,
|
||||
}: TooltipButtonProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick && !disabled) {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -39,12 +37,7 @@ export function TooltipButton({
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"hover:opacity-80",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
className={cn("hover:opacity-80", className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -52,7 +45,7 @@ export function TooltipButton({
|
||||
|
||||
let content;
|
||||
|
||||
if (navLinkTo && !disabled) {
|
||||
if (navLinkTo) {
|
||||
content = (
|
||||
<NavLink
|
||||
to={navLinkTo}
|
||||
@@ -70,24 +63,7 @@ export function TooltipButton({
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
} else if (navLinkTo && disabled) {
|
||||
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
|
||||
content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"text-[#9099AC]",
|
||||
"opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
} else if (href && !disabled) {
|
||||
} else if (href) {
|
||||
content = (
|
||||
<a
|
||||
href={href}
|
||||
@@ -100,19 +76,6 @@ export function TooltipButton({
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
} else if (href && disabled) {
|
||||
// If disabled and has href, render a button that looks like a link but doesn't navigate
|
||||
content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
className={cn("opacity-50 cursor-not-allowed", className)}
|
||||
disabled
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
content = buttonContent;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,8 @@ export function WsClientProvider({
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const messageRateHandler = useRate({ threshold: 250 });
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: conversation, refetch: refetchConversation } =
|
||||
useActiveConversation();
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
if (!sioRef.current) {
|
||||
@@ -269,14 +270,11 @@ export function WsClientProvider({
|
||||
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
|
||||
setErrorMessage(
|
||||
hasValidMessageProperty(data)
|
||||
? data.message
|
||||
: "The WebSocket connection was closed.",
|
||||
);
|
||||
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
|
||||
}
|
||||
|
||||
function handleError(data: unknown) {
|
||||
// set status
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
|
||||
@@ -285,6 +283,9 @@ export function WsClientProvider({
|
||||
? data.message
|
||||
: "An unknown error occurred on the WebSocket connection.",
|
||||
);
|
||||
|
||||
// check if something went wrong with the conversation.
|
||||
refetchConversation();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -300,12 +301,19 @@ export function WsClientProvider({
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
}
|
||||
if (!conversation || conversation.status === "STARTING") {
|
||||
if (
|
||||
!conversation ||
|
||||
["STOPPED", "STARTING"].includes(conversation.status)
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -9,7 +9,7 @@ export const useActiveConversation = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const userConversation = useUserConversation(conversationId, (query) => {
|
||||
if (query.state.data?.status === "STARTING") {
|
||||
return 2000; // 2 seconds
|
||||
return 3000; // 3 seconds
|
||||
}
|
||||
return FIVE_MINUTES;
|
||||
});
|
||||
|
||||
@@ -27,8 +27,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
apiSettings.enable_proactive_conversation_starters,
|
||||
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
|
||||
SEARCH_API_KEY: apiSettings.search_api_key || "",
|
||||
EMAIL: apiSettings.email || "",
|
||||
EMAIL_VERIFIED: apiSettings.email_verified,
|
||||
|
||||
MCP_CONFIG: apiSettings.mcp_config,
|
||||
IS_NEW_USER: false,
|
||||
};
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
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]);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
// 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",
|
||||
@@ -555,18 +556,4 @@ export enum I18nKey {
|
||||
TIPS$PROTIP = "TIPS$PROTIP",
|
||||
FEEDBACK$SUBMITTING_LABEL = "FEEDBACK$SUBMITTING_LABEL",
|
||||
FEEDBACK$SUBMITTING_MESSAGE = "FEEDBACK$SUBMITTING_MESSAGE",
|
||||
SETTINGS$NAV_USER = "SETTINGS$NAV_USER",
|
||||
SETTINGS$USER_TITLE = "SETTINGS$USER_TITLE",
|
||||
SETTINGS$USER_EMAIL = "SETTINGS$USER_EMAIL",
|
||||
SETTINGS$USER_EMAIL_LOADING = "SETTINGS$USER_EMAIL_LOADING",
|
||||
SETTINGS$SAVE = "SETTINGS$SAVE",
|
||||
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
|
||||
SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY = "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY",
|
||||
SETTINGS$FAILED_TO_SAVE_EMAIL = "SETTINGS$FAILED_TO_SAVE_EMAIL",
|
||||
SETTINGS$SENDING = "SETTINGS$SENDING",
|
||||
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
|
||||
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
|
||||
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
|
||||
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
|
||||
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
|
||||
}
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
{
|
||||
"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": "ゼロから始める",
|
||||
@@ -8878,229 +8894,5 @@
|
||||
"tr": "Geri bildirim gönderiliyor, lütfen bekleyin...",
|
||||
"de": "Feedback senden, bitte warten...",
|
||||
"uk": "Відправляємо відгук, будь ласка, почекайте..."
|
||||
},
|
||||
"SETTINGS$NAV_USER": {
|
||||
"en": "User",
|
||||
"ja": "ユーザー",
|
||||
"zh-CN": "用户",
|
||||
"zh-TW": "用戶",
|
||||
"ko-KR": "사용자",
|
||||
"no": "Bruker",
|
||||
"it": "Utente",
|
||||
"pt": "Usuário",
|
||||
"es": "Usuario",
|
||||
"ar": "المستخدم",
|
||||
"fr": "Utilisateur",
|
||||
"tr": "Kullanıcı",
|
||||
"de": "Benutzer",
|
||||
"uk": "Користувач"
|
||||
},
|
||||
"SETTINGS$USER_TITLE": {
|
||||
"en": "User Information",
|
||||
"ja": "ユーザー情報",
|
||||
"zh-CN": "用户信息",
|
||||
"zh-TW": "用戶信息",
|
||||
"ko-KR": "사용자 정보",
|
||||
"no": "Brukerinformasjon",
|
||||
"it": "Informazioni utente",
|
||||
"pt": "Informações do usuário",
|
||||
"es": "Información del usuario",
|
||||
"ar": "معلومات المستخدم",
|
||||
"fr": "Informations utilisateur",
|
||||
"tr": "Kullanıcı Bilgileri",
|
||||
"de": "Benutzerinformationen",
|
||||
"uk": "Інформація про користувача"
|
||||
},
|
||||
"SETTINGS$USER_EMAIL": {
|
||||
"en": "Email",
|
||||
"ja": "メール",
|
||||
"zh-CN": "邮箱",
|
||||
"zh-TW": "郵箱",
|
||||
"ko-KR": "이메일",
|
||||
"no": "E-post",
|
||||
"it": "Email",
|
||||
"pt": "Email",
|
||||
"es": "Correo electrónico",
|
||||
"ar": "البريد الإلكتروني",
|
||||
"fr": "Email",
|
||||
"tr": "E-posta",
|
||||
"de": "E-Mail",
|
||||
"uk": "Електронна пошта"
|
||||
},
|
||||
"SETTINGS$USER_EMAIL_LOADING": {
|
||||
"en": "Loading...",
|
||||
"ja": "読み込み中...",
|
||||
"zh-CN": "加载中...",
|
||||
"zh-TW": "加載中...",
|
||||
"ko-KR": "로딩 중...",
|
||||
"no": "Laster...",
|
||||
"it": "Caricamento...",
|
||||
"pt": "Carregando...",
|
||||
"es": "Cargando...",
|
||||
"ar": "جار التحميل...",
|
||||
"fr": "Chargement...",
|
||||
"tr": "Yükleniyor...",
|
||||
"de": "Wird geladen...",
|
||||
"uk": "Завантаження..."
|
||||
},
|
||||
"SETTINGS$SAVE": {
|
||||
"en": "Save",
|
||||
"ja": "保存",
|
||||
"zh-CN": "保存",
|
||||
"zh-TW": "儲存",
|
||||
"ko-KR": "저장",
|
||||
"no": "Lagre",
|
||||
"it": "Salva",
|
||||
"pt": "Salvar",
|
||||
"es": "Guardar",
|
||||
"ar": "حفظ",
|
||||
"fr": "Enregistrer",
|
||||
"tr": "Kaydet",
|
||||
"de": "Speichern",
|
||||
"uk": "Зберегти"
|
||||
},
|
||||
"SETTINGS$EMAIL_SAVED_SUCCESSFULLY": {
|
||||
"en": "Email saved successfully",
|
||||
"ja": "メールが正常に保存されました",
|
||||
"zh-CN": "邮箱保存成功",
|
||||
"zh-TW": "郵箱儲存成功",
|
||||
"ko-KR": "이메일이 성공적으로 저장되었습니다",
|
||||
"no": "E-post lagret",
|
||||
"it": "Email salvata con successo",
|
||||
"pt": "Email salvo com sucesso",
|
||||
"es": "Correo electrónico guardado con éxito",
|
||||
"ar": "تم حفظ البريد الإلكتروني بنجاح",
|
||||
"fr": "Email enregistré avec succès",
|
||||
"tr": "E-posta başarıyla kaydedildi",
|
||||
"de": "E-Mail erfolgreich gespeichert",
|
||||
"uk": "Електронну пошту успішно збережено"
|
||||
},
|
||||
"SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": {
|
||||
"en": "Your email has been verified successfully!",
|
||||
"ja": "メールアドレスの確認が完了しました!",
|
||||
"zh-CN": "您的邮箱已成功验证!",
|
||||
"zh-TW": "您的郵箱已成功驗證!",
|
||||
"ko-KR": "이메일이 성공적으로 인증되었습니다!",
|
||||
"no": "E-posten din er bekreftet!",
|
||||
"it": "La tua email è stata verificata con successo!",
|
||||
"pt": "Seu email foi verificado com sucesso!",
|
||||
"es": "¡Tu correo electrónico ha sido verificado con éxito!",
|
||||
"ar": "تم التحقق من بريدك الإلكتروني بنجاح!",
|
||||
"fr": "Votre email a été vérifié avec succès !",
|
||||
"tr": "E-postanız başarıyla doğrulandı!",
|
||||
"de": "Ihre E-Mail wurde erfolgreich verifiziert!",
|
||||
"uk": "Вашу електронну пошту успішно підтверджено!"
|
||||
},
|
||||
"SETTINGS$FAILED_TO_SAVE_EMAIL": {
|
||||
"en": "Failed to save email",
|
||||
"ja": "メールの保存に失敗しました",
|
||||
"zh-CN": "保存邮箱失败",
|
||||
"zh-TW": "儲存郵箱失敗",
|
||||
"ko-KR": "이메일 저장 실패",
|
||||
"no": "Kunne ikke lagre e-post",
|
||||
"it": "Impossibile salvare l'email",
|
||||
"pt": "Falha ao salvar email",
|
||||
"es": "Error al guardar el correo electrónico",
|
||||
"ar": "فشل في حفظ البريد الإلكتروني",
|
||||
"fr": "Échec de l'enregistrement de l'email",
|
||||
"tr": "E-posta kaydedilemedi",
|
||||
"de": "E-Mail konnte nicht gespeichert werden",
|
||||
"uk": "Не вдалося зберегти електронну пошту"
|
||||
},
|
||||
"SETTINGS$SENDING": {
|
||||
"en": "Sending",
|
||||
"ja": "送信中",
|
||||
"zh-CN": "发送中",
|
||||
"zh-TW": "發送中",
|
||||
"ko-KR": "전송 중",
|
||||
"no": "Sender",
|
||||
"it": "Invio in corso",
|
||||
"pt": "Enviando",
|
||||
"es": "Enviando",
|
||||
"ar": "جاري الإرسال",
|
||||
"fr": "Envoi en cours",
|
||||
"tr": "Gönderiliyor",
|
||||
"de": "Wird gesendet",
|
||||
"uk": "Надсилання"
|
||||
},
|
||||
"SETTINGS$VERIFICATION_EMAIL_SENT": {
|
||||
"en": "Verification email sent",
|
||||
"ja": "確認メールを送信しました",
|
||||
"zh-CN": "验证邮件已发送",
|
||||
"zh-TW": "驗證郵件已發送",
|
||||
"ko-KR": "인증 이메일이 전송되었습니다",
|
||||
"no": "Bekreftelsese-post sendt",
|
||||
"it": "Email di verifica inviata",
|
||||
"pt": "Email de verificação enviado",
|
||||
"es": "Correo de verificación enviado",
|
||||
"ar": "تم إرسال بريد التحقق",
|
||||
"fr": "Email de vérification envoyé",
|
||||
"tr": "Doğrulama e-postası gönderildi",
|
||||
"de": "Bestätigungs-E-Mail gesendet",
|
||||
"uk": "Лист підтвердження надіслано"
|
||||
},
|
||||
"SETTINGS$EMAIL_VERIFICATION_REQUIRED": {
|
||||
"en": "You must verify your email address before using All Hands",
|
||||
"ja": "All Handsを使用する前にメールアドレスを確認する必要があります",
|
||||
"zh-CN": "使用All Hands前,您必须验证您的电子邮件地址",
|
||||
"zh-TW": "使用All Hands前,您必須驗證您的電子郵件地址",
|
||||
"ko-KR": "All Hands를 사용하기 전에 이메일 주소를 확인해야 합니다",
|
||||
"no": "Du må bekrefte e-postadressen din før du bruker All Hands",
|
||||
"it": "Devi verificare il tuo indirizzo email prima di utilizzare All Hands",
|
||||
"pt": "Você deve verificar seu endereço de e-mail antes de usar o All Hands",
|
||||
"es": "Debe verificar su dirección de correo electrónico antes de usar All Hands",
|
||||
"ar": "يجب عليك التحقق من عنوان بريدك الإلكتروني قبل استخدام All Hands",
|
||||
"fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands",
|
||||
"tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor",
|
||||
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
|
||||
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
|
||||
},
|
||||
"SETTINGS$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": "Не вдалося повторно надіслати лист підтвердження"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ export default [
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/llm-settings.tsx"),
|
||||
route("mcp", "routes/mcp-settings.tsx"),
|
||||
route("user", "routes/user-settings.tsx"),
|
||||
route("git", "routes/git-settings.tsx"),
|
||||
route("app", "routes/app-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
|
||||
@@ -43,7 +43,7 @@ function AppContent() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = useSettings();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation, isFetched } = useActiveConversation();
|
||||
const { data: conversation, isFetched, refetch } = useActiveConversation();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
@@ -61,8 +61,13 @@ 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, isFetched, isAuthed]);
|
||||
}, [conversation?.conversation_id, isFetched, isAuthed]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(clearTerminal());
|
||||
|
||||
@@ -24,9 +24,7 @@ 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();
|
||||
@@ -94,9 +92,6 @@ 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) {
|
||||
@@ -209,9 +204,7 @@ export default function MainApp() {
|
||||
id="root-outlet"
|
||||
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
|
||||
>
|
||||
<EmailVerificationGuard>
|
||||
<Outlet />
|
||||
</EmailVerificationGuard>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{renderAuthModal && (
|
||||
|
||||
@@ -15,7 +15,6 @@ function SettingsScreen() {
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
|
||||
const saasNavItems = [
|
||||
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
@@ -34,11 +33,10 @@ function SettingsScreen() {
|
||||
React.useEffect(() => {
|
||||
if (isSaas) {
|
||||
if (pathname === "/settings") {
|
||||
navigate("/settings/user");
|
||||
navigate("/settings/git");
|
||||
}
|
||||
} else {
|
||||
const noEnteringPaths = [
|
||||
"/settings/user",
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
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;
|
||||
@@ -19,8 +19,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
|
||||
SEARCH_API_KEY: "",
|
||||
IS_NEW_USER: true,
|
||||
EMAIL: "",
|
||||
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
|
||||
MCP_CONFIG: {
|
||||
sse_servers: [],
|
||||
stdio_servers: [],
|
||||
|
||||
@@ -45,8 +45,6 @@ export type Settings = {
|
||||
SEARCH_API_KEY?: string;
|
||||
IS_NEW_USER?: boolean;
|
||||
MCP_CONFIG?: MCPConfig;
|
||||
EMAIL?: string;
|
||||
EMAIL_VERIFIED?: boolean;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
@@ -70,8 +68,6 @@ export type ApiSettings = {
|
||||
sse_servers: (string | MCPSSEServer)[];
|
||||
stdio_servers: MCPStdioServer[];
|
||||
};
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
};
|
||||
|
||||
export type PostSettings = Settings & {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
@@ -454,6 +453,37 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
|
||||
# Check if team command is used
|
||||
if hasattr(args, 'command') and args.command == 'team':
|
||||
# Import and run the team CLI directly
|
||||
import sys
|
||||
|
||||
from openhands.cli.team import main as team_main
|
||||
|
||||
# Get arguments after 'team'
|
||||
team_args = []
|
||||
if len(sys.argv) > 2:
|
||||
# Pass all arguments after 'team'
|
||||
team_args = sys.argv[2:]
|
||||
|
||||
if not team_args:
|
||||
# If no additional arguments, show help message
|
||||
print('OpenHands Team CLI')
|
||||
print('=================')
|
||||
print('To use the team CLI, run one of the following commands:')
|
||||
print(' openhands team list - List all conversations')
|
||||
print(' openhands team create - Create a new conversation')
|
||||
print(' openhands team join <id> - Join an existing conversation')
|
||||
print()
|
||||
print("For more information, run 'openhands team --help'")
|
||||
return
|
||||
|
||||
# Run the team CLI with the arguments
|
||||
team_main(team_args)
|
||||
return
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
|
||||
549
openhands/cli/team.py
Normal file
549
openhands/cli/team.py
Normal file
@@ -0,0 +1,549 @@
|
||||
"""Team CLI interface for OpenHands.
|
||||
|
||||
This module provides a CLI interface for interacting with the OpenHands HTTP and WebSocket APIs.
|
||||
It allows creating conversations and showing the current list of conversations/statuses.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiohttp
|
||||
import socketio
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.shortcuts import clear
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from openhands.cli.tui import (
|
||||
display_banner,
|
||||
display_event,
|
||||
display_welcome_message,
|
||||
read_prompt_input,
|
||||
)
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization import event_from_dict, event_to_dict
|
||||
|
||||
|
||||
class TeamClient:
|
||||
"""Client for interacting with the OpenHands HTTP and WebSocket APIs."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: Optional[str] = None):
|
||||
"""Initialize the TeamClient.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for the OpenHands API.
|
||||
api_key: Optional API key for authentication.
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.sio = socketio.AsyncClient()
|
||||
self.console = Console()
|
||||
self.headers = {}
|
||||
if api_key:
|
||||
self.headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
async def list_conversations(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""List conversations.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of conversations to return.
|
||||
|
||||
Returns:
|
||||
List of conversation objects.
|
||||
"""
|
||||
async with aiohttp.ClientSession(headers=self.headers) as session:
|
||||
async with session.get(
|
||||
f'{self.base_url}/api/conversations?limit={limit}'
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise Exception(f'Failed to list conversations: {error_text}')
|
||||
data = await response.json()
|
||||
return data.get('results', [])
|
||||
|
||||
async def create_conversation(
|
||||
self,
|
||||
repository: Optional[str] = None,
|
||||
git_provider: Optional[str] = None,
|
||||
selected_branch: Optional[str] = None,
|
||||
initial_user_msg: Optional[str] = None,
|
||||
conversation_instructions: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Create a new conversation.
|
||||
|
||||
Args:
|
||||
repository: Optional repository name (owner/repo).
|
||||
git_provider: Optional git provider (github or gitlab).
|
||||
selected_branch: Optional branch name.
|
||||
initial_user_msg: Optional initial user message.
|
||||
conversation_instructions: Optional conversation instructions.
|
||||
|
||||
Returns:
|
||||
The conversation ID.
|
||||
"""
|
||||
payload = {
|
||||
'repository': repository,
|
||||
'git_provider': git_provider,
|
||||
'selected_branch': selected_branch,
|
||||
'initial_user_msg': initial_user_msg,
|
||||
'conversation_instructions': conversation_instructions,
|
||||
}
|
||||
# Remove None values
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
|
||||
async with aiohttp.ClientSession(headers=self.headers) as session:
|
||||
async with session.post(
|
||||
f'{self.base_url}/api/conversations', json=payload
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise Exception(f'Failed to create conversation: {error_text}')
|
||||
data = await response.json()
|
||||
return data.get('conversation_id')
|
||||
|
||||
async def get_conversation(self, conversation_id: str) -> dict[str, Any]:
|
||||
"""Get conversation details.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID.
|
||||
|
||||
Returns:
|
||||
Conversation details.
|
||||
"""
|
||||
async with aiohttp.ClientSession(headers=self.headers) as session:
|
||||
async with session.get(
|
||||
f'{self.base_url}/api/conversations/{conversation_id}'
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise Exception(f'Failed to get conversation: {error_text}')
|
||||
return await response.json()
|
||||
|
||||
async def connect_to_conversation(
|
||||
self, conversation_id: str, latest_event_id: int = -1
|
||||
) -> None:
|
||||
"""Connect to a conversation via WebSocket.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID.
|
||||
latest_event_id: The latest event ID to start from.
|
||||
"""
|
||||
|
||||
# Set up event handlers
|
||||
@self.sio.event
|
||||
async def connect():
|
||||
self.console.print('[green]Connected to conversation[/green]')
|
||||
|
||||
@self.sio.event
|
||||
async def disconnect():
|
||||
self.console.print('[yellow]Disconnected from conversation[/yellow]')
|
||||
|
||||
@self.sio.event
|
||||
async def oh_event(data):
|
||||
event = event_from_dict(data)
|
||||
# Create a dummy config object to satisfy the type checker
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
|
||||
dummy_config = OpenHandsConfig()
|
||||
display_event(event, dummy_config)
|
||||
|
||||
# Connect to the WebSocket
|
||||
query = {
|
||||
'conversation_id': conversation_id,
|
||||
'latest_event_id': str(latest_event_id),
|
||||
}
|
||||
if self.api_key:
|
||||
query['session_api_key'] = self.api_key
|
||||
|
||||
await self.sio.connect(
|
||||
f'{self.base_url}',
|
||||
headers=self.headers,
|
||||
transports=['websocket'],
|
||||
socketio_path='socket.io',
|
||||
wait_timeout=10,
|
||||
query=query,
|
||||
)
|
||||
|
||||
async def send_message(self, message: str) -> None:
|
||||
"""Send a message to the conversation.
|
||||
|
||||
Args:
|
||||
message: The message to send.
|
||||
"""
|
||||
event = MessageAction(content=message)
|
||||
event_dict = event_to_dict(event)
|
||||
await self.sio.emit('oh_user_action', event_dict)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the WebSocket."""
|
||||
await self.sio.disconnect()
|
||||
|
||||
|
||||
async def list_conversations_cmd(client: TeamClient, args: argparse.Namespace) -> None:
|
||||
"""List conversations command.
|
||||
|
||||
Args:
|
||||
client: The TeamClient instance.
|
||||
args: Command line arguments.
|
||||
"""
|
||||
conversations = await client.list_conversations(limit=args.limit)
|
||||
|
||||
if not conversations:
|
||||
print('No conversations found.')
|
||||
return
|
||||
|
||||
table = Table(title='Conversations')
|
||||
table.add_column('ID', style='cyan')
|
||||
table.add_column('Title', style='green')
|
||||
table.add_column('Status', style='magenta')
|
||||
table.add_column('Repository', style='blue')
|
||||
table.add_column('Last Updated', style='yellow')
|
||||
table.add_column('Created', style='yellow')
|
||||
|
||||
for convo in conversations:
|
||||
# Format dates
|
||||
created_at = datetime.fromisoformat(convo['created_at'].replace('Z', '+00:00'))
|
||||
last_updated_at = datetime.fromisoformat(
|
||||
convo['last_updated_at'].replace('Z', '+00:00')
|
||||
)
|
||||
|
||||
created_str = created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
updated_str = last_updated_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Add row to table
|
||||
table.add_row(
|
||||
convo['conversation_id'],
|
||||
convo['title'],
|
||||
convo['status'],
|
||||
convo.get('selected_repository', ''),
|
||||
updated_str,
|
||||
created_str,
|
||||
)
|
||||
|
||||
client.console.print(table)
|
||||
|
||||
|
||||
async def create_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
|
||||
"""Create a conversation command.
|
||||
|
||||
Args:
|
||||
client: The TeamClient instance.
|
||||
args: Command line arguments.
|
||||
"""
|
||||
initial_message = args.message
|
||||
|
||||
# If no message provided, prompt for one
|
||||
if not initial_message:
|
||||
print_formatted_text(HTML('<green>Enter your initial message:</green>'))
|
||||
initial_message = input('> ')
|
||||
|
||||
try:
|
||||
conversation_id = await client.create_conversation(
|
||||
repository=args.repository,
|
||||
git_provider=args.git_provider,
|
||||
selected_branch=args.branch,
|
||||
initial_user_msg=initial_message,
|
||||
conversation_instructions=args.instructions,
|
||||
)
|
||||
|
||||
print_formatted_text(
|
||||
HTML(f'<green>Conversation created with ID: {conversation_id}</green>')
|
||||
)
|
||||
|
||||
if args.join:
|
||||
await join_conversation_cmd(
|
||||
client, argparse.Namespace(conversation_id=conversation_id)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error creating conversation: {str(e)}</red>'))
|
||||
|
||||
|
||||
async def join_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
|
||||
"""Join a conversation command.
|
||||
|
||||
Args:
|
||||
client: The TeamClient instance.
|
||||
args: Command line arguments.
|
||||
"""
|
||||
conversation_id = args.conversation_id
|
||||
|
||||
try:
|
||||
# Get conversation details
|
||||
conversation = await client.get_conversation(conversation_id)
|
||||
|
||||
# Clear screen and show banner
|
||||
clear()
|
||||
display_banner(session_id=conversation_id)
|
||||
|
||||
# Show conversation title
|
||||
title = conversation.get('title', 'Untitled Conversation')
|
||||
display_welcome_message(f'Joined conversation: {title}')
|
||||
|
||||
# Connect to the WebSocket
|
||||
await client.connect_to_conversation(conversation_id)
|
||||
|
||||
# Main conversation loop
|
||||
try:
|
||||
while True:
|
||||
next_message = await read_prompt_input(
|
||||
AgentState.AWAITING_USER_INPUT.value
|
||||
)
|
||||
|
||||
if not next_message.strip():
|
||||
continue
|
||||
|
||||
if next_message.lower() in ['exit', 'quit', '/exit', '/quit']:
|
||||
break
|
||||
|
||||
await client.send_message(next_message)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print('\nDisconnecting...')
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error joining conversation: {str(e)}</red>'))
|
||||
|
||||
|
||||
def get_base_url() -> str:
|
||||
"""Get the base URL for the OpenHands API.
|
||||
|
||||
Returns:
|
||||
The base URL.
|
||||
"""
|
||||
# Check environment variables first
|
||||
base_url = os.environ.get('OPENHANDS_API_URL')
|
||||
if base_url:
|
||||
return base_url
|
||||
|
||||
# Default to staging server
|
||||
return 'https://staging.all-hands.dev'
|
||||
|
||||
|
||||
def get_api_key() -> Optional[str]:
|
||||
"""Get the API key for authentication.
|
||||
|
||||
Returns:
|
||||
The API key, or None if not found.
|
||||
"""
|
||||
return os.environ.get('OPENHANDS_API_KEY')
|
||||
|
||||
|
||||
def setup_parser() -> argparse.ArgumentParser:
|
||||
"""Set up the argument parser for the team CLI.
|
||||
|
||||
Returns:
|
||||
The argument parser.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description='OpenHands Team CLI')
|
||||
parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
|
||||
# Server configuration
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
||||
|
||||
# List conversations command
|
||||
list_parser = subparsers.add_parser(
|
||||
'list',
|
||||
help='List conversations',
|
||||
description='List all available conversations',
|
||||
)
|
||||
list_parser.add_argument(
|
||||
'-l',
|
||||
'--limit',
|
||||
type=int,
|
||||
default=20,
|
||||
help='Maximum number of conversations to list',
|
||||
)
|
||||
# Add help formatter
|
||||
list_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
|
||||
# Create conversation command
|
||||
create_parser = subparsers.add_parser(
|
||||
'create',
|
||||
help='Create a new conversation',
|
||||
description='Create a new conversation with optional repository and message',
|
||||
)
|
||||
create_parser.add_argument(
|
||||
'-r', '--repository', help='Repository name (owner/repo)'
|
||||
)
|
||||
create_parser.add_argument(
|
||||
'-g', '--git-provider', help='Git provider (github or gitlab)'
|
||||
)
|
||||
create_parser.add_argument('-b', '--branch', help='Branch name')
|
||||
create_parser.add_argument('-m', '--message', help='Initial user message')
|
||||
create_parser.add_argument('-i', '--instructions', help='Conversation instructions')
|
||||
create_parser.add_argument(
|
||||
'-j', '--join', action='store_true', help='Join the conversation after creation'
|
||||
)
|
||||
# Add help formatter
|
||||
create_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
|
||||
# Join conversation command
|
||||
join_parser = subparsers.add_parser(
|
||||
'join',
|
||||
help='Join an existing conversation',
|
||||
description='Join an existing conversation by ID',
|
||||
)
|
||||
join_parser.add_argument('conversation_id', help='Conversation ID')
|
||||
# Add help formatter
|
||||
join_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
async def main_async(args: argparse.Namespace) -> None:
|
||||
"""Main async function for the team CLI.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
# Get base URL and API key
|
||||
base_url = args.url or get_base_url()
|
||||
api_key = args.api_key or get_api_key()
|
||||
|
||||
# Create client
|
||||
client = TeamClient(base_url, api_key)
|
||||
|
||||
# Run command
|
||||
if args.command == 'list':
|
||||
await list_conversations_cmd(client, args)
|
||||
elif args.command == 'create':
|
||||
await create_conversation_cmd(client, args)
|
||||
elif args.command == 'join':
|
||||
await join_conversation_cmd(client, args)
|
||||
else:
|
||||
print('No command specified. Use --help for usage information.')
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> None:
|
||||
"""Main function for the team CLI.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
parser = setup_parser()
|
||||
|
||||
# If no arguments provided, show help
|
||||
if not args or len(args) == 0:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# Special case for subcommand help
|
||||
if (
|
||||
len(args) >= 2
|
||||
and args[0] in ['list', 'create', 'join']
|
||||
and args[1] in ['-h', '--help']
|
||||
):
|
||||
# Create a new parser just for this subcommand
|
||||
if args[0] == 'list':
|
||||
subparser = argparse.ArgumentParser(
|
||||
description='List all available conversations',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-l',
|
||||
'--limit',
|
||||
type=int,
|
||||
default=20,
|
||||
help='Maximum number of conversations to list',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--api-key',
|
||||
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
|
||||
)
|
||||
subparser.print_help()
|
||||
return
|
||||
elif args[0] == 'create':
|
||||
subparser = argparse.ArgumentParser(
|
||||
description='Create a new conversation with optional repository and message',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-r',
|
||||
'--repository',
|
||||
help='Repository name (owner/repo)',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-g',
|
||||
'--git-provider',
|
||||
help='Git provider (github or gitlab)',
|
||||
)
|
||||
subparser.add_argument('-b', '--branch', help='Branch name')
|
||||
subparser.add_argument('-m', '--message', help='Initial user message')
|
||||
subparser.add_argument(
|
||||
'-i',
|
||||
'--instructions',
|
||||
help='Conversation instructions',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-j',
|
||||
'--join',
|
||||
action='store_true',
|
||||
help='Join the conversation after creation',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--api-key',
|
||||
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
|
||||
)
|
||||
subparser.print_help()
|
||||
return
|
||||
elif args[0] == 'join':
|
||||
subparser = argparse.ArgumentParser(
|
||||
description='Join an existing conversation by ID',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
subparser.add_argument('conversation_id', help='Conversation ID')
|
||||
subparser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--api-key',
|
||||
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
|
||||
)
|
||||
subparser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
parsed_args = parser.parse_args(args)
|
||||
|
||||
# If no command specified, show help
|
||||
if not parsed_args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# Run the command
|
||||
asyncio.run(main_async(parsed_args))
|
||||
except KeyboardInterrupt:
|
||||
print('\nOperation cancelled by user.')
|
||||
except Exception as e:
|
||||
print(f'Error: {str(e)}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
7
openhands/cli/team_cli.sh
Executable file
7
openhands/cli/team_cli.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the Python executable
|
||||
PYTHON_EXE=$(which python)
|
||||
|
||||
# Run the team CLI
|
||||
$PYTHON_EXE -m openhands.cli.team "$@"
|
||||
76
openhands/cli/team_create.py
Normal file
76
openhands/cli/team_create.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Create conversation command for the OpenHands Team CLI."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from openhands.cli.team import TeamClient
|
||||
|
||||
|
||||
def setup_parser() -> argparse.ArgumentParser:
|
||||
"""Set up the argument parser for the create command.
|
||||
|
||||
Returns:
|
||||
The argument parser.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Create a new conversation with optional repository and message',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument('-r', '--repository', help='Repository name (owner/repo)')
|
||||
parser.add_argument('-g', '--git-provider', help='Git provider (github or gitlab)')
|
||||
parser.add_argument('-b', '--branch', help='Branch name')
|
||||
parser.add_argument('-m', '--message', help='Initial user message')
|
||||
parser.add_argument('-i', '--instructions', help='Conversation instructions')
|
||||
parser.add_argument(
|
||||
'-j', '--join', action='store_true', help='Join the conversation after creation'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
async def create_conversation(args: argparse.Namespace) -> None:
|
||||
"""Create a conversation command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
# Create client
|
||||
client = TeamClient(args.url, args.api_key)
|
||||
|
||||
try:
|
||||
# Create conversation
|
||||
await client.create_conversation(
|
||||
repository=args.repository,
|
||||
git_provider=args.git_provider,
|
||||
selected_branch=args.branch,
|
||||
initial_user_msg=args.message,
|
||||
conversation_instructions=args.instructions,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'Error creating conversation: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> None:
|
||||
"""Main function for the create command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
parser = setup_parser()
|
||||
parsed_args = parser.parse_args(args)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(create_conversation(parsed_args))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
63
openhands/cli/team_join.py
Normal file
63
openhands/cli/team_join.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Join conversation command for the OpenHands Team CLI."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from openhands.cli.team import TeamClient, join_conversation_cmd
|
||||
|
||||
|
||||
def setup_parser() -> argparse.ArgumentParser:
|
||||
"""Set up the argument parser for the join command.
|
||||
|
||||
Returns:
|
||||
The argument parser.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Join an existing conversation by ID',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument('conversation_id', help='Conversation ID')
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
async def join_conversation(args: argparse.Namespace) -> None:
|
||||
"""Join a conversation command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
# Create client
|
||||
client = TeamClient(args.url, args.api_key)
|
||||
|
||||
try:
|
||||
# Join conversation
|
||||
await join_conversation_cmd(client, args)
|
||||
except Exception as e:
|
||||
print(f'Error joining conversation: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> None:
|
||||
"""Main function for the join command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
parser = setup_parser()
|
||||
parsed_args = parser.parse_args(args)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(join_conversation(parsed_args))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
69
openhands/cli/team_list.py
Normal file
69
openhands/cli/team_list.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""List conversations command for the OpenHands Team CLI."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from openhands.cli.team import TeamClient
|
||||
|
||||
|
||||
def setup_parser() -> argparse.ArgumentParser:
|
||||
"""Set up the argument parser for the list command.
|
||||
|
||||
Returns:
|
||||
The argument parser.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='List all available conversations',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
'-l',
|
||||
'--limit',
|
||||
type=int,
|
||||
default=20,
|
||||
help='Maximum number of conversations to list',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
async def list_conversations(args: argparse.Namespace) -> None:
|
||||
"""List conversations command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
# Create client
|
||||
client = TeamClient(args.url, args.api_key)
|
||||
|
||||
try:
|
||||
# List conversations
|
||||
await client.list_conversations(limit=args.limit)
|
||||
except Exception as e:
|
||||
print(f'Error listing conversations: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> None:
|
||||
"""Main function for the list command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
parser = setup_parser()
|
||||
parsed_args = parser.parse_args(args)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(list_conversations(parsed_args))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -744,13 +744,26 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
# Add team subcommand
|
||||
subparsers = parser.add_subparsers(dest='command')
|
||||
subparsers.add_parser(
|
||||
'team', help='Use team mode to interact with the OpenHands API'
|
||||
)
|
||||
# We'll handle the team subcommands separately
|
||||
return parser
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = get_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check if 'team' command is present
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'team':
|
||||
# Only parse known arguments, ignoring any team-specific arguments
|
||||
args, _ = parser.parse_known_args()
|
||||
else:
|
||||
# Parse all arguments normally
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
print(f'OpenHands version: {__version__}')
|
||||
|
||||
@@ -505,10 +505,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
)
|
||||
|
||||
# Return the HTML URL of the created PR
|
||||
if 'html_url' in response:
|
||||
return response['html_url']
|
||||
else:
|
||||
return f'PR created but URL not found in response: {response}'
|
||||
return response['html_url']
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -500,12 +500,8 @@ 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']
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,4 +15,3 @@ 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 }})`
|
||||
|
||||
@@ -9,4 +9,3 @@ 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 }})`
|
||||
|
||||
@@ -15,4 +15,3 @@ 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 }})`
|
||||
|
||||
@@ -9,4 +9,3 @@ 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 }})`
|
||||
|
||||
@@ -5,7 +5,3 @@ 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 }})`
|
||||
|
||||
@@ -65,7 +65,6 @@ 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
|
||||
@@ -254,12 +253,10 @@ class ActionExecutor:
|
||||
# If we get here, the browser is ready
|
||||
logger.debug('Browser is ready')
|
||||
|
||||
async def ainit(self):
|
||||
# bash needs to be initialized first
|
||||
logger.debug('Initializing bash session')
|
||||
def _create_bash_session(self, cwd: str | None = None):
|
||||
if sys.platform == 'win32':
|
||||
self.bash_session = WindowsPowershellSession( # type: ignore[name-defined]
|
||||
work_dir=self._initial_cwd,
|
||||
return WindowsPowershellSession( # type: ignore[name-defined]
|
||||
work_dir=cwd or self._initial_cwd,
|
||||
username=self.username,
|
||||
no_change_timeout_seconds=int(
|
||||
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
|
||||
@@ -267,15 +264,21 @@ class ActionExecutor:
|
||||
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
|
||||
)
|
||||
else:
|
||||
self.bash_session = BashSession(
|
||||
work_dir=self._initial_cwd,
|
||||
bash_session = BashSession(
|
||||
work_dir=cwd or 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,
|
||||
)
|
||||
self.bash_session.initialize()
|
||||
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()
|
||||
logger.debug('Bash session initialized')
|
||||
|
||||
# Start browser initialization in the background
|
||||
@@ -388,18 +391,11 @@ class ActionExecutor:
|
||||
self, action: CmdRunAction
|
||||
) -> CmdOutputObservation | ErrorObservation:
|
||||
try:
|
||||
bash_session = self.bash_session
|
||||
if action.is_static:
|
||||
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)
|
||||
bash_session = self._create_bash_session(action.cwd)
|
||||
assert bash_session is not None
|
||||
obs = await call_sync_from_async(bash_session.execute, action)
|
||||
return obs
|
||||
except Exception as e:
|
||||
logger.error(f'Error running command: {e}')
|
||||
|
||||
@@ -400,7 +400,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
'No repository selected. Initializing a new git repository in the workspace.'
|
||||
)
|
||||
action = CmdRunAction(
|
||||
command='git init',
|
||||
command=f'git init && git config --global --add safe.directory {self.workspace_root}'
|
||||
)
|
||||
self.run_action(action)
|
||||
else:
|
||||
@@ -952,6 +952,9 @@ 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'):
|
||||
|
||||
@@ -406,7 +406,7 @@ class ActionExecutionClient(Runtime):
|
||||
'POST',
|
||||
f'{self.action_execution_server_url}/update_mcp_server',
|
||||
json=stdio_tools,
|
||||
timeout=10,
|
||||
timeout=60,
|
||||
)
|
||||
result = response.json()
|
||||
if response.status_code != 200:
|
||||
@@ -464,7 +464,9 @@ 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
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -44,7 +44,7 @@ class GitHandler:
|
||||
Returns:
|
||||
bool: True if inside a Git repository, otherwise False.
|
||||
"""
|
||||
cmd = 'git rev-parse --is-inside-work-tree'
|
||||
cmd = 'git --no-pager 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 rev-parse --verify {ref}'
|
||||
cmd = f'git --no-pager 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 merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_default_branch = 'origin/' + default_branch
|
||||
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
ref_new_repo = '$(git --no-pager 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 show {ref}:{file_path}'
|
||||
cmd = f'git --no-pager 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 remote show origin | grep "HEAD branch"'
|
||||
cmd = 'git --no-pager 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 rev-parse --abbrev-ref HEAD'
|
||||
cmd = 'git --no-pager rev-parse --abbrev-ref HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip()
|
||||
|
||||
@@ -153,8 +153,12 @@ class GitHandler:
|
||||
if not ref:
|
||||
return []
|
||||
|
||||
diff_cmd = f'git diff --name-status {ref}'
|
||||
diff_cmd = f'git --no-pager 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]]:
|
||||
@@ -164,7 +168,7 @@ class GitHandler:
|
||||
Returns:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
"""
|
||||
cmd = 'git ls-files --others --exclude-standard'
|
||||
cmd = 'git --no-pager ls-files --others --exclude-standard'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
obs_list = output.content.splitlines()
|
||||
return (
|
||||
|
||||
@@ -281,7 +281,23 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
raise ValueError('unsupported_operation')
|
||||
|
||||
async def close_session(self, sid: str):
|
||||
stop_all_containers(f'openhands-runtime-{sid}')
|
||||
# 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()
|
||||
|
||||
async def get_agent_loop_info(self, user_id: str | None = None, filter_to_sids: set[str] | None = None) -> list[AgentLoopInfo]:
|
||||
results = []
|
||||
|
||||
@@ -369,7 +369,9 @@ 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)
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
)
|
||||
@@ -124,26 +125,28 @@ async def connect(connection_id: str, environ: dict) -> None:
|
||||
f'User {user_id} is allowed to connect to conversation {conversation_id}'
|
||||
)
|
||||
|
||||
conversation_init_data = await setup_init_convo_settings(
|
||||
user_id, conversation_id, providers_set
|
||||
)
|
||||
agent_loop_info = await conversation_manager.join_conversation(
|
||||
conversation_id,
|
||||
connection_id,
|
||||
conversation_init_data,
|
||||
user_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'Connected to conversation {conversation_id} with connection_id {connection_id}. Replaying event stream...'
|
||||
f'Replaying event stream for conversation {conversation_id} with connection_id {connection_id}...'
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
# 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),
|
||||
@@ -153,13 +156,33 @@ async def connect(connection_id: str, environ: dict) -> None:
|
||||
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
|
||||
)
|
||||
agent_loop_info = await conversation_manager.join_conversation(
|
||||
conversation_id,
|
||||
connection_id,
|
||||
conversation_init_data,
|
||||
user_id,
|
||||
)
|
||||
|
||||
if agent_loop_info is None:
|
||||
raise ConnectionRefusedError('Failed to join conversation')
|
||||
|
||||
logger.info(
|
||||
f'Successfully joined conversation {conversation_id} with connection_id {connection_id}'
|
||||
)
|
||||
except ConnectionRefusedError:
|
||||
# Close the broken connection after sending an error message
|
||||
asyncio.create_task(sio.disconnect(connection_id))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
@@ -10,9 +11,10 @@ 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 ProviderType
|
||||
from openhands.integrations.service_types import GitService, ProviderType
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
from openhands.server.shared import ConversationStoreImpl, config, server_config
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
get_provider_tokens,
|
||||
@@ -20,7 +22,31 @@ 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)
|
||||
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
|
||||
|
||||
|
||||
async def save_pr_metadata(
|
||||
user_id: str, conversation_id: str, tool_result: str
|
||||
@@ -84,6 +110,11 @@ 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,
|
||||
@@ -97,7 +128,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
|
||||
@@ -132,7 +163,7 @@ async def create_mr(
|
||||
else ProviderToken()
|
||||
)
|
||||
|
||||
github_service = GitLabServiceImpl(
|
||||
gitlab_service = GitLabServiceImpl(
|
||||
user_id=github_token.user_id,
|
||||
external_auth_id=user_id,
|
||||
external_auth_token=access_token,
|
||||
@@ -141,7 +172,14 @@ async def create_mr(
|
||||
)
|
||||
|
||||
try:
|
||||
response = await github_service.create_mr(
|
||||
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(
|
||||
id=id,
|
||||
source_branch=source_branch,
|
||||
target_branch=target_branch,
|
||||
@@ -153,7 +191,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
|
||||
|
||||
@@ -15,7 +15,6 @@ from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
@@ -36,9 +35,10 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
|
||||
async def load_settings(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
settings: Settings = Depends(get_user_settings),
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> GETSettingsModel | JSONResponse:
|
||||
settings = await settings_store.load()
|
||||
|
||||
try:
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -124,8 +124,8 @@ async def create_new_conversation(
|
||||
image_urls=image_urls or [],
|
||||
)
|
||||
|
||||
if attach_convo_id and conversation_instructions:
|
||||
conversation_instructions = conversation_instructions.format(conversation_id)
|
||||
if attach_convo_id:
|
||||
logger.warning('Attaching convo ID is deprecated, skipping process')
|
||||
|
||||
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
|
||||
conversation_id,
|
||||
|
||||
@@ -25,10 +25,6 @@ class DefaultUserAuth(UserAuth):
|
||||
"""The default implementation does not support multi tenancy, so user_id is always None"""
|
||||
return None
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
"""The default implementation does not support multi tenancy, so email is always None"""
|
||||
return None
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""The default implementation does not support multi tenancy, so access_token is always None"""
|
||||
return None
|
||||
|
||||
@@ -38,10 +38,6 @@ class UserAuth(ABC):
|
||||
async def get_user_id(self) -> str | None:
|
||||
"""Get the unique identifier for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_email(self) -> str | None:
|
||||
"""Get the email for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""Get the access token for the current user"""
|
||||
|
||||
@@ -12,7 +12,7 @@ async def get_conversation_store(request: Request) -> ConversationStore | None:
|
||||
)
|
||||
if conversation_store:
|
||||
return conversation_store
|
||||
user_id = get_user_id(request)
|
||||
user_id = await get_user_id(request)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
request.state.conversation_store = conversation_store
|
||||
return conversation_store
|
||||
|
||||
@@ -40,8 +40,6 @@ class Settings(BaseModel):
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
mcp_config: MCPConfig | None = None
|
||||
search_api_key: SecretStr | None = None
|
||||
email: str | None = None
|
||||
email_verified: bool | None = None
|
||||
|
||||
model_config = {
|
||||
'validate_assignment': True,
|
||||
|
||||
119
poetry.lock
generated
119
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 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.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
{version = ">=1.25.0,<2.0.0dev", 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,<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.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
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,102 +6456,106 @@ et-xmlfile = "*"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.25.0"
|
||||
version = "1.34.0"
|
||||
description = "OpenTelemetry Python API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_api-1.25.0-py3-none-any.whl", hash = "sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737"},
|
||||
{file = "opentelemetry_api-1.25.0.tar.gz", hash = "sha256:77c4985f62f2614e42ce77ee4c9da5fa5f0bc1e1821085e9a47533a9323ae869"},
|
||||
{file = "opentelemetry_api-1.34.0-py3-none-any.whl", hash = "sha256:390b81984affe4453180820ca518de55e3be051111e70cc241bb3b0071ca3a2c"},
|
||||
{file = "opentelemetry_api-1.34.0.tar.gz", hash = "sha256:48d167589134799093005b7f7f347c69cc67859c693b17787f334fbe8871279f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
deprecated = ">=1.2.6"
|
||||
importlib-metadata = ">=6.0,<=7.1"
|
||||
importlib-metadata = ">=6.0,<8.8.0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-common"
|
||||
version = "1.25.0"
|
||||
version = "1.34.0"
|
||||
description = "OpenTelemetry Protobuf encoding"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{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"},
|
||||
{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"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-proto = "1.25.0"
|
||||
opentelemetry-proto = "1.34.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-grpc"
|
||||
version = "1.25.0"
|
||||
version = "1.34.0"
|
||||
description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{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"},
|
||||
{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"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
deprecated = ">=1.2.6"
|
||||
googleapis-common-protos = ">=1.52,<2.0"
|
||||
grpcio = ">=1.0.0,<2.0.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\""},
|
||||
]
|
||||
opentelemetry-api = ">=1.15,<2.0"
|
||||
opentelemetry-exporter-otlp-proto-common = "1.25.0"
|
||||
opentelemetry-proto = "1.25.0"
|
||||
opentelemetry-sdk = ">=1.25.0,<1.26.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"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "1.25.0"
|
||||
version = "1.34.0"
|
||||
description = "OpenTelemetry Python Proto"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_proto-1.25.0-py3-none-any.whl", hash = "sha256:f07e3341c78d835d9b86665903b199893befa5e98866f63d22b00d0b7ca4972f"},
|
||||
{file = "opentelemetry_proto-1.25.0.tar.gz", hash = "sha256:35b6ef9dc4a9f7853ecc5006738ad40443701e52c26099e197895cbda8b815a3"},
|
||||
{file = "opentelemetry_proto-1.34.0-py3-none-any.whl", hash = "sha256:ffb1f1b27552fda5a1cd581e34243cc0b6f134fb14c1c2a33cc3b4b208c9bf97"},
|
||||
{file = "opentelemetry_proto-1.34.0.tar.gz", hash = "sha256:73e40509b692630a47192888424f7e0b8fb19d9ecf2f04e6f708170cd3346dfe"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
protobuf = ">=3.19,<5.0"
|
||||
protobuf = ">=5.0,<6.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-sdk"
|
||||
version = "1.25.0"
|
||||
version = "1.34.0"
|
||||
description = "OpenTelemetry Python SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_sdk-1.25.0-py3-none-any.whl", hash = "sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9"},
|
||||
{file = "opentelemetry_sdk-1.25.0.tar.gz", hash = "sha256:ce7fc319c57707ef5bf8b74fb9f8ebdb8bfafbe11898410e0d2a761d08a98ec7"},
|
||||
{file = "opentelemetry_sdk-1.34.0-py3-none-any.whl", hash = "sha256:7850bcd5b5c95f9aae48603d6592bdad5c7bdef50c03e06393f8f457d891fe32"},
|
||||
{file = "opentelemetry_sdk-1.34.0.tar.gz", hash = "sha256:719559622afcd515c2aec462ccb749ba2e70075a01df45837623643814d33716"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = "1.25.0"
|
||||
opentelemetry-semantic-conventions = "0.46b0"
|
||||
typing-extensions = ">=3.7.4"
|
||||
opentelemetry-api = "1.34.0"
|
||||
opentelemetry-semantic-conventions = "0.55b0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.46b0"
|
||||
version = "0.55b0"
|
||||
description = "OpenTelemetry Semantic Conventions"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_semantic_conventions-0.46b0-py3-none-any.whl", hash = "sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07"},
|
||||
{file = "opentelemetry_semantic_conventions-0.46b0.tar.gz", hash = "sha256:fbc982ecbb6a6e90869b15c1673be90bd18c8a56ff1cffc0864e38e2edffaefa"},
|
||||
{file = "opentelemetry_semantic_conventions-0.55b0-py3-none-any.whl", hash = "sha256:63bb15b67377700e51c422d0d24092ca6ce9f3a4cb6f032375aa8af1fc2aab65"},
|
||||
{file = "opentelemetry_semantic_conventions-0.55b0.tar.gz", hash = "sha256:933d2e20c2dbc0f9b2f4f52138282875b4b14c66c491f5273bcdef1781368e9c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = "1.25.0"
|
||||
opentelemetry-api = "1.34.0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "overrides"
|
||||
@@ -7171,23 +7175,23 @@ testing = ["google-api-core (>=1.31.5)"]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "4.25.8"
|
||||
version = "5.29.5"
|
||||
description = ""
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{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"},
|
||||
{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"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9335,7 +9339,6 @@ 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\""]
|
||||
@@ -9578,7 +9581,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"},
|
||||
@@ -9595,7 +9598,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"},
|
||||
@@ -11757,4 +11760,4 @@ cffi = ["cffi (>=1.11)"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "eaa84e30dbafb061a75b4b173a8ba16542c4a03ab74583c55ab282cd6119e430"
|
||||
content-hash = "d9f6c24fa80dd191f180af0c802ea11ecf514d86aaa421cb19a9bb497362c101"
|
||||
|
||||
@@ -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,10 +48,11 @@ dirhash = "*"
|
||||
tornado = "*"
|
||||
python-dotenv = "*"
|
||||
rapidfuzz = "^3.9.0"
|
||||
rich = "^13.7.0"
|
||||
whatthepatch = "^1.0.6"
|
||||
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"
|
||||
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"
|
||||
modal = ">=0.66.26,<0.78.0"
|
||||
runloop-api-client = "0.33.0"
|
||||
libtmux = ">=0.37,<0.40"
|
||||
|
||||
@@ -46,28 +46,40 @@ 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 config user.email 'test@example.com'", self.origin_dir
|
||||
'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
|
||||
)
|
||||
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 add file1.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
|
||||
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
|
||||
)
|
||||
|
||||
# Clone the origin repository to local
|
||||
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.local_dir
|
||||
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
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.local_dir)
|
||||
|
||||
# Create a feature branch in the local repository
|
||||
self._execute_command('git checkout -b feature-branch', self.local_dir)
|
||||
self._execute_command(
|
||||
'git --no-pager 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:
|
||||
@@ -77,32 +89,40 @@ class TestGitHandler(unittest.TestCase):
|
||||
f.write('New file content')
|
||||
|
||||
# Add and commit file1.txt changes to create a baseline
|
||||
self._execute_command('git add file1.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
|
||||
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
|
||||
)
|
||||
|
||||
# Add and commit file2.txt, then modify it
|
||||
self._execute_command('git add file2.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Add file2.txt'", self.local_dir)
|
||||
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
|
||||
)
|
||||
|
||||
# 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 add file2.txt', self.local_dir)
|
||||
self._execute_command('git --no-pager 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 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)
|
||||
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)
|
||||
|
||||
# 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 push -u origin feature-branch', self.local_dir)
|
||||
self._execute_command(
|
||||
'git --no-pager 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."""
|
||||
@@ -111,7 +131,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git rev-parse --is-inside-work-tree'
|
||||
cmd == 'git --no-pager rev-parse --is-inside-work-tree'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
@@ -124,7 +144,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git remote show origin | grep "HEAD branch"'
|
||||
cmd == 'git --no-pager remote show origin | grep "HEAD branch"'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
@@ -133,11 +153,12 @@ 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 rev-parse --abbrev-ref HEAD'
|
||||
cmd == 'git --no-pager rev-parse --abbrev-ref HEAD'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
@@ -152,7 +173,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
verify_commands = [
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git rev-parse --verify')
|
||||
if cmd.startswith('git --no-pager rev-parse --verify')
|
||||
]
|
||||
|
||||
# First should check origin/feature-branch (current branch)
|
||||
@@ -162,13 +183,17 @@ class TestGitHandler(unittest.TestCase):
|
||||
self.assertEqual(ref, 'origin/feature-branch')
|
||||
|
||||
# Verify the ref exists
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
result = self._execute_command(
|
||||
f'git --no-pager 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 checkout -b new-local-branch', self.local_dir)
|
||||
self._execute_command(
|
||||
'git --no-pager checkout -b new-local-branch', self.local_dir
|
||||
)
|
||||
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
@@ -180,7 +205,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
verify_commands = [
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git rev-parse --verify')
|
||||
if cmd.startswith('git --no-pager rev-parse --verify')
|
||||
]
|
||||
|
||||
# Should have tried origin/new-local-branch first (which doesn't exist)
|
||||
@@ -193,7 +218,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
self.assertTrue(ref == 'origin/main' or 'merge-base' in ref)
|
||||
|
||||
# Verify the ref exists
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
result = self._execute_command(
|
||||
f'git --no-pager rev-parse --verify {ref}', self.local_dir
|
||||
)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
def test_get_valid_ref_without_origin(self):
|
||||
@@ -203,15 +230,21 @@ class TestGitHandler(unittest.TestCase):
|
||||
os.makedirs(no_origin_dir, exist_ok=True)
|
||||
|
||||
# Initialize git repo without origin
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
# 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 add file1.txt', no_origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", no_origin_dir)
|
||||
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
|
||||
)
|
||||
|
||||
# Create a custom GitHandler with a modified _get_default_branch method for this test
|
||||
class TestGitHandler(GitHandler):
|
||||
@@ -234,19 +267,20 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Verify that git commands were executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd.startswith('git rev-parse --verify')
|
||||
cmd.startswith('git --no-pager rev-parse --verify')
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
# Should have fallen back to the empty tree ref
|
||||
self.assertEqual(
|
||||
ref, '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
|
||||
ref,
|
||||
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)',
|
||||
)
|
||||
|
||||
# Verify the ref exists (the empty tree ref always exists)
|
||||
result = self._execute_command(
|
||||
'git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
|
||||
'git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
|
||||
no_origin_dir,
|
||||
)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
@@ -258,7 +292,9 @@ 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 show')
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git --no-pager show')
|
||||
]
|
||||
self.assertTrue(any('file1.txt' in cmd for cmd in show_commands))
|
||||
|
||||
@@ -277,7 +313,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 add new_file.txt', self.local_dir)
|
||||
self._execute_command('git --no-pager add new_file.txt', self.local_dir)
|
||||
|
||||
files = self.git_handler._get_changed_files()
|
||||
self.assertTrue(files)
|
||||
@@ -291,7 +327,9 @@ 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 diff')
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git --no-pager diff')
|
||||
]
|
||||
self.assertTrue(diff_commands)
|
||||
|
||||
@@ -309,7 +347,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git ls-files --others --exclude-standard'
|
||||
cmd == 'git --no-pager ls-files --others --exclude-standard'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
@@ -323,7 +361,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 add new_file2.txt', self.local_dir)
|
||||
self._execute_command('git --no-pager add new_file2.txt', self.local_dir)
|
||||
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
@@ -353,7 +391,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
'git show' in cmd and 'file1.txt' in cmd
|
||||
'git --no-pager show' in cmd and 'file1.txt' in cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
86
tests/unit/test_mcp_routes.py
Normal file
86
tests/unit/test_mcp_routes.py
Normal file
@@ -0,0 +1,86 @@
|
||||
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()
|
||||
@@ -28,9 +28,6 @@ class MockUserAuth(UserAuth):
|
||||
async def get_user_id(self) -> str | None:
|
||||
return 'test-user'
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
return 'test-email@whatever.com'
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
return SecretStr('test-token')
|
||||
|
||||
|
||||
@@ -234,7 +234,10 @@ 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 == 'git init'
|
||||
assert (
|
||||
runtime.run_action_calls[0].command
|
||||
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
|
||||
)
|
||||
assert result == ''
|
||||
|
||||
|
||||
@@ -255,7 +258,10 @@ 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 == 'git init'
|
||||
assert (
|
||||
runtime.run_action_calls[0].command
|
||||
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
|
||||
)
|
||||
assert result == ''
|
||||
|
||||
|
||||
|
||||
@@ -27,9 +27,6 @@ class MockUserAuth(UserAuth):
|
||||
async def get_user_id(self) -> str | None:
|
||||
return 'test-user'
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
return 'test-email@whatever.com'
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
return SecretStr('test-token')
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ 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:
|
||||
@@ -181,6 +182,7 @@ 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
|
||||
@@ -188,3 +190,8 @@ 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')
|
||||
|
||||
Reference in New Issue
Block a user