mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 23:08:04 -05:00
CLI(V1): GUI Launcher (#11257)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
56
openhands-cli/openhands_cli/argparsers/main_parser.py
Normal file
56
openhands-cli/openhands_cli/argparsers/main_parser.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Main argument parser for OpenHands CLI."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def create_main_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Create the main argument parser with CLI as default and serve as subcommand.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The configured argument parser
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='OpenHands CLI - Terminal User Interface for OpenHands AI Agent',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
By default, OpenHands runs in CLI mode (terminal interface).
|
||||||
|
Use 'serve' subcommand to launch the GUI server instead.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
openhands # Start CLI mode
|
||||||
|
openhands --resume conversation-id # Resume a conversation in CLI mode
|
||||||
|
openhands serve # Launch GUI server
|
||||||
|
openhands serve --gpu # Launch GUI server with GPU support
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# CLI arguments at top level (default mode)
|
||||||
|
parser.add_argument(
|
||||||
|
'--resume',
|
||||||
|
type=str,
|
||||||
|
help='Conversation ID to resume'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only serve as subcommand
|
||||||
|
subparsers = parser.add_subparsers(
|
||||||
|
dest='command',
|
||||||
|
help='Additional commands'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add serve subcommand
|
||||||
|
serve_parser = subparsers.add_parser(
|
||||||
|
'serve',
|
||||||
|
help='Launch the OpenHands GUI server using Docker (web interface)'
|
||||||
|
)
|
||||||
|
serve_parser.add_argument(
|
||||||
|
'--mount-cwd',
|
||||||
|
action='store_true',
|
||||||
|
help='Mount the current working directory in the Docker container'
|
||||||
|
)
|
||||||
|
serve_parser.add_argument(
|
||||||
|
'--gpu',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable GPU support in the Docker container'
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
31
openhands-cli/openhands_cli/argparsers/serve_parser.py
Normal file
31
openhands-cli/openhands_cli/argparsers/serve_parser.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Argument parser for serve subcommand."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||||
|
"""Add serve subcommand parser.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subparsers: The subparsers object to add the serve parser to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The serve argument parser
|
||||||
|
"""
|
||||||
|
serve_parser = subparsers.add_parser(
|
||||||
|
'serve',
|
||||||
|
help='Launch the OpenHands GUI server using Docker (web interface)'
|
||||||
|
)
|
||||||
|
serve_parser.add_argument(
|
||||||
|
'--mount-cwd',
|
||||||
|
help='Mount the current working directory into the GUI server container',
|
||||||
|
action='store_true',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
serve_parser.add_argument(
|
||||||
|
'--gpu',
|
||||||
|
help='Enable GPU support by mounting all GPUs into the Docker container via nvidia-docker',
|
||||||
|
action='store_true',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
return serve_parser
|
||||||
229
openhands-cli/openhands_cli/gui_launcher.py
Normal file
229
openhands-cli/openhands_cli/gui_launcher.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""GUI launcher for OpenHands CLI."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from prompt_toolkit import print_formatted_text
|
||||||
|
from prompt_toolkit.formatted_text import HTML
|
||||||
|
from openhands_cli.locations import PERSISTENCE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _format_docker_command_for_logging(cmd: list[str]) -> str:
|
||||||
|
"""Format a Docker command for logging with grey color.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd (list[str]): The Docker command as a list of strings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The formatted command string in grey HTML color
|
||||||
|
"""
|
||||||
|
cmd_str = ' '.join(cmd)
|
||||||
|
return f'<grey>Running Docker command: {cmd_str}</grey>'
|
||||||
|
|
||||||
|
|
||||||
|
def check_docker_requirements() -> bool:
|
||||||
|
"""Check if Docker is installed and running.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if Docker is available and running, False otherwise.
|
||||||
|
"""
|
||||||
|
# Check if Docker is installed
|
||||||
|
if not shutil.which('docker'):
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansired>❌ Docker is not installed or not in PATH.</ansired>')
|
||||||
|
)
|
||||||
|
print_formatted_text(
|
||||||
|
HTML(
|
||||||
|
'<grey>Please install Docker first: https://docs.docker.com/get-docker/</grey>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if Docker daemon is running
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['docker', 'info'], capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansired>❌ Docker daemon is not running.</ansired>')
|
||||||
|
)
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<grey>Please start Docker and try again.</grey>')
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansired>❌ Failed to check Docker status.</ansired>')
|
||||||
|
)
|
||||||
|
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_config_dir_exists() -> Path:
|
||||||
|
"""Ensure the OpenHands configuration directory exists and return its path."""
|
||||||
|
path = Path(PERSISTENCE_DIR)
|
||||||
|
path.mkdir(exist_ok=True, parents=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def get_openhands_version() -> str:
|
||||||
|
"""Get the OpenHands version for Docker images.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The version string to use for Docker images
|
||||||
|
"""
|
||||||
|
# For now, use 'latest' as the default version
|
||||||
|
# In the future, this could be read from a version file or environment variable
|
||||||
|
return os.environ.get('OPENHANDS_VERSION', 'latest')
|
||||||
|
|
||||||
|
|
||||||
|
def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
|
||||||
|
"""Launch the OpenHands GUI server using Docker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mount_cwd: If True, mount the current working directory into the container.
|
||||||
|
gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker.
|
||||||
|
"""
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansiblue>🚀 Launching OpenHands GUI server...</ansiblue>')
|
||||||
|
)
|
||||||
|
print_formatted_text('')
|
||||||
|
|
||||||
|
# Check Docker requirements
|
||||||
|
if not check_docker_requirements():
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Ensure config directory exists
|
||||||
|
config_dir = ensure_config_dir_exists()
|
||||||
|
|
||||||
|
# Get the current version for the Docker image
|
||||||
|
version = get_openhands_version()
|
||||||
|
runtime_image = f'docker.all-hands.dev/all-hands-ai/runtime:{version}-nikolaik'
|
||||||
|
app_image = f'docker.all-hands.dev/all-hands-ai/openhands:{version}'
|
||||||
|
|
||||||
|
print_formatted_text(HTML('<grey>Pulling required Docker images...</grey>'))
|
||||||
|
|
||||||
|
# Pull the runtime image first
|
||||||
|
pull_cmd = ['docker', 'pull', runtime_image]
|
||||||
|
print_formatted_text(HTML(_format_docker_command_for_logging(pull_cmd)))
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
pull_cmd,
|
||||||
|
check=True,
|
||||||
|
timeout=300, # 5 minutes timeout
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansired>❌ Failed to pull runtime image.</ansired>')
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansired>❌ Timeout while pulling runtime image.</ansired>')
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print_formatted_text('')
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansigreen>✅ Starting OpenHands GUI server...</ansigreen>')
|
||||||
|
)
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<grey>The server will be available at: http://localhost:3000</grey>')
|
||||||
|
)
|
||||||
|
print_formatted_text(HTML('<grey>Press Ctrl+C to stop the server.</grey>'))
|
||||||
|
print_formatted_text('')
|
||||||
|
|
||||||
|
# Build the Docker command
|
||||||
|
docker_cmd = [
|
||||||
|
'docker',
|
||||||
|
'run',
|
||||||
|
'-it',
|
||||||
|
'--rm',
|
||||||
|
'--pull=always',
|
||||||
|
'-e',
|
||||||
|
f'SANDBOX_RUNTIME_CONTAINER_IMAGE={runtime_image}',
|
||||||
|
'-e',
|
||||||
|
'LOG_ALL_EVENTS=true',
|
||||||
|
'-v',
|
||||||
|
'/var/run/docker.sock:/var/run/docker.sock',
|
||||||
|
'-v',
|
||||||
|
f'{config_dir}:/.openhands',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add GPU support if requested
|
||||||
|
if gpu:
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansigreen>🖥️ Enabling GPU support via nvidia-docker...</ansigreen>')
|
||||||
|
)
|
||||||
|
# Add the --gpus all flag to enable all GPUs
|
||||||
|
docker_cmd.insert(2, '--gpus')
|
||||||
|
docker_cmd.insert(3, 'all')
|
||||||
|
# Add environment variable to pass GPU support to sandbox containers
|
||||||
|
docker_cmd.extend(
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
'SANDBOX_ENABLE_GPU=true',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add current working directory mount if requested
|
||||||
|
if mount_cwd:
|
||||||
|
cwd = Path.cwd()
|
||||||
|
# Following the documentation at https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem
|
||||||
|
docker_cmd.extend(
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
f'SANDBOX_VOLUMES={cwd}:/workspace:rw',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set user ID for Unix-like systems only
|
||||||
|
if os.name != 'nt': # Not Windows
|
||||||
|
try:
|
||||||
|
user_id = subprocess.check_output(['id', '-u'], text=True).strip()
|
||||||
|
docker_cmd.extend(['-e', f'SANDBOX_USER_ID={user_id}'])
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
# If 'id' command fails or doesn't exist, skip setting user ID
|
||||||
|
pass
|
||||||
|
# Print the folder that will be mounted to inform the user
|
||||||
|
print_formatted_text(
|
||||||
|
HTML(
|
||||||
|
f'<ansigreen>📂 Mounting current directory:</ansigreen> <ansiyellow>{cwd}</ansiyellow> <ansigreen>to</ansigreen> <ansiyellow>/workspace</ansiyellow>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
docker_cmd.extend(
|
||||||
|
[
|
||||||
|
'-p',
|
||||||
|
'3000:3000',
|
||||||
|
'--add-host',
|
||||||
|
'host.docker.internal:host-gateway',
|
||||||
|
'--name',
|
||||||
|
'openhands-app',
|
||||||
|
app_image,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Log and run the Docker command
|
||||||
|
print_formatted_text(HTML(_format_docker_command_for_logging(docker_cmd)))
|
||||||
|
subprocess.run(docker_cmd, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print_formatted_text('')
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansired>❌ Failed to start OpenHands GUI server.</ansired>')
|
||||||
|
)
|
||||||
|
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
|
||||||
|
sys.exit(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print_formatted_text('')
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<ansigreen>✓ OpenHands GUI server stopped successfully.</ansigreen>')
|
||||||
|
)
|
||||||
|
sys.exit(0)
|
||||||
@@ -4,9 +4,9 @@ Simple main entry point for OpenHands CLI.
|
|||||||
This is a simplified version that demonstrates the TUI functionality.
|
This is a simplified version that demonstrates the TUI functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
debug_env = os.getenv('DEBUG', 'false').lower()
|
debug_env = os.getenv('DEBUG', 'false').lower()
|
||||||
@@ -17,7 +17,7 @@ if debug_env != '1' and debug_env != 'true':
|
|||||||
from prompt_toolkit import print_formatted_text
|
from prompt_toolkit import print_formatted_text
|
||||||
from prompt_toolkit.formatted_text import HTML
|
from prompt_toolkit.formatted_text import HTML
|
||||||
|
|
||||||
from openhands_cli.agent_chat import run_cli_entry
|
from openhands_cli.argparsers.main_parser import create_main_parser
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -27,35 +27,28 @@ def main() -> None:
|
|||||||
ImportError: If agent chat dependencies are missing
|
ImportError: If agent chat dependencies are missing
|
||||||
Exception: On other error conditions
|
Exception: On other error conditions
|
||||||
"""
|
"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = create_main_parser()
|
||||||
description='OpenHands CLI - Terminal User Interface for OpenHands AI Agent'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--resume',
|
|
||||||
type=str,
|
|
||||||
help='Conversation ID to use for the session. If not provided, a random UUID will be generated.',
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start agent chat
|
if args.command == 'serve':
|
||||||
run_cli_entry(resume_conversation_id=args.resume)
|
# Import gui_launcher only when needed
|
||||||
|
from openhands_cli.gui_launcher import launch_gui_server
|
||||||
|
|
||||||
except ImportError as e:
|
launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu)
|
||||||
print_formatted_text(
|
else:
|
||||||
HTML(f'<red>Error: Agent chat requires additional dependencies: {e}</red>')
|
# Default CLI behavior - no subcommand needed
|
||||||
)
|
# Import agent_chat only when needed
|
||||||
print_formatted_text(
|
from openhands_cli.agent_chat import run_cli_entry
|
||||||
HTML('<yellow>Please ensure the agent SDK is properly installed.</yellow>')
|
|
||||||
)
|
# Start agent chat
|
||||||
raise
|
run_cli_entry(resume_conversation_id=args.resume)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||||
except EOFError:
|
except EOFError:
|
||||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_formatted_text(HTML(f'<red>Error starting agent chat: {e}</red>'))
|
print_formatted_text(HTML(f'<red>Error: {e}</red>'))
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
201
openhands-cli/tests/test_gui_launcher.py
Normal file
201
openhands-cli/tests/test_gui_launcher.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""Tests for GUI launcher functionality."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from openhands_cli.gui_launcher import (
|
||||||
|
_format_docker_command_for_logging,
|
||||||
|
check_docker_requirements,
|
||||||
|
get_openhands_version,
|
||||||
|
launch_gui_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatDockerCommand:
|
||||||
|
"""Test the Docker command formatting function."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd,expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
['docker', 'run', 'hello-world'],
|
||||||
|
'<grey>Running Docker command: docker run hello-world</grey>',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
['docker', 'run', '-it', '--rm', '-p', '3000:3000', 'openhands:latest'],
|
||||||
|
'<grey>Running Docker command: docker run -it --rm -p 3000:3000 openhands:latest</grey>',
|
||||||
|
),
|
||||||
|
([], '<grey>Running Docker command: </grey>'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_format_docker_command(self, cmd, expected):
|
||||||
|
"""Test formatting Docker commands."""
|
||||||
|
result = _format_docker_command_for_logging(cmd)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckDockerRequirements:
|
||||||
|
"""Test Docker requirements checking."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"which_return,run_side_effect,expected_result,expected_print_count",
|
||||||
|
[
|
||||||
|
# Docker not installed
|
||||||
|
(None, None, False, 2),
|
||||||
|
# Docker daemon not running
|
||||||
|
('/usr/bin/docker', MagicMock(returncode=1), False, 2),
|
||||||
|
# Docker timeout
|
||||||
|
('/usr/bin/docker', subprocess.TimeoutExpired('docker info', 10), False, 2),
|
||||||
|
# Docker available
|
||||||
|
('/usr/bin/docker', MagicMock(returncode=0), True, 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch('shutil.which')
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_docker_requirements(
|
||||||
|
self, mock_run, mock_which, which_return, run_side_effect, expected_result, expected_print_count
|
||||||
|
):
|
||||||
|
"""Test Docker requirements checking scenarios."""
|
||||||
|
mock_which.return_value = which_return
|
||||||
|
if run_side_effect is not None:
|
||||||
|
if isinstance(run_side_effect, Exception):
|
||||||
|
mock_run.side_effect = run_side_effect
|
||||||
|
else:
|
||||||
|
mock_run.return_value = run_side_effect
|
||||||
|
|
||||||
|
with patch('openhands_cli.gui_launcher.print_formatted_text') as mock_print:
|
||||||
|
result = check_docker_requirements()
|
||||||
|
|
||||||
|
assert result is expected_result
|
||||||
|
assert mock_print.call_count == expected_print_count
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOpenHandsVersion:
|
||||||
|
"""Test version retrieval."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"env_value,expected",
|
||||||
|
[
|
||||||
|
(None, 'latest'), # No environment variable set
|
||||||
|
('1.2.3', '1.2.3'), # Environment variable set
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_version_retrieval(self, env_value, expected):
|
||||||
|
"""Test version retrieval from environment."""
|
||||||
|
if env_value:
|
||||||
|
os.environ['OPENHANDS_VERSION'] = env_value
|
||||||
|
result = get_openhands_version()
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestLaunchGuiServer:
|
||||||
|
"""Test GUI server launching."""
|
||||||
|
|
||||||
|
@patch('openhands_cli.gui_launcher.check_docker_requirements')
|
||||||
|
@patch('openhands_cli.gui_launcher.print_formatted_text')
|
||||||
|
def test_launch_gui_server_docker_not_available(self, mock_print, mock_check_docker):
|
||||||
|
"""Test that launch_gui_server exits when Docker is not available."""
|
||||||
|
mock_check_docker.return_value = False
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
launch_gui_server()
|
||||||
|
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"pull_side_effect,run_side_effect,expected_exit_code,mount_cwd,gpu",
|
||||||
|
[
|
||||||
|
# Docker pull failure
|
||||||
|
(subprocess.CalledProcessError(1, 'docker pull'), None, 1, False, False),
|
||||||
|
# Docker pull timeout
|
||||||
|
(subprocess.TimeoutExpired('docker pull', 300), None, 1, False, False),
|
||||||
|
# Docker run failure
|
||||||
|
(MagicMock(returncode=0), subprocess.CalledProcessError(1, 'docker run'), 1, False, False),
|
||||||
|
# KeyboardInterrupt during run
|
||||||
|
(MagicMock(returncode=0), KeyboardInterrupt(), 0, False, False),
|
||||||
|
# Success with mount_cwd
|
||||||
|
(MagicMock(returncode=0), MagicMock(returncode=0), None, True, False),
|
||||||
|
# Success with GPU
|
||||||
|
(MagicMock(returncode=0), MagicMock(returncode=0), None, False, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch('openhands_cli.gui_launcher.check_docker_requirements')
|
||||||
|
@patch('openhands_cli.gui_launcher.ensure_config_dir_exists')
|
||||||
|
@patch('openhands_cli.gui_launcher.get_openhands_version')
|
||||||
|
@patch('subprocess.run')
|
||||||
|
@patch('subprocess.check_output')
|
||||||
|
@patch('pathlib.Path.cwd')
|
||||||
|
@patch('openhands_cli.gui_launcher.print_formatted_text')
|
||||||
|
def test_launch_gui_server_scenarios(
|
||||||
|
self,
|
||||||
|
mock_print,
|
||||||
|
mock_cwd,
|
||||||
|
mock_check_output,
|
||||||
|
mock_run,
|
||||||
|
mock_version,
|
||||||
|
mock_config_dir,
|
||||||
|
mock_check_docker,
|
||||||
|
pull_side_effect,
|
||||||
|
run_side_effect,
|
||||||
|
expected_exit_code,
|
||||||
|
mount_cwd,
|
||||||
|
gpu,
|
||||||
|
):
|
||||||
|
"""Test various GUI server launch scenarios."""
|
||||||
|
# Setup mocks
|
||||||
|
mock_check_docker.return_value = True
|
||||||
|
mock_config_dir.return_value = Path('/home/user/.openhands')
|
||||||
|
mock_version.return_value = 'latest'
|
||||||
|
mock_check_output.return_value = '1000\n'
|
||||||
|
mock_cwd.return_value = Path('/current/dir')
|
||||||
|
|
||||||
|
# Configure subprocess.run side effects
|
||||||
|
side_effects = []
|
||||||
|
if pull_side_effect is not None:
|
||||||
|
if isinstance(pull_side_effect, Exception):
|
||||||
|
side_effects.append(pull_side_effect)
|
||||||
|
else:
|
||||||
|
side_effects.append(pull_side_effect)
|
||||||
|
|
||||||
|
if run_side_effect is not None:
|
||||||
|
if isinstance(run_side_effect, Exception):
|
||||||
|
side_effects.append(run_side_effect)
|
||||||
|
else:
|
||||||
|
side_effects.append(run_side_effect)
|
||||||
|
|
||||||
|
mock_run.side_effect = side_effects
|
||||||
|
|
||||||
|
# Test the function
|
||||||
|
if expected_exit_code is not None:
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
launch_gui_server(mount_cwd=mount_cwd, gpu=gpu)
|
||||||
|
assert exc_info.value.code == expected_exit_code
|
||||||
|
else:
|
||||||
|
# Should not raise SystemExit for successful cases
|
||||||
|
launch_gui_server(mount_cwd=mount_cwd, gpu=gpu)
|
||||||
|
|
||||||
|
# Verify subprocess.run was called correctly
|
||||||
|
assert mock_run.call_count == 2 # Pull and run commands
|
||||||
|
|
||||||
|
# Check pull command
|
||||||
|
pull_call = mock_run.call_args_list[0]
|
||||||
|
pull_cmd = pull_call[0][0]
|
||||||
|
assert pull_cmd[0:3] == ['docker', 'pull', 'docker.all-hands.dev/all-hands-ai/runtime:latest-nikolaik']
|
||||||
|
|
||||||
|
# Check run command
|
||||||
|
run_call = mock_run.call_args_list[1]
|
||||||
|
run_cmd = run_call[0][0]
|
||||||
|
assert run_cmd[0:2] == ['docker', 'run']
|
||||||
|
|
||||||
|
if mount_cwd:
|
||||||
|
assert 'SANDBOX_VOLUMES=/current/dir:/workspace:rw' in ' '.join(run_cmd)
|
||||||
|
assert 'SANDBOX_USER_ID=1000' in ' '.join(run_cmd)
|
||||||
|
|
||||||
|
if gpu:
|
||||||
|
assert '--gpus' in run_cmd
|
||||||
|
assert 'all' in run_cmd
|
||||||
|
assert 'SANDBOX_ENABLE_GPU=true' in ' '.join(run_cmd)
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
"""Tests for main entry point functionality."""
|
"""Tests for main entry point functionality."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from openhands_cli import simple_main
|
from openhands_cli import simple_main
|
||||||
|
from openhands_cli.simple_main import main
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestMainEntryPoint:
|
class TestMainEntryPoint:
|
||||||
"""Test the main entry point behavior."""
|
"""Test the main entry point behavior."""
|
||||||
|
|
||||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
@patch('openhands_cli.agent_chat.run_cli_entry')
|
||||||
@patch('sys.argv', ['openhands'])
|
@patch('sys.argv', ['openhands'])
|
||||||
def test_main_starts_agent_chat_directly(
|
def test_main_starts_agent_chat_directly(
|
||||||
self, mock_run_agent_chat: MagicMock
|
self, mock_run_agent_chat: MagicMock
|
||||||
@@ -24,7 +28,7 @@ class TestMainEntryPoint:
|
|||||||
# Should call run_cli_entry with no resume conversation ID
|
# Should call run_cli_entry with no resume conversation ID
|
||||||
mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None)
|
mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None)
|
||||||
|
|
||||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
@patch('openhands_cli.agent_chat.run_cli_entry')
|
||||||
@patch('sys.argv', ['openhands'])
|
@patch('sys.argv', ['openhands'])
|
||||||
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
|
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
|
||||||
"""Test that main() handles ImportError gracefully."""
|
"""Test that main() handles ImportError gracefully."""
|
||||||
@@ -36,7 +40,7 @@ class TestMainEntryPoint:
|
|||||||
|
|
||||||
assert str(exc_info.value) == 'Missing dependency'
|
assert str(exc_info.value) == 'Missing dependency'
|
||||||
|
|
||||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
@patch('openhands_cli.agent_chat.run_cli_entry')
|
||||||
@patch('sys.argv', ['openhands'])
|
@patch('sys.argv', ['openhands'])
|
||||||
def test_main_handles_keyboard_interrupt(
|
def test_main_handles_keyboard_interrupt(
|
||||||
self, mock_run_agent_chat: MagicMock
|
self, mock_run_agent_chat: MagicMock
|
||||||
@@ -48,7 +52,7 @@ class TestMainEntryPoint:
|
|||||||
# Should complete without raising an exception (graceful exit)
|
# Should complete without raising an exception (graceful exit)
|
||||||
simple_main.main()
|
simple_main.main()
|
||||||
|
|
||||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
@patch('openhands_cli.agent_chat.run_cli_entry')
|
||||||
@patch('sys.argv', ['openhands'])
|
@patch('sys.argv', ['openhands'])
|
||||||
def test_main_handles_eof_error(self, mock_run_agent_chat: MagicMock) -> None:
|
def test_main_handles_eof_error(self, mock_run_agent_chat: MagicMock) -> None:
|
||||||
"""Test that main() handles EOFError gracefully."""
|
"""Test that main() handles EOFError gracefully."""
|
||||||
@@ -58,7 +62,7 @@ class TestMainEntryPoint:
|
|||||||
# Should complete without raising an exception (graceful exit)
|
# Should complete without raising an exception (graceful exit)
|
||||||
simple_main.main()
|
simple_main.main()
|
||||||
|
|
||||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
@patch('openhands_cli.agent_chat.run_cli_entry')
|
||||||
@patch('sys.argv', ['openhands'])
|
@patch('sys.argv', ['openhands'])
|
||||||
def test_main_handles_general_exception(
|
def test_main_handles_general_exception(
|
||||||
self, mock_run_agent_chat: MagicMock
|
self, mock_run_agent_chat: MagicMock
|
||||||
@@ -72,7 +76,7 @@ class TestMainEntryPoint:
|
|||||||
|
|
||||||
assert str(exc_info.value) == 'Unexpected error'
|
assert str(exc_info.value) == 'Unexpected error'
|
||||||
|
|
||||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
@patch('openhands_cli.agent_chat.run_cli_entry')
|
||||||
@patch('sys.argv', ['openhands', '--resume', 'test-conversation-id'])
|
@patch('sys.argv', ['openhands', '--resume', 'test-conversation-id'])
|
||||||
def test_main_with_resume_argument(self, mock_run_agent_chat: MagicMock) -> None:
|
def test_main_with_resume_argument(self, mock_run_agent_chat: MagicMock) -> None:
|
||||||
"""Test that main() passes resume conversation ID when provided."""
|
"""Test that main() passes resume conversation ID when provided."""
|
||||||
@@ -86,3 +90,65 @@ class TestMainEntryPoint:
|
|||||||
mock_run_agent_chat.assert_called_once_with(
|
mock_run_agent_chat.assert_called_once_with(
|
||||||
resume_conversation_id='test-conversation-id'
|
resume_conversation_id='test-conversation-id'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv,expected_kwargs",
|
||||||
|
[
|
||||||
|
(['openhands'], {"resume_conversation_id": None}),
|
||||||
|
(['openhands', '--resume', 'test-id'], {"resume_conversation_id": 'test-id'}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_main_cli_calls_run_cli_entry(monkeypatch, argv, expected_kwargs):
|
||||||
|
# Patch sys.argv since main() takes no params
|
||||||
|
monkeypatch.setattr(sys, "argv", argv, raising=False)
|
||||||
|
|
||||||
|
called = {}
|
||||||
|
fake_agent_chat = SimpleNamespace(
|
||||||
|
run_cli_entry=lambda **kw: called.setdefault("kwargs", kw)
|
||||||
|
)
|
||||||
|
# Provide the symbol that main() will import
|
||||||
|
monkeypatch.setitem(sys.modules, "openhands_cli.agent_chat", fake_agent_chat)
|
||||||
|
|
||||||
|
# Execute (no SystemExit expected on success)
|
||||||
|
main()
|
||||||
|
assert called["kwargs"] == expected_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv,expected_kwargs",
|
||||||
|
[
|
||||||
|
(['openhands', 'serve'], {"mount_cwd": False, "gpu": False}),
|
||||||
|
(['openhands', 'serve', '--mount-cwd'], {"mount_cwd": True, "gpu": False}),
|
||||||
|
(['openhands', 'serve', '--gpu'], {"mount_cwd": False, "gpu": True}),
|
||||||
|
(['openhands', 'serve', '--mount-cwd', '--gpu'], {"mount_cwd": True, "gpu": True}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_main_serve_calls_launch_gui_server(monkeypatch, argv, expected_kwargs):
|
||||||
|
monkeypatch.setattr(sys, "argv", argv, raising=False)
|
||||||
|
|
||||||
|
called = {}
|
||||||
|
fake_gui = SimpleNamespace(
|
||||||
|
launch_gui_server=lambda **kw: called.setdefault("kwargs", kw)
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "openhands_cli.gui_launcher", fake_gui)
|
||||||
|
|
||||||
|
main()
|
||||||
|
assert called["kwargs"] == expected_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv,expected_exit_code",
|
||||||
|
[
|
||||||
|
(['openhands', 'invalid-command'], 2), # argparse error
|
||||||
|
(['openhands', '--help'], 0), # top-level help
|
||||||
|
(['openhands', 'serve', '--help'], 0), # subcommand help
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_help_and_invalid(monkeypatch, argv, expected_exit_code):
|
||||||
|
monkeypatch.setattr(sys, "argv", argv, raising=False)
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
main()
|
||||||
|
assert exc.value.code == expected_exit_code
|
||||||
|
|||||||
Reference in New Issue
Block a user