mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
CLI(V1): GUI Launcher (#11257)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
80dc2efaab
commit
23d325cb16
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.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
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.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:
|
||||
@ -27,35 +27,28 @@ def main() -> None:
|
||||
ImportError: If agent chat dependencies are missing
|
||||
Exception: On other error conditions
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
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.',
|
||||
)
|
||||
|
||||
parser = create_main_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Start agent chat
|
||||
run_cli_entry(resume_conversation_id=args.resume)
|
||||
if args.command == 'serve':
|
||||
# Import gui_launcher only when needed
|
||||
from openhands_cli.gui_launcher import launch_gui_server
|
||||
|
||||
except ImportError as e:
|
||||
print_formatted_text(
|
||||
HTML(f'<red>Error: Agent chat requires additional dependencies: {e}</red>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML('<yellow>Please ensure the agent SDK is properly installed.</yellow>')
|
||||
)
|
||||
raise
|
||||
launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu)
|
||||
else:
|
||||
# Default CLI behavior - no subcommand needed
|
||||
# Import agent_chat only when needed
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
|
||||
# Start agent chat
|
||||
run_cli_entry(resume_conversation_id=args.resume)
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
except EOFError:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
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
|
||||
|
||||
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."""
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openhands_cli import simple_main
|
||||
from openhands_cli.simple_main import main
|
||||
|
||||
|
||||
|
||||
class TestMainEntryPoint:
|
||||
"""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'])
|
||||
def test_main_starts_agent_chat_directly(
|
||||
self, mock_run_agent_chat: MagicMock
|
||||
@ -24,7 +28,7 @@ class TestMainEntryPoint:
|
||||
# Should call run_cli_entry with no resume conversation ID
|
||||
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'])
|
||||
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
|
||||
"""Test that main() handles ImportError gracefully."""
|
||||
@ -36,7 +40,7 @@ class TestMainEntryPoint:
|
||||
|
||||
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'])
|
||||
def test_main_handles_keyboard_interrupt(
|
||||
self, mock_run_agent_chat: MagicMock
|
||||
@ -48,7 +52,7 @@ class TestMainEntryPoint:
|
||||
# Should complete without raising an exception (graceful exit)
|
||||
simple_main.main()
|
||||
|
||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
||||
@patch('openhands_cli.agent_chat.run_cli_entry')
|
||||
@patch('sys.argv', ['openhands'])
|
||||
def test_main_handles_eof_error(self, mock_run_agent_chat: MagicMock) -> None:
|
||||
"""Test that main() handles EOFError gracefully."""
|
||||
@ -58,7 +62,7 @@ class TestMainEntryPoint:
|
||||
# Should complete without raising an exception (graceful exit)
|
||||
simple_main.main()
|
||||
|
||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
||||
@patch('openhands_cli.agent_chat.run_cli_entry')
|
||||
@patch('sys.argv', ['openhands'])
|
||||
def test_main_handles_general_exception(
|
||||
self, mock_run_agent_chat: MagicMock
|
||||
@ -72,7 +76,7 @@ class TestMainEntryPoint:
|
||||
|
||||
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'])
|
||||
def test_main_with_resume_argument(self, mock_run_agent_chat: MagicMock) -> None:
|
||||
"""Test that main() passes resume conversation ID when provided."""
|
||||
@ -86,3 +90,65 @@ class TestMainEntryPoint:
|
||||
mock_run_agent_chat.assert_called_once_with(
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user