diff --git a/openhands/cli/__init__.py b/openhands/cli/__init__.py
deleted file mode 100644
index 9315930b74..0000000000
--- a/openhands/cli/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""OpenHands CLI module."""
diff --git a/openhands/cli/commands.py b/openhands/cli/commands.py
deleted file mode 100644
index e8fc3ef250..0000000000
--- a/openhands/cli/commands.py
+++ /dev/null
@@ -1,905 +0,0 @@
-import asyncio
-import os
-import sys
-from pathlib import Path
-from typing import Any
-
-import tomlkit
-from prompt_toolkit import HTML, print_formatted_text
-from prompt_toolkit.patch_stdout import patch_stdout
-from prompt_toolkit.shortcuts import clear, print_container
-from prompt_toolkit.widgets import Frame, TextArea
-from pydantic import ValidationError
-
-from openhands.cli.settings import (
- display_settings,
- modify_llm_settings_advanced,
- modify_llm_settings_basic,
- modify_search_api_settings,
-)
-from openhands.cli.tui import (
- COLOR_GREY,
- UsageMetrics,
- cli_confirm,
- create_prompt_session,
- display_help,
- display_mcp_errors,
- display_shutdown_message,
- display_status,
- read_prompt_input,
-)
-from openhands.cli.utils import (
- add_local_config_trusted_dir,
- get_local_config_trusted_dirs,
- read_file,
- write_to_file,
-)
-from openhands.core.config import (
- OpenHandsConfig,
-)
-from openhands.core.config.mcp_config import (
- MCPSHTTPServerConfig,
- MCPSSEServerConfig,
- MCPStdioServerConfig,
-)
-from openhands.core.schema import AgentState
-from openhands.core.schema.exit_reason import ExitReason
-from openhands.events import EventSource
-from openhands.events.action import (
- ChangeAgentStateAction,
- LoopRecoveryAction,
- MessageAction,
-)
-from openhands.events.stream import EventStream
-from openhands.storage.settings.file_settings_store import FileSettingsStore
-
-
-async def collect_input(config: OpenHandsConfig, prompt_text: str) -> str | None:
- """Collect user input with cancellation support.
-
- Args:
- config: OpenHands configuration
- prompt_text: Text to display to user
-
- Returns:
- str | None: User input string, or None if user cancelled
- """
- print_formatted_text(prompt_text, end=' ')
- user_input = await read_prompt_input(config, '', multiline=False)
-
- # Check for cancellation
- if user_input.strip().lower() in ['/exit', '/cancel', 'cancel']:
- return None
-
- return user_input.strip()
-
-
-def restart_cli() -> None:
- """Restart the CLI by replacing the current process."""
- print_formatted_text('🔄 Restarting OpenHands CLI...')
-
- # Get the current Python executable and script arguments
- python_executable = sys.executable
- script_args = sys.argv
-
- # Use os.execv to replace the current process
- # This preserves the original command line arguments
- try:
- os.execv(python_executable, [python_executable] + script_args)
- except Exception as e:
- print_formatted_text(f'❌ Failed to restart CLI: {e}')
- print_formatted_text(
- 'Please restart OpenHands manually for changes to take effect.'
- )
-
-
-async def prompt_for_restart(config: OpenHandsConfig) -> bool:
- """Prompt user if they want to restart the CLI and return their choice."""
- print_formatted_text('📝 MCP server configuration updated successfully!')
- print_formatted_text('The changes will take effect after restarting OpenHands.')
-
- prompt_session = create_prompt_session(config)
-
- while True:
- try:
- with patch_stdout():
- response = await prompt_session.prompt_async(
- HTML(
- 'Would you like to restart OpenHands now? (y/n): '
- )
- )
- response = response.strip().lower() if response else ''
-
- if response in ['y', 'yes']:
- return True
- elif response in ['n', 'no']:
- return False
- else:
- print_formatted_text('Please enter "y" for yes or "n" for no.')
- except (KeyboardInterrupt, EOFError):
- return False
-
-
-async def handle_commands(
- command: str,
- event_stream: EventStream,
- usage_metrics: UsageMetrics,
- sid: str,
- config: OpenHandsConfig,
- current_dir: str,
- settings_store: FileSettingsStore,
- agent_state: str,
-) -> tuple[bool, bool, bool, ExitReason]:
- close_repl = False
- reload_microagents = False
- new_session_requested = False
- exit_reason = ExitReason.ERROR
-
- if command == '/exit':
- close_repl = handle_exit_command(
- config,
- event_stream,
- usage_metrics,
- sid,
- )
- if close_repl:
- exit_reason = ExitReason.INTENTIONAL
- elif command == '/help':
- handle_help_command()
- elif command == '/init':
- close_repl, reload_microagents = await handle_init_command(
- config, event_stream, current_dir
- )
- elif command == '/status':
- handle_status_command(usage_metrics, sid)
- elif command == '/new':
- close_repl, new_session_requested = handle_new_command(
- config, event_stream, usage_metrics, sid
- )
- if close_repl:
- exit_reason = ExitReason.INTENTIONAL
- elif command == '/settings':
- await handle_settings_command(config, settings_store)
- elif command.startswith('/resume'):
- close_repl, new_session_requested = await handle_resume_command(
- command, event_stream, agent_state
- )
- elif command == '/mcp':
- await handle_mcp_command(config)
- else:
- close_repl = True
- action = MessageAction(content=command)
- event_stream.add_event(action, EventSource.USER)
-
- return close_repl, reload_microagents, new_session_requested, exit_reason
-
-
-def handle_exit_command(
- config: OpenHandsConfig,
- event_stream: EventStream,
- usage_metrics: UsageMetrics,
- sid: str,
-) -> bool:
- close_repl = False
-
- confirm_exit = (
- cli_confirm(config, '\nTerminate session?', ['Yes, proceed', 'No, dismiss'])
- == 0
- )
-
- if confirm_exit:
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.STOPPED),
- EventSource.ENVIRONMENT,
- )
- display_shutdown_message(usage_metrics, sid)
- close_repl = True
-
- return close_repl
-
-
-def handle_help_command() -> None:
- display_help()
-
-
-async def handle_init_command(
- config: OpenHandsConfig, event_stream: EventStream, current_dir: str
-) -> tuple[bool, bool]:
- REPO_MD_CREATE_PROMPT = """
- Please explore this repository. Create the file .openhands/microagents/repo.md with:
- - A description of the project
- - An overview of the file structure
- - Any information on how to run tests or other relevant commands
- - Any other information that would be helpful to a brand new developer
- Keep it short--just a few paragraphs will do.
- """
- close_repl = False
- reload_microagents = False
-
- if config.runtime in ('local', 'cli'):
- init_repo = await init_repository(config, current_dir)
- if init_repo:
- event_stream.add_event(
- MessageAction(content=REPO_MD_CREATE_PROMPT),
- EventSource.USER,
- )
- reload_microagents = True
- close_repl = True
- else:
- print_formatted_text(
- '\nRepository initialization through the CLI is only supported for CLI and local runtimes.\n'
- )
-
- return close_repl, reload_microagents
-
-
-def handle_status_command(usage_metrics: UsageMetrics, sid: str) -> None:
- display_status(usage_metrics, sid)
-
-
-def handle_new_command(
- config: OpenHandsConfig,
- event_stream: EventStream,
- usage_metrics: UsageMetrics,
- sid: str,
-) -> tuple[bool, bool]:
- close_repl = False
- new_session_requested = False
-
- new_session_requested = (
- cli_confirm(
- config,
- '\nCurrent session will be terminated and you will lose the conversation history.\n\nContinue?',
- ['Yes, proceed', 'No, dismiss'],
- )
- == 0
- )
-
- if new_session_requested:
- close_repl = True
- new_session_requested = True
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.STOPPED),
- EventSource.ENVIRONMENT,
- )
- display_shutdown_message(usage_metrics, sid)
-
- return close_repl, new_session_requested
-
-
-async def handle_settings_command(
- config: OpenHandsConfig,
- settings_store: FileSettingsStore,
-) -> None:
- display_settings(config)
- modify_settings = cli_confirm(
- config,
- '\nWhich settings would you like to modify?',
- [
- 'LLM (Basic)',
- 'LLM (Advanced)',
- 'Search API (Optional)',
- 'Go back',
- ],
- )
-
- if modify_settings == 0:
- await modify_llm_settings_basic(config, settings_store)
- elif modify_settings == 1:
- await modify_llm_settings_advanced(config, settings_store)
- elif modify_settings == 2:
- await modify_search_api_settings(config, settings_store)
-
-
-# FIXME: Currently there's an issue with the actual 'resume' behavior.
-# Setting the agent state to RUNNING will currently freeze the agent without continuing with the rest of the task.
-# This is a workaround to handle the resume command for the time being. Replace user message with the state change event once the issue is fixed.
-async def handle_resume_command(
- command: str,
- event_stream: EventStream,
- agent_state: str,
-) -> tuple[bool, bool]:
- close_repl = True
- new_session_requested = False
-
- if agent_state != AgentState.PAUSED:
- close_repl = False
- print_formatted_text(
- HTML(
- 'Error: Agent is not paused. /resume command is only available when agent is paused.'
- )
- )
- return close_repl, new_session_requested
-
- # Check if this is a loop recovery resume with an option
- if command.strip() != '/resume':
- # Parse the option from the command (e.g., '/resume 1', '/resume 2')
- parts = command.strip().split()
- if len(parts) == 2 and parts[1] in ['1', '2']:
- option = parts[1]
- # Send the option as a message to be handled by the controller
- event_stream.add_event(
- LoopRecoveryAction(option=int(option)),
- EventSource.USER,
- )
- else:
- # Invalid format, send as regular resume
- event_stream.add_event(
- MessageAction(content='continue'),
- EventSource.USER,
- )
- else:
- # Regular resume without loop recovery option
- event_stream.add_event(
- MessageAction(content='continue'),
- EventSource.USER,
- )
-
- # event_stream.add_event(
- # ChangeAgentStateAction(AgentState.RUNNING),
- # EventSource.ENVIRONMENT,
- # )
-
- return close_repl, new_session_requested
-
-
-async def init_repository(config: OpenHandsConfig, current_dir: str) -> bool:
- repo_file_path = Path(current_dir) / '.openhands' / 'microagents' / 'repo.md'
- init_repo = False
-
- if repo_file_path.exists():
- try:
- # Path.exists() ensures repo_file_path is not None, so we can safely pass it to read_file
- content = await asyncio.get_event_loop().run_in_executor(
- None, read_file, repo_file_path
- )
-
- print_formatted_text(
- 'Repository instructions file (repo.md) already exists.\n'
- )
-
- container = Frame(
- TextArea(
- text=content,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title='Repository Instructions (repo.md)',
- style=f'fg:{COLOR_GREY}',
- )
- print_container(container)
- print_formatted_text('') # Add a newline after the frame
-
- init_repo = (
- cli_confirm(
- config,
- 'Do you want to re-initialize?',
- ['Yes, re-initialize', 'No, dismiss'],
- )
- == 0
- )
-
- if init_repo:
- write_to_file(repo_file_path, '')
- except Exception:
- print_formatted_text('Error reading repository instructions file (repo.md)')
- init_repo = False
- else:
- print_formatted_text(
- '\nRepository instructions file will be created by exploring the repository.\n'
- )
-
- init_repo = (
- cli_confirm(
- config,
- 'Do you want to proceed?',
- ['Yes, create', 'No, dismiss'],
- )
- == 0
- )
-
- return init_repo
-
-
-def check_folder_security_agreement(config: OpenHandsConfig, current_dir: str) -> bool:
- # Directories trusted by user for the CLI to use as workspace
- # Config from ~/.openhands/config.toml overrides the app config
-
- app_config_trusted_dirs = config.sandbox.trusted_dirs
- local_config_trusted_dirs = get_local_config_trusted_dirs()
-
- trusted_dirs = local_config_trusted_dirs
- if not local_config_trusted_dirs:
- trusted_dirs = app_config_trusted_dirs
-
- is_trusted = current_dir in trusted_dirs
-
- if not is_trusted:
- security_frame = Frame(
- TextArea(
- text=(
- f' Do you trust the files in this folder?\n\n'
- f' {current_dir}\n\n'
- ' OpenHands may read and execute files in this folder with your permission.'
- ),
- style=COLOR_GREY,
- read_only=True,
- wrap_lines=True,
- ),
- style=f'fg:{COLOR_GREY}',
- )
-
- clear()
- print_container(security_frame)
- print_formatted_text('')
-
- confirm = (
- cli_confirm(
- config, 'Do you wish to continue?', ['Yes, proceed', 'No, exit']
- )
- == 0
- )
-
- if confirm:
- add_local_config_trusted_dir(current_dir)
-
- return confirm
-
- return True
-
-
-async def handle_mcp_command(config: OpenHandsConfig) -> None:
- """Handle MCP command with interactive menu."""
- action = cli_confirm(
- config,
- 'MCP Server Configuration',
- [
- 'List configured servers',
- 'Add new server',
- 'Remove server',
- 'View errors',
- 'Go back',
- ],
- )
-
- if action == 0: # List
- display_mcp_servers(config)
- elif action == 1: # Add
- await add_mcp_server(config)
- elif action == 2: # Remove
- await remove_mcp_server(config)
- elif action == 3: # View errors
- handle_mcp_errors_command()
- # action == 4 is "Go back", do nothing
-
-
-def display_mcp_servers(config: OpenHandsConfig) -> None:
- """Display MCP server configuration information."""
- mcp_config = config.mcp
-
- # Count the different types of servers
- sse_count = len(mcp_config.sse_servers)
- stdio_count = len(mcp_config.stdio_servers)
- shttp_count = len(mcp_config.shttp_servers)
- total_count = sse_count + stdio_count + shttp_count
-
- if total_count == 0:
- print_formatted_text(
- 'No custom MCP servers configured. See the documentation to learn more:\n'
- ' https://docs.all-hands.dev/usage/how-to/cli-mode#using-mcp-servers'
- )
- else:
- print_formatted_text(
- f'Configured MCP servers:\n'
- f' • SSE servers: {sse_count}\n'
- f' • Stdio servers: {stdio_count}\n'
- f' • SHTTP servers: {shttp_count}\n'
- f' • Total: {total_count}'
- )
-
- # Show details for each type if they exist
- if sse_count > 0:
- print_formatted_text('SSE Servers:')
- for idx, sse_server in enumerate(mcp_config.sse_servers, 1):
- print_formatted_text(f' {idx}. {sse_server.url}')
- print_formatted_text('')
-
- if stdio_count > 0:
- print_formatted_text('Stdio Servers:')
- for idx, stdio_server in enumerate(mcp_config.stdio_servers, 1):
- print_formatted_text(
- f' {idx}. {stdio_server.name} ({stdio_server.command})'
- )
- print_formatted_text('')
-
- if shttp_count > 0:
- print_formatted_text('SHTTP Servers:')
- for idx, shttp_server in enumerate(mcp_config.shttp_servers, 1):
- print_formatted_text(f' {idx}. {shttp_server.url}')
- print_formatted_text('')
-
-
-def handle_mcp_errors_command() -> None:
- """Display MCP connection errors."""
- display_mcp_errors()
-
-
-def get_config_file_path() -> Path:
- """Get the path to the config file. By default, we use config.toml in the current working directory. If not found, we use ~/.openhands/config.toml."""
- # Check if config.toml exists in the current directory
- current_dir = Path.cwd() / 'config.toml'
- if current_dir.exists():
- return current_dir
-
- # Fallback to the user's home directory
- return Path.home() / '.openhands' / 'config.toml'
-
-
-def load_config_file(file_path: Path) -> dict:
- """Load the config file, creating it if it doesn't exist."""
- if file_path.exists():
- try:
- with open(file_path, 'r') as f:
- return dict(tomlkit.load(f))
- except Exception:
- pass
-
- # Create directory if it doesn't exist
- file_path.parent.mkdir(parents=True, exist_ok=True)
- return {}
-
-
-def save_config_file(config_data: dict, file_path: Path) -> None:
- """Save the config file with proper MCP formatting."""
- doc = tomlkit.document()
-
- for key, value in config_data.items():
- if key == 'mcp':
- # Handle MCP section specially
- mcp_section = tomlkit.table()
-
- for mcp_key, mcp_value in value.items():
- # Create array with inline tables for server configurations
- server_array = tomlkit.array()
- for server_config in mcp_value:
- if isinstance(server_config, dict):
- # Create inline table for each server
- inline_table = tomlkit.inline_table()
- for server_key, server_val in server_config.items():
- inline_table[server_key] = server_val
- server_array.append(inline_table)
- else:
- # Handle non-dict values (like string URLs)
- server_array.append(server_config)
- mcp_section[mcp_key] = server_array
-
- doc[key] = mcp_section
- else:
- # Handle non-MCP sections normally
- doc[key] = value
-
- with open(file_path, 'w') as f:
- f.write(tomlkit.dumps(doc))
-
-
-def _ensure_mcp_config_structure(config_data: dict) -> None:
- """Ensure MCP configuration structure exists in config data."""
- if 'mcp' not in config_data:
- config_data['mcp'] = {}
-
-
-def _add_server_to_config(server_type: str, server_config: dict) -> Path:
- """Add a server configuration to the config file."""
- config_file_path = get_config_file_path()
- config_data = load_config_file(config_file_path)
- _ensure_mcp_config_structure(config_data)
-
- if server_type not in config_data['mcp']:
- config_data['mcp'][server_type] = []
-
- config_data['mcp'][server_type].append(server_config)
- save_config_file(config_data, config_file_path)
-
- return config_file_path
-
-
-async def add_mcp_server(config: OpenHandsConfig) -> None:
- """Add a new MCP server configuration."""
- # Choose transport type
- transport_type = cli_confirm(
- config,
- 'Select MCP server transport type:',
- [
- 'SSE (Server-Sent Events)',
- 'Stdio (Standard Input/Output)',
- 'SHTTP (Streamable HTTP)',
- 'Cancel',
- ],
- )
-
- if transport_type == 3: # Cancel
- return
-
- try:
- if transport_type == 0: # SSE
- await add_sse_server(config)
- elif transport_type == 1: # Stdio
- await add_stdio_server(config)
- elif transport_type == 2: # SHTTP
- await add_shttp_server(config)
- except Exception as e:
- print_formatted_text(f'Error adding MCP server: {e}')
-
-
-async def add_sse_server(config: OpenHandsConfig) -> None:
- """Add an SSE MCP server."""
- print_formatted_text('Adding SSE MCP Server')
-
- while True: # Retry loop for the entire form
- # Collect all inputs
- url = await collect_input(config, '\nEnter server URL:')
- if url is None:
- print_formatted_text('Operation cancelled.')
- return
-
- api_key = await collect_input(
- config, '\nEnter API key (optional, press Enter to skip):'
- )
- if api_key is None:
- print_formatted_text('Operation cancelled.')
- return
-
- # Convert empty string to None for optional field
- api_key = api_key if api_key else None
-
- # Validate all inputs at once
- try:
- server = MCPSSEServerConfig(url=url, api_key=api_key)
- break # Success - exit retry loop
-
- except ValidationError as e:
- # Show all errors at once
- print_formatted_text('❌ Please fix the following errors:')
- for error in e.errors():
- field = error['loc'][0] if error['loc'] else 'unknown'
- print_formatted_text(f' • {field}: {error["msg"]}')
-
- if cli_confirm(config, '\nTry again?') != 0:
- print_formatted_text('Operation cancelled.')
- return
-
- # Save to config file
- server_config = {'url': server.url}
- if server.api_key:
- server_config['api_key'] = server.api_key
-
- config_file_path = _add_server_to_config('sse_servers', server_config)
- print_formatted_text(f'✓ SSE MCP server added to {config_file_path}: {server.url}')
-
- # Prompt for restart
- if await prompt_for_restart(config):
- restart_cli()
-
-
-async def add_stdio_server(config: OpenHandsConfig) -> None:
- """Add a Stdio MCP server."""
- print_formatted_text('Adding Stdio MCP Server')
-
- # Get existing server names to check for duplicates
- existing_names = [server.name for server in config.mcp.stdio_servers]
-
- while True: # Retry loop for the entire form
- # Collect all inputs
- name = await collect_input(config, '\nEnter server name:')
- if name is None:
- print_formatted_text('Operation cancelled.')
- return
-
- command = await collect_input(config, "\nEnter command (e.g., 'uvx', 'npx'):")
- if command is None:
- print_formatted_text('Operation cancelled.')
- return
-
- args_input = await collect_input(
- config,
- '\nEnter arguments (optional, e.g., "-y server-package arg1"):',
- )
- if args_input is None:
- print_formatted_text('Operation cancelled.')
- return
-
- env_input = await collect_input(
- config,
- '\nEnter environment variables (KEY=VALUE format, comma-separated, optional):',
- )
- if env_input is None:
- print_formatted_text('Operation cancelled.')
- return
-
- # Check for duplicate server names
- if name in existing_names:
- print_formatted_text(f"❌ Server name '{name}' already exists.")
- if cli_confirm(config, '\nTry again?') != 0:
- print_formatted_text('Operation cancelled.')
- return
- continue
-
- # Validate all inputs at once
- try:
- server = MCPStdioServerConfig(
- name=name,
- command=command,
- args=args_input, # type: ignore # Will be parsed by Pydantic validator
- env=env_input, # type: ignore # Will be parsed by Pydantic validator
- )
- break # Success - exit retry loop
-
- except ValidationError as e:
- # Show all errors at once
- print_formatted_text('❌ Please fix the following errors:')
- for error in e.errors():
- field = error['loc'][0] if error['loc'] else 'unknown'
- print_formatted_text(f' • {field}: {error["msg"]}')
-
- if cli_confirm(config, '\nTry again?') != 0:
- print_formatted_text('Operation cancelled.')
- return
-
- # Save to config file
- server_config: dict[str, Any] = {
- 'name': server.name,
- 'command': server.command,
- }
- if server.args:
- server_config['args'] = server.args
- if server.env:
- server_config['env'] = server.env
-
- config_file_path = _add_server_to_config('stdio_servers', server_config)
- print_formatted_text(
- f'✓ Stdio MCP server added to {config_file_path}: {server.name}'
- )
-
- # Prompt for restart
- if await prompt_for_restart(config):
- restart_cli()
-
-
-async def add_shttp_server(config: OpenHandsConfig) -> None:
- """Add an SHTTP MCP server."""
- print_formatted_text('Adding SHTTP MCP Server')
-
- while True: # Retry loop for the entire form
- # Collect all inputs
- url = await collect_input(config, '\nEnter server URL:')
- if url is None:
- print_formatted_text('Operation cancelled.')
- return
-
- api_key = await collect_input(
- config, '\nEnter API key (optional, press Enter to skip):'
- )
- if api_key is None:
- print_formatted_text('Operation cancelled.')
- return
-
- # Convert empty string to None for optional field
- api_key = api_key if api_key else None
-
- # Validate all inputs at once
- try:
- server = MCPSHTTPServerConfig(url=url, api_key=api_key)
- break # Success - exit retry loop
-
- except ValidationError as e:
- # Show all errors at once
- print_formatted_text('❌ Please fix the following errors:')
- for error in e.errors():
- field = error['loc'][0] if error['loc'] else 'unknown'
- print_formatted_text(f' • {field}: {error["msg"]}')
-
- if cli_confirm(config, '\nTry again?') != 0:
- print_formatted_text('Operation cancelled.')
- return
-
- # Save to config file
- server_config = {'url': server.url}
- if server.api_key:
- server_config['api_key'] = server.api_key
-
- config_file_path = _add_server_to_config('shttp_servers', server_config)
- print_formatted_text(
- f'✓ SHTTP MCP server added to {config_file_path}: {server.url}'
- )
-
- # Prompt for restart
- if await prompt_for_restart(config):
- restart_cli()
-
-
-async def remove_mcp_server(config: OpenHandsConfig) -> None:
- """Remove an MCP server configuration."""
- mcp_config = config.mcp
-
- # Collect all servers with their types
- servers: list[tuple[str, str, object]] = []
-
- # Add SSE servers
- for sse_server in mcp_config.sse_servers:
- servers.append(('SSE', sse_server.url, sse_server))
-
- # Add Stdio servers
- for stdio_server in mcp_config.stdio_servers:
- servers.append(('Stdio', stdio_server.name, stdio_server))
-
- # Add SHTTP servers
- for shttp_server in mcp_config.shttp_servers:
- servers.append(('SHTTP', shttp_server.url, shttp_server))
-
- if not servers:
- print_formatted_text('No MCP servers configured to remove.')
- return
-
- # Create choices for the user
- choices = []
- for server_type, identifier, _ in servers:
- choices.append(f'{server_type}: {identifier}')
- choices.append('Cancel')
-
- # Let user choose which server to remove
- choice = cli_confirm(config, 'Select MCP server to remove:', choices)
-
- if choice == len(choices) - 1: # Cancel
- return
-
- # Remove the selected server
- server_type, identifier, _ = servers[choice]
-
- # Confirm removal
- confirm = cli_confirm(
- config,
- f'Are you sure you want to remove {server_type} server "{identifier}"?',
- ['Yes, remove', 'Cancel'],
- )
-
- if confirm == 1: # Cancel
- return
-
- # Load config file and remove the server
- config_file_path = get_config_file_path()
- config_data = load_config_file(config_file_path)
-
- _ensure_mcp_config_structure(config_data)
-
- removed = False
-
- if server_type == 'SSE' and 'sse_servers' in config_data['mcp']:
- config_data['mcp']['sse_servers'] = [
- s for s in config_data['mcp']['sse_servers'] if s.get('url') != identifier
- ]
- removed = True
- elif server_type == 'Stdio' and 'stdio_servers' in config_data['mcp']:
- config_data['mcp']['stdio_servers'] = [
- s
- for s in config_data['mcp']['stdio_servers']
- if s.get('name') != identifier
- ]
- removed = True
- elif server_type == 'SHTTP' and 'shttp_servers' in config_data['mcp']:
- config_data['mcp']['shttp_servers'] = [
- s for s in config_data['mcp']['shttp_servers'] if s.get('url') != identifier
- ]
- removed = True
-
- if removed:
- save_config_file(config_data, config_file_path)
- print_formatted_text(
- f'✓ {server_type} MCP server "{identifier}" removed from {config_file_path}.'
- )
-
- # Prompt for restart
- if await prompt_for_restart(config):
- restart_cli()
- else:
- print_formatted_text(f'Failed to remove {server_type} server "{identifier}".')
diff --git a/openhands/cli/deprecation_warning.py b/openhands/cli/deprecation_warning.py
deleted file mode 100644
index 3188e45f78..0000000000
--- a/openhands/cli/deprecation_warning.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""Deprecation warning utilities for the old OpenHands CLI."""
-
-import sys
-
-from prompt_toolkit import print_formatted_text
-from prompt_toolkit.formatted_text import HTML
-
-
-def display_deprecation_warning() -> None:
- """Display a prominent deprecation warning for the old CLI interface."""
- warning_lines = [
- '',
- '⚠️ DEPRECATION WARNING ⚠️',
- '',
- 'This CLI interface is deprecated and will be removed in a future version.',
- 'Please migrate to the new OpenHands CLI:',
- '',
- 'For more information, visit: https://docs.all-hands.dev/usage/how-to/cli-mode',
- '',
- '=' * 70,
- '',
- ]
-
- # Print warning with prominent styling
- for line in warning_lines:
- if 'DEPRECATION WARNING' in line:
- print_formatted_text(HTML(f'{line}'))
- elif line.startswith(' •'):
- print_formatted_text(HTML(f'{line}'))
- elif 'https://' in line:
- print_formatted_text(HTML(f'{line}'))
- elif line.startswith('='):
- print_formatted_text(HTML(f'{line}'))
- else:
- print_formatted_text(HTML(f'{line}'))
-
- # Flush to ensure immediate display
- sys.stdout.flush()
diff --git a/openhands/cli/entry.py b/openhands/cli/entry.py
deleted file mode 100644
index 8d9a0c0dcf..0000000000
--- a/openhands/cli/entry.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""Main entry point for OpenHands CLI with subcommand support."""
-
-import sys
-
-# Import only essential modules for CLI help
-# Other imports are deferred until they're actually needed
-import openhands
-import openhands.cli.suppress_warnings # noqa: F401
-from openhands.cli.fast_help import handle_fast_commands
-
-
-def main():
- """Main entry point with subcommand support and backward compatibility."""
- # Fast path for help and version commands
- if handle_fast_commands():
- sys.exit(0)
-
- # Import parser only when needed - only if we're not just showing help
- from openhands.core.config import get_cli_parser
-
- parser = get_cli_parser()
-
- # Special case: no subcommand provided, simulate "openhands cli"
- if len(sys.argv) == 1 or (
- len(sys.argv) > 1 and sys.argv[1] not in ['cli', 'serve']
- ):
- # Inject 'cli' as default command
- sys.argv.insert(1, 'cli')
-
- args = parser.parse_args()
-
- if hasattr(args, 'version') and args.version:
- from openhands import get_version
-
- print(f'OpenHands CLI version: {get_version()}')
- sys.exit(0)
-
- if args.command == 'serve':
- # Import gui_launcher only when needed
- from openhands.cli.gui_launcher import launch_gui_server
-
- launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu)
- elif args.command == 'cli' or args.command is None:
- # Import main only when needed
- from openhands.cli.main import run_cli_command
-
- run_cli_command(args)
- else:
- parser.print_help()
- sys.exit(1)
-
-
-if __name__ == '__main__':
- main()
diff --git a/openhands/cli/fast_help.py b/openhands/cli/fast_help.py
deleted file mode 100644
index 8171978578..0000000000
--- a/openhands/cli/fast_help.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""Fast help module for OpenHands CLI.
-
-This module provides a lightweight implementation of the CLI help and version commands
-without loading all the dependencies, which significantly improves the
-performance of `openhands --help` and `openhands --version`.
-
-The approach is to create a simplified version of the CLI parser that only includes
-the necessary options for displaying help and version information. This avoids loading
-the full OpenHands codebase, which can take several seconds.
-
-This implementation addresses GitHub issue #10698, which reported that
-`openhands --help` was taking around 20 seconds to run.
-"""
-
-import argparse
-import sys
-
-from openhands.cli.deprecation_warning import display_deprecation_warning
-
-
-def get_fast_cli_parser() -> argparse.ArgumentParser:
- """Create a lightweight argument parser for CLI help command."""
- # Create a description with welcome message explaining available commands
- description = (
- 'Welcome to OpenHands: Code Less, Make More\n\n'
- 'OpenHands supports two main commands:\n'
- ' serve - Launch the OpenHands GUI server (web interface)\n'
- ' cli - Run OpenHands in CLI mode (terminal interface)\n\n'
- 'Running "openhands" without a command is the same as "openhands cli"'
- )
-
- parser = argparse.ArgumentParser(
- description=description,
- prog='openhands',
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog='For more information about a command, run: openhands COMMAND --help',
- )
-
- # Create subparsers
- subparsers = parser.add_subparsers(
- dest='command',
- title='commands',
- description='OpenHands supports two main commands:',
- metavar='COMMAND',
- )
-
- # 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',
- 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,
- )
-
- # Add 'cli' subcommand with common arguments
- cli_parser = subparsers.add_parser(
- 'cli', help='Run OpenHands in CLI mode (terminal interface)'
- )
-
- # Add common arguments
- cli_parser.add_argument(
- '--config-file',
- type=str,
- default='config.toml',
- help='Path to the config file (default: config.toml in the current directory)',
- )
- cli_parser.add_argument(
- '-t',
- '--task',
- type=str,
- default='',
- help='The task for the agent to perform',
- )
- cli_parser.add_argument(
- '-f',
- '--file',
- type=str,
- help='Path to a file containing the task. Overrides -t if both are provided.',
- )
- cli_parser.add_argument(
- '-n',
- '--name',
- help='Session name',
- type=str,
- default='',
- )
- cli_parser.add_argument(
- '--log-level',
- help='Set the log level',
- type=str,
- default=None,
- )
- cli_parser.add_argument(
- '-l',
- '--llm-config',
- default=None,
- type=str,
- help='Replace default LLM ([llm] section in config.toml) config with the specified LLM config, e.g. "llama3" for [llm.llama3] section in config.toml',
- )
- cli_parser.add_argument(
- '--agent-config',
- default=None,
- type=str,
- help='Replace default Agent ([agent] section in config.toml) config with the specified Agent config, e.g. "CodeAct" for [agent.CodeAct] section in config.toml',
- )
- cli_parser.add_argument(
- '-v', '--version', action='store_true', help='Show version information'
- )
- cli_parser.add_argument(
- '--override-cli-mode',
- help='Override the default settings for CLI mode',
- type=bool,
- default=False,
- )
- parser.add_argument(
- '--conversation',
- help='The conversation id to continue',
- type=str,
- default=None,
- )
-
- return parser
-
-
-def get_fast_subparser(
- parser: argparse.ArgumentParser, name: str
-) -> argparse.ArgumentParser:
- """Get a subparser by name."""
- for action in parser._actions:
- if isinstance(action, argparse._SubParsersAction):
- if name in action.choices:
- return action.choices[name]
- raise ValueError(f"Subparser '{name}' not found")
-
-
-def handle_fast_commands() -> bool:
- """Handle fast path commands like help and version.
-
- Returns:
- bool: True if a command was handled, False otherwise.
- """
- # Handle --help or -h
- if len(sys.argv) == 2 and sys.argv[1] in ('--help', '-h'):
- display_deprecation_warning()
- parser = get_fast_cli_parser()
-
- # Print top-level help
- print(parser.format_help())
-
- # Also print help for `cli` subcommand
- print('\n' + '=' * 80)
- print('CLI command help:\n')
-
- cli_parser = get_fast_subparser(parser, 'cli')
- print(cli_parser.format_help())
-
- return True
-
- # Handle --version or -v
- if len(sys.argv) == 2 and sys.argv[1] in ('--version', '-v'):
- from openhands import get_version
-
- print(f'OpenHands CLI version: {get_version()}')
-
- display_deprecation_warning()
-
- return True
-
- return False
diff --git a/openhands/cli/gui_launcher.py b/openhands/cli/gui_launcher.py
deleted file mode 100644
index 7946bc8796..0000000000
--- a/openhands/cli/gui_launcher.py
+++ /dev/null
@@ -1,210 +0,0 @@
-"""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 import __version__
-
-
-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'Running Docker command: {cmd_str}'
-
-
-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('❌ Docker is not installed or not in PATH.')
- )
- print_formatted_text(
- HTML(
- 'Please install Docker first: https://docs.docker.com/get-docker/'
- )
- )
- 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('❌ Docker daemon is not running.')
- )
- print_formatted_text(
- HTML('Please start Docker and try again.')
- )
- return False
- except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
- print_formatted_text(
- HTML('❌ Failed to check Docker status.')
- )
- print_formatted_text(HTML(f'Error: {e}'))
- return False
-
- return True
-
-
-def ensure_config_dir_exists() -> Path:
- """Ensure the OpenHands configuration directory exists and return its path."""
- config_dir = Path.home() / '.openhands'
- config_dir.mkdir(exist_ok=True)
- return config_dir
-
-
-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('🚀 Launching OpenHands GUI server...')
- )
- 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 = __version__
- runtime_image = f'docker.all-hands.dev/openhands/runtime:{version}-nikolaik'
- app_image = f'docker.all-hands.dev/openhands/openhands:{version}'
-
- print_formatted_text(HTML('Pulling required Docker images...'))
-
- # 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)
- except subprocess.CalledProcessError:
- print_formatted_text(
- HTML('❌ Failed to pull runtime image.')
- )
- sys.exit(1)
-
- print_formatted_text('')
- print_formatted_text(
- HTML('✅ Starting OpenHands GUI server...')
- )
- print_formatted_text(
- HTML('The server will be available at: http://localhost:3000')
- )
- print_formatted_text(HTML('Press Ctrl+C to stop the server.'))
- 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('🖥️ Enabling GPU support via nvidia-docker...')
- )
- # 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'📂 Mounting current directory: {cwd} to /workspace'
- )
- )
-
- 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('❌ Failed to start OpenHands GUI server.')
- )
- print_formatted_text(HTML(f'Error: {e}'))
- sys.exit(1)
- except KeyboardInterrupt:
- print_formatted_text('')
- print_formatted_text(
- HTML('✓ OpenHands GUI server stopped successfully.')
- )
- sys.exit(0)
diff --git a/openhands/cli/main.py b/openhands/cli/main.py
deleted file mode 100644
index 604776e07c..0000000000
--- a/openhands/cli/main.py
+++ /dev/null
@@ -1,801 +0,0 @@
-import openhands.cli.suppress_warnings # noqa: F401 # isort: skip
-
-import asyncio
-import logging
-import os
-import sys
-
-from prompt_toolkit import print_formatted_text
-from prompt_toolkit.formatted_text import HTML
-from prompt_toolkit.shortcuts import clear
-
-import openhands.agenthub # noqa F401 (we import this to get the agents registered)
-from openhands.cli.commands import (
- check_folder_security_agreement,
- handle_commands,
-)
-from openhands.cli.deprecation_warning import display_deprecation_warning
-from openhands.cli.settings import modify_llm_settings_basic
-from openhands.cli.shell_config import (
- ShellConfigManager,
- add_aliases_to_shell_config,
- alias_setup_declined,
- aliases_exist_in_shell_config,
- mark_alias_setup_declined,
-)
-from openhands.cli.tui import (
- UsageMetrics,
- cli_confirm,
- display_agent_running_message,
- display_banner,
- display_event,
- display_initial_user_prompt,
- display_initialization_animation,
- display_runtime_initialization_message,
- display_welcome_message,
- read_confirmation_input,
- read_prompt_input,
- start_pause_listener,
- stop_pause_listener,
- update_streaming_output,
-)
-from openhands.cli.utils import (
- update_usage_metrics,
-)
-from openhands.cli.vscode_extension import attempt_vscode_extension_install
-from openhands.controller import AgentController
-from openhands.controller.agent import Agent
-from openhands.core.config import (
- OpenHandsConfig,
- setup_config_from_args,
-)
-from openhands.core.config.condenser_config import NoOpCondenserConfig
-from openhands.core.config.mcp_config import (
- OpenHandsMCPConfigImpl,
-)
-from openhands.core.config.utils import finalize_config
-from openhands.core.logger import openhands_logger as logger
-from openhands.core.loop import run_agent_until_done
-from openhands.core.schema import AgentState
-from openhands.core.schema.exit_reason import ExitReason
-from openhands.core.setup import (
- create_agent,
- create_controller,
- create_memory,
- create_runtime,
- generate_sid,
- initialize_repository_for_runtime,
-)
-from openhands.events import EventSource, EventStreamSubscriber
-from openhands.events.action import (
- ActionSecurityRisk,
- ChangeAgentStateAction,
- MessageAction,
-)
-from openhands.events.event import Event
-from openhands.events.observation import (
- AgentStateChangedObservation,
-)
-from openhands.io import read_task
-from openhands.mcp import add_mcp_tools_to_agent
-from openhands.mcp.error_collector import mcp_error_collector
-from openhands.memory.condenser.impl.llm_summarizing_condenser import (
- LLMSummarizingCondenserConfig,
-)
-from openhands.microagent.microagent import BaseMicroagent
-from openhands.runtime import get_runtime_cls
-from openhands.runtime.base import Runtime
-from openhands.storage.settings.file_settings_store import FileSettingsStore
-from openhands.utils.utils import create_registry_and_conversation_stats
-
-
-async def cleanup_session(
- loop: asyncio.AbstractEventLoop,
- agent: Agent,
- runtime: Runtime,
- controller: AgentController,
-) -> None:
- """Clean up all resources from the current session."""
- event_stream = runtime.event_stream
- end_state = controller.get_state()
- end_state.save_to_session(
- event_stream.sid,
- event_stream.file_store,
- event_stream.user_id,
- )
-
- try:
- current_task = asyncio.current_task(loop)
- pending = [task for task in asyncio.all_tasks(loop) if task is not current_task]
-
- if pending:
- done, pending_set = await asyncio.wait(set(pending), timeout=2.0)
- pending = list(pending_set)
-
- for task in pending:
- task.cancel()
-
- agent.reset()
- runtime.close()
- await controller.close()
-
- except Exception as e:
- logger.error(f'Error during session cleanup: {e}')
-
-
-async def run_session(
- loop: asyncio.AbstractEventLoop,
- config: OpenHandsConfig,
- settings_store: FileSettingsStore,
- current_dir: str,
- task_content: str | None = None,
- conversation_instructions: str | None = None,
- session_name: str | None = None,
- skip_banner: bool = False,
- conversation_id: str | None = None,
-) -> bool:
- reload_microagents = False
- new_session_requested = False
- exit_reason = ExitReason.INTENTIONAL
-
- sid = conversation_id or generate_sid(config, session_name)
- is_loaded = asyncio.Event()
- is_paused = asyncio.Event() # Event to track agent pause requests
- always_confirm_mode = False # Flag to enable always confirm mode
- auto_highrisk_confirm_mode = (
- False # Flag to enable auto_highrisk confirm mode (only ask for HIGH risk)
- )
-
- # Show runtime initialization message
- display_runtime_initialization_message(config.runtime)
-
- # Show Initialization loader
- loop.run_in_executor(
- None, display_initialization_animation, 'Initializing...', is_loaded
- )
-
- llm_registry, conversation_stats, config = create_registry_and_conversation_stats(
- config,
- sid,
- None,
- )
-
- agent = create_agent(config, llm_registry)
- runtime = create_runtime(
- config,
- llm_registry,
- sid=sid,
- headless_mode=True,
- agent=agent,
- )
-
- def stream_to_console(output: str) -> None:
- # Instead of printing to stdout, pass the string to the TUI module
- update_streaming_output(output)
-
- runtime.subscribe_to_shell_stream(stream_to_console)
-
- controller, initial_state = create_controller(
- agent, runtime, config, conversation_stats
- )
-
- event_stream = runtime.event_stream
-
- usage_metrics = UsageMetrics()
-
- async def prompt_for_next_task(agent_state: str) -> None:
- nonlocal reload_microagents, new_session_requested, exit_reason
- while True:
- next_message = await read_prompt_input(
- config, agent_state, multiline=config.cli_multiline_input
- )
-
- if not next_message.strip():
- continue
-
- (
- close_repl,
- reload_microagents,
- new_session_requested,
- exit_reason,
- ) = await handle_commands(
- next_message,
- event_stream,
- usage_metrics,
- sid,
- config,
- current_dir,
- settings_store,
- agent_state,
- )
-
- if close_repl:
- return
-
- async def on_event_async(event: Event) -> None:
- nonlocal \
- reload_microagents, \
- is_paused, \
- always_confirm_mode, \
- auto_highrisk_confirm_mode
- display_event(event, config)
- update_usage_metrics(event, usage_metrics)
-
- if isinstance(event, AgentStateChangedObservation):
- if event.agent_state not in [AgentState.RUNNING, AgentState.PAUSED]:
- await stop_pause_listener()
-
- if isinstance(event, AgentStateChangedObservation):
- if event.agent_state in [
- AgentState.AWAITING_USER_INPUT,
- AgentState.FINISHED,
- ]:
- # If the agent is paused, do not prompt for input as it's already handled by PAUSED state change
- if is_paused.is_set():
- return
-
- # Reload microagents after initialization of repo.md
- if reload_microagents:
- microagents: list[BaseMicroagent] = (
- runtime.get_microagents_from_selected_repo(None)
- )
- memory.load_user_workspace_microagents(microagents)
- reload_microagents = False
- await prompt_for_next_task(event.agent_state)
-
- if event.agent_state == AgentState.AWAITING_USER_CONFIRMATION:
- # If the agent is paused, do not prompt for confirmation
- # The confirmation step will re-run after the agent has been resumed
- if is_paused.is_set():
- return
-
- if always_confirm_mode:
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.USER_CONFIRMED),
- EventSource.USER,
- )
- return
-
- # Check if auto_highrisk confirm mode is enabled and action is low/medium risk
- pending_action = controller._pending_action
- security_risk = ActionSecurityRisk.LOW
- if pending_action and hasattr(pending_action, 'security_risk'):
- security_risk = pending_action.security_risk
- if (
- auto_highrisk_confirm_mode
- and security_risk != ActionSecurityRisk.HIGH
- ):
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.USER_CONFIRMED),
- EventSource.USER,
- )
- return
-
- # Get the pending action to show risk information
- confirmation_status = await read_confirmation_input(
- config, security_risk=security_risk
- )
- if confirmation_status in ('yes', 'always', 'auto_highrisk'):
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.USER_CONFIRMED),
- EventSource.USER,
- )
- else: # 'no' or alternative instructions
- # Tell the agent the proposed action was rejected
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.USER_REJECTED),
- EventSource.USER,
- )
- # Notify the user
- print_formatted_text(
- HTML(
- 'Okay, please tell me what I should do next/instead.'
- )
- )
-
- # Set the confirmation mode flags based on user choice
- if confirmation_status == 'always':
- always_confirm_mode = True
- elif confirmation_status == 'auto_highrisk':
- auto_highrisk_confirm_mode = True
-
- if event.agent_state == AgentState.PAUSED:
- is_paused.clear() # Revert the event state before prompting for user input
- await prompt_for_next_task(event.agent_state)
-
- if event.agent_state == AgentState.RUNNING:
- display_agent_running_message()
- start_pause_listener(loop, is_paused, event_stream)
-
- def on_event(event: Event) -> None:
- loop.create_task(on_event_async(event))
-
- event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
-
- await runtime.connect()
-
- # Initialize repository if needed
- repo_directory = None
- if config.sandbox.selected_repo:
- repo_directory = initialize_repository_for_runtime(
- runtime,
- selected_repository=config.sandbox.selected_repo,
- )
-
- # when memory is created, it will load the microagents from the selected repository
- memory = create_memory(
- runtime=runtime,
- event_stream=event_stream,
- sid=sid,
- selected_repository=config.sandbox.selected_repo,
- repo_directory=repo_directory,
- conversation_instructions=conversation_instructions,
- working_dir=os.getcwd(),
- )
-
- # Add MCP tools to the agent
- if agent.config.enable_mcp:
- # Clear any previous errors and enable collection
- mcp_error_collector.clear_errors()
- mcp_error_collector.enable_collection()
-
- # Add OpenHands' MCP server by default
- _, openhands_mcp_stdio_servers = (
- OpenHandsMCPConfigImpl.create_default_mcp_server_config(
- config.mcp_host, config, None
- )
- )
-
- runtime.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
-
- await add_mcp_tools_to_agent(agent, runtime, memory)
-
- # Disable collection after startup
- mcp_error_collector.disable_collection()
-
- # Clear loading animation
- is_loaded.set()
-
- # Clear the terminal
- clear()
-
- # Show OpenHands banner and session ID if not skipped
- if not skip_banner:
- display_banner(session_id=sid)
-
- welcome_message = ''
-
- # Display number of MCP servers configured
- if agent.config.enable_mcp:
- total_mcp_servers = (
- len(runtime.config.mcp.stdio_servers)
- + len(runtime.config.mcp.sse_servers)
- + len(runtime.config.mcp.shttp_servers)
- )
- if total_mcp_servers > 0:
- mcp_line = f'Using {len(runtime.config.mcp.stdio_servers)} stdio MCP servers, {len(runtime.config.mcp.sse_servers)} SSE MCP servers and {len(runtime.config.mcp.shttp_servers)} SHTTP MCP servers.'
-
- # Check for MCP errors and add indicator to the same line
- if agent.config.enable_mcp and mcp_error_collector.has_errors():
- mcp_line += (
- ' ✗ MCP errors detected (type /mcp → select View errors to view)'
- )
-
- welcome_message += mcp_line + '\n\n'
-
- welcome_message += 'What do you want to build?' # from the application
- initial_message = '' # from the user
-
- if task_content:
- initial_message = task_content
-
- # If we loaded a state, we are resuming a previous session
- if initial_state is not None:
- logger.info(f'Resuming session: {sid}')
-
- if initial_state.last_error:
- # If the last session ended in an error, provide a message.
- error_message = initial_state.last_error
-
- # Check if it's an authentication error
- if 'ERROR_LLM_AUTHENTICATION' in error_message:
- # Start with base authentication error message
- welcome_message = 'Authentication error with the LLM provider. Please check your API key.'
-
- # Add OpenHands-specific guidance if using an OpenHands model
- llm_config = config.get_llm_config()
- if llm_config.model.startswith('openhands/'):
- welcome_message += "\nIf you're using OpenHands models, get a new API key from https://app.all-hands.dev/settings/api-keys"
- else:
- # For other errors, use the standard message
- initial_message = (
- 'NOTE: the last session ended with an error.'
- "Let's get back on track. Do NOT resume your task. Ask me about it."
- )
- else:
- # If we are resuming, we already have a task
- initial_message = ''
- welcome_message += '\nLoading previous conversation.'
-
- # Show OpenHands welcome
- display_welcome_message(welcome_message)
-
- # The prompt_for_next_task will be triggered if the agent enters AWAITING_USER_INPUT.
- # If the restored state is already AWAITING_USER_INPUT, on_event_async will handle it.
-
- if initial_message:
- display_initial_user_prompt(initial_message)
- event_stream.add_event(MessageAction(content=initial_message), EventSource.USER)
- else:
- # No session restored, no initial action: prompt for the user's first message
- asyncio.create_task(prompt_for_next_task(''))
-
- skip_set_callback = False
- while True:
- await run_agent_until_done(
- controller,
- runtime,
- memory,
- [AgentState.STOPPED, AgentState.ERROR],
- skip_set_callback,
- )
- # Try loop recovery in CLI app
- if (
- controller.state.agent_state == AgentState.ERROR
- and controller.state.last_error.startswith('AgentStuckInLoopError')
- ):
- controller.attempt_loop_recovery()
- skip_set_callback = True
- continue
- else:
- break
-
- await cleanup_session(loop, agent, runtime, controller)
-
- if exit_reason == ExitReason.INTENTIONAL:
- print_formatted_text('✅ Session terminated successfully.\n')
- else:
- print_formatted_text(f'⚠️ Session was interrupted: {exit_reason.value}\n')
-
- return new_session_requested
-
-
-async def run_setup_flow(config: OpenHandsConfig, settings_store: FileSettingsStore):
- """Run the setup flow to configure initial settings.
-
- Returns:
- bool: True if settings were successfully configured, False otherwise.
- """
- # Display the banner with ASCII art first
- display_banner(session_id='setup')
-
- print_formatted_text(
- HTML('No settings found. Starting initial setup...\n')
- )
-
- # Use the existing settings modification function for basic setup
- await modify_llm_settings_basic(config, settings_store)
-
- # Ask if user wants to configure search API settings
- print_formatted_text('')
- setup_search = cli_confirm(
- config,
- 'Would you like to configure Search API settings (optional)?',
- ['Yes', 'No'],
- )
-
- if setup_search == 0: # Yes
- from openhands.cli.settings import modify_search_api_settings
-
- await modify_search_api_settings(config, settings_store)
-
-
-def run_alias_setup_flow(config: OpenHandsConfig) -> None:
- """Run the alias setup flow to configure shell aliases.
-
- Prompts the user to set up aliases for 'openhands' and 'oh' commands.
- Handles existing aliases by offering to keep or remove them.
-
- Args:
- config: OpenHands configuration
- """
- print_formatted_text('')
- print_formatted_text(HTML('🚀 Welcome to OpenHands CLI!'))
- print_formatted_text('')
-
- # Show the normal setup flow
- print_formatted_text(
- HTML('Would you like to set up convenient shell aliases?')
- )
- print_formatted_text('')
- print_formatted_text(
- HTML('This will add the following aliases to your shell profile:')
- )
- print_formatted_text(
- HTML(
- ' • openhands → uvx --python 3.12 --from openhands-ai openhands'
- )
- )
- print_formatted_text(
- HTML(
- ' • oh → uvx --python 3.12 --from openhands-ai openhands'
- )
- )
- print_formatted_text('')
- print_formatted_text(
- HTML(
- '⚠️ Note: This requires uv to be installed first.'
- )
- )
- print_formatted_text(
- HTML(
- ' Installation guide: https://docs.astral.sh/uv/getting-started/installation'
- )
- )
- print_formatted_text('')
-
- # Use cli_confirm to get user choice
- choice = cli_confirm(
- config,
- 'Set up shell aliases?',
- ['Yes, set up aliases', 'No, skip this step'],
- )
-
- if choice == 0: # User chose "Yes"
- success = add_aliases_to_shell_config()
- if success:
- print_formatted_text('')
- print_formatted_text(
- HTML('✅ Aliases added successfully!')
- )
-
- # Get the appropriate reload command using the shell config manager
- shell_manager = ShellConfigManager()
- reload_cmd = shell_manager.get_reload_command()
-
- print_formatted_text(
- HTML(
- f'Run {reload_cmd} (or restart your terminal) to use the new aliases.'
- )
- )
- else:
- print_formatted_text('')
- print_formatted_text(
- HTML(
- '❌ Failed to add aliases. You can set them up manually later.'
- )
- )
- else: # User chose "No"
- # Mark that the user has declined alias setup
- mark_alias_setup_declined()
-
- print_formatted_text('')
- print_formatted_text(
- HTML(
- 'Skipped alias setup. You can run this setup again anytime.'
- )
- )
-
- print_formatted_text('')
-
-
-async def main_with_loop(loop: asyncio.AbstractEventLoop, args) -> None:
- """Runs the agent in CLI mode."""
- # Set log level from command line argument if provided
- if args.log_level and isinstance(args.log_level, str):
- log_level = getattr(logging, str(args.log_level).upper())
- logger.setLevel(log_level)
- else:
- # Set default log level to WARNING if no LOG_LEVEL environment variable is set
- # (command line argument takes precedence over environment variable)
- env_log_level = os.getenv('LOG_LEVEL')
- if not env_log_level:
- logger.setLevel(logging.WARNING)
-
- # If `config.toml` does not exist in current directory, use the file under home directory
- if not os.path.exists(args.config_file):
- home_config_file = os.path.join(
- os.path.expanduser('~'), '.openhands', 'config.toml'
- )
- logger.info(
- f'Config file {args.config_file} does not exist, using default config file in home directory: {home_config_file}.'
- )
- args.config_file = home_config_file
-
- # Load config from toml and override with command line arguments
- config: OpenHandsConfig = setup_config_from_args(args)
-
- # Attempt to install VS Code extension if applicable (one-time attempt)
- attempt_vscode_extension_install()
-
- # Load settings from Settings Store
- # TODO: Make this generic?
- settings_store = await FileSettingsStore.get_instance(config=config, user_id=None)
- settings = await settings_store.load()
-
- # Track if we've shown the banner during setup
- banner_shown = False
-
- # If settings don't exist, automatically enter the setup flow
- if not settings:
- # Clear the terminal before showing the banner
- clear()
-
- await run_setup_flow(config, settings_store)
- banner_shown = True
-
- settings = await settings_store.load()
-
- # Use settings from settings store if available and override with command line arguments
- if settings:
- # settings.agent is not None because we check for it in setup_config_from_args
- assert settings.agent is not None
- config.default_agent = settings.agent
-
- # Handle LLM configuration with proper precedence:
- # 1. CLI parameters (-l) have highest precedence (already handled in setup_config_from_args)
- # 2. config.toml in current directory has next highest precedence (already loaded)
- # 3. ~/.openhands/settings.json has lowest precedence (handled here)
-
- # Only apply settings from settings.json if:
- # - No LLM config was specified via CLI arguments (-l)
- # - The current LLM config doesn't have model or API key set (indicating it wasn't loaded from config.toml)
- llm_config = config.get_llm_config()
- if (
- not args.llm_config
- and (not llm_config.model or not llm_config.api_key)
- and settings.llm_model
- and settings.llm_api_key
- ):
- logger.debug('Using LLM configuration from settings.json')
- llm_config.model = settings.llm_model
- llm_config.api_key = settings.llm_api_key
- llm_config.base_url = settings.llm_base_url
- config.set_llm_config(llm_config)
- config.security.confirmation_mode = (
- settings.confirmation_mode if settings.confirmation_mode else False
- )
-
- # Load search API key from settings if available and not already set from config.toml
- if settings.search_api_key and not config.search_api_key:
- config.search_api_key = settings.search_api_key
- logger.debug('Using search API key from settings.json')
-
- if settings.enable_default_condenser:
- # TODO: Make this generic?
- llm_config = config.get_llm_config()
- agent_config = config.get_agent_config(config.default_agent)
- agent_config.condenser = LLMSummarizingCondenserConfig(
- llm_config=llm_config,
- type='llm',
- )
- config.set_agent_config(agent_config)
- config.enable_default_condenser = True
- else:
- agent_config = config.get_agent_config(config.default_agent)
- agent_config.condenser = NoOpCondenserConfig(type='noop')
- config.set_agent_config(agent_config)
- config.enable_default_condenser = False
-
- # Determine if CLI defaults should be overridden
- val_override = args.override_cli_mode
- should_override_cli_defaults = (
- val_override is True
- or (isinstance(val_override, str) and val_override.lower() in ('true', '1'))
- or (isinstance(val_override, int) and val_override == 1)
- )
-
- if not should_override_cli_defaults:
- config.runtime = 'cli'
- if not config.workspace_base:
- config.workspace_base = os.getcwd()
- config.security.confirmation_mode = True
- config.security.security_analyzer = 'llm'
- agent_config = config.get_agent_config(config.default_agent)
- agent_config.cli_mode = True
- config.set_agent_config(agent_config)
-
- # Need to finalize config again after setting runtime to 'cli'
- # This ensures Jupyter plugin is disabled for CLI runtime
- finalize_config(config)
-
- # Check if we should show the alias setup flow
- # Only show it if:
- # 1. Aliases don't exist in the shell configuration
- # 2. User hasn't previously declined alias setup
- # 3. We're in an interactive environment (not during tests or CI)
- should_show_alias_setup = (
- not aliases_exist_in_shell_config()
- and not alias_setup_declined()
- and sys.stdin.isatty()
- )
-
- if should_show_alias_setup:
- # Clear the terminal if we haven't shown a banner yet (i.e., setup flow didn't run)
- if not banner_shown:
- clear()
-
- run_alias_setup_flow(config)
- # Don't set banner_shown = True here, so the ASCII art banner will still be shown
-
- # TODO: Set working directory from config or use current working directory?
- current_dir = config.workspace_base
-
- if not current_dir:
- raise ValueError('Workspace base directory not specified')
-
- if not check_folder_security_agreement(config, current_dir):
- # User rejected, exit application
- return
-
- # Read task from file, CLI args, or stdin
- if args.file:
- # For CLI usage, we want to enhance the file content with a prompt
- # that instructs the agent to read and understand the file first
- with open(args.file, 'r', encoding='utf-8') as file:
- file_content = file.read()
-
- # Create a prompt that instructs the agent to read and understand the file first
- task_str = f"""The user has tagged a file '{args.file}'.
-Please read and understand the following file content first:
-
-```
-{file_content}
-```
-
-After reviewing the file, please ask the user what they would like to do with it."""
- else:
- task_str = read_task(args, config.cli_multiline_input)
-
- # Setup the runtime
- get_runtime_cls(config.runtime).setup(config)
-
- # Run the first session
- new_session_requested = await run_session(
- loop,
- config,
- settings_store,
- current_dir,
- task_str,
- session_name=args.name,
- skip_banner=banner_shown,
- conversation_id=args.conversation,
- )
-
- # If a new session was requested, run it
- while new_session_requested:
- new_session_requested = await run_session(
- loop, config, settings_store, current_dir, None
- )
-
- # Teardown the runtime
- get_runtime_cls(config.runtime).teardown(config)
-
-
-def run_cli_command(args):
- """Run the CLI command with proper error handling and cleanup."""
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- try:
- loop.run_until_complete(main_with_loop(loop, args))
- except KeyboardInterrupt:
- print_formatted_text('⚠️ Session was interrupted: interrupted\n')
- except ConnectionRefusedError as e:
- print_formatted_text(f'Connection refused: {e}')
- sys.exit(1)
- finally:
- try:
- # Cancel all running tasks
- pending = asyncio.all_tasks(loop)
- for task in pending:
- task.cancel()
-
- # Wait for all tasks to complete with a timeout
- loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
- loop.close()
- except Exception as e:
- print_formatted_text(f'Error during cleanup: {e}')
- sys.exit(1)
- finally:
- # Display deprecation warning on exit
- display_deprecation_warning()
diff --git a/openhands/cli/pt_style.py b/openhands/cli/pt_style.py
deleted file mode 100644
index d171214e33..0000000000
--- a/openhands/cli/pt_style.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from prompt_toolkit.styles import Style, merge_styles
-from prompt_toolkit.styles.defaults import default_ui_style
-
-# Centralized helper for CLI styles so we can safely merge our custom colors
-# with prompt_toolkit's default UI style. This preserves completion menu and
-# fuzzy-match visibility across different terminal themes (e.g., Ubuntu).
-
-COLOR_GOLD = '#FFD700'
-COLOR_GREY = '#808080'
-COLOR_AGENT_BLUE = '#4682B4' # Steel blue - readable on light/dark backgrounds
-
-
-def get_cli_style() -> Style:
- base = default_ui_style()
- custom = Style.from_dict(
- {
- 'gold': COLOR_GOLD,
- 'grey': COLOR_GREY,
- 'prompt': f'{COLOR_GOLD} bold',
- # Ensure good contrast for fuzzy matches on the selected completion row
- # across terminals/themes (e.g., Ubuntu GNOME, Alacritty, Kitty).
- # See https://github.com/OpenHands/OpenHands/issues/10330
- 'completion-menu.completion.current fuzzymatch.outside': 'fg:#ffffff bg:#888888',
- 'selected': COLOR_GOLD,
- 'risk-high': '#FF0000 bold', # Red bold for HIGH risk
- }
- )
- return merge_styles([base, custom])
diff --git a/openhands/cli/settings.py b/openhands/cli/settings.py
deleted file mode 100644
index 567b1b00c5..0000000000
--- a/openhands/cli/settings.py
+++ /dev/null
@@ -1,686 +0,0 @@
-from pathlib import Path
-from typing import Optional
-
-from prompt_toolkit import PromptSession, print_formatted_text
-from prompt_toolkit.completion import FuzzyWordCompleter
-from prompt_toolkit.formatted_text import HTML
-from prompt_toolkit.shortcuts import print_container
-from prompt_toolkit.widgets import Frame, TextArea
-from pydantic import SecretStr
-
-from openhands.cli.pt_style import COLOR_GREY, get_cli_style
-from openhands.cli.tui import (
- UserCancelledError,
- cli_confirm,
- kb_cancel,
-)
-from openhands.cli.utils import (
- VERIFIED_ANTHROPIC_MODELS,
- VERIFIED_MISTRAL_MODELS,
- VERIFIED_OPENAI_MODELS,
- VERIFIED_OPENHANDS_MODELS,
- VERIFIED_PROVIDERS,
- extract_model_and_provider,
- organize_models_and_providers,
-)
-from openhands.controller.agent import Agent
-from openhands.core.config import OpenHandsConfig
-from openhands.core.config.condenser_config import (
- CondenserPipelineConfig,
- ConversationWindowCondenserConfig,
-)
-from openhands.core.config.config_utils import OH_DEFAULT_AGENT
-from openhands.memory.condenser.impl.llm_summarizing_condenser import (
- LLMSummarizingCondenserConfig,
-)
-from openhands.storage.data_models.settings import Settings
-from openhands.storage.settings.file_settings_store import FileSettingsStore
-from openhands.utils.llm import get_supported_llm_models
-
-
-def display_settings(config: OpenHandsConfig) -> None:
- llm_config = config.get_llm_config()
- advanced_llm_settings = True if llm_config.base_url else False
-
- # Prepare labels and values based on settings
- labels_and_values = []
- if not advanced_llm_settings:
- # Attempt to determine provider, fallback if not directly available
- provider = getattr(
- llm_config,
- 'provider',
- llm_config.model.split('/')[0] if '/' in llm_config.model else 'Unknown',
- )
- labels_and_values.extend(
- [
- (' LLM Provider', str(provider)),
- (' LLM Model', str(llm_config.model)),
- (' API Key', '********' if llm_config.api_key else 'Not Set'),
- ]
- )
- else:
- labels_and_values.extend(
- [
- (' Custom Model', str(llm_config.model)),
- (' Base URL', str(llm_config.base_url)),
- (' API Key', '********' if llm_config.api_key else 'Not Set'),
- ]
- )
-
- # Common settings
- labels_and_values.extend(
- [
- (' Agent', str(config.default_agent)),
- (
- ' Confirmation Mode',
- 'Enabled' if config.security.confirmation_mode else 'Disabled',
- ),
- (
- ' Memory Condensation',
- 'Enabled' if config.enable_default_condenser else 'Disabled',
- ),
- (
- ' Search API Key',
- '********' if config.search_api_key else 'Not Set',
- ),
- (
- ' Configuration File',
- str(Path(config.file_store_path) / 'settings.json'),
- ),
- ]
- )
-
- # Calculate max widths for alignment
- # Ensure values are strings for len() calculation
- str_labels_and_values = [(label, str(value)) for label, value in labels_and_values]
- max_label_width = (
- max(len(label) for label, _ in str_labels_and_values)
- if str_labels_and_values
- else 0
- )
-
- # Construct the summary text with aligned columns
- settings_lines = [
- f'{label + ":":<{max_label_width + 1}} {value:<}' # Changed value alignment to left (<)
- for label, value in str_labels_and_values
- ]
- settings_text = '\n'.join(settings_lines)
-
- container = Frame(
- TextArea(
- text=settings_text,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title='Settings',
- style=f'fg:{COLOR_GREY}',
- )
-
- print_container(container)
-
-
-async def get_validated_input(
- session: PromptSession,
- prompt_text: str,
- completer=None,
- validator=None,
- error_message: str = 'Input cannot be empty',
- *,
- default_value: str = '',
- enter_keeps_value: Optional[str] = None,
-) -> str:
- """
- Get validated input from user.
-
- Args:
- session: PromptSession instance
- prompt_text: The text to display before the input
- completer: Completer instance
- validator: Function to validate input
- error_message: Error message to display if input is invalid
- default_value: Value to show prefilled in the prompt (prompt placeholder)
- enter_keeps_value: If provided, pressing Enter on an empty input will
- return this value (useful for keeping existing sensitive values)
-
- Returns:
- str: The validated input
- """
-
- session.completer = completer
- value = None
-
- while True:
- value = await session.prompt_async(prompt_text, default=default_value)
-
- # If user submits empty input and a keep-value is provided, use it.
- if not value.strip() and enter_keeps_value is not None:
- value = enter_keeps_value
-
- if validator:
- is_valid = validator(value)
- if not is_valid:
- print_formatted_text('')
- print_formatted_text(HTML(f'{error_message}: {value}'))
- print_formatted_text('')
- continue
- elif not value:
- print_formatted_text('')
- print_formatted_text(HTML(f'{error_message}'))
- print_formatted_text('')
- continue
-
- break
-
- return value
-
-
-def save_settings_confirmation(config: OpenHandsConfig) -> bool:
- return (
- cli_confirm(
- config,
- '\nSave new settings? (They will take effect after restart)',
- ['Yes, save', 'No, discard'],
- )
- == 0
- )
-
-
-def _get_current_values_for_modification_basic(
- config: OpenHandsConfig,
-) -> tuple[str, str, str]:
- llm_config = config.get_llm_config()
- current_provider = ''
- current_model = ''
- current_api_key = (
- llm_config.api_key.get_secret_value() if llm_config.api_key else ''
- )
- if llm_config.model:
- model_info = extract_model_and_provider(llm_config.model)
- current_provider = model_info.provider or ''
- current_model = model_info.model or ''
- return current_provider, current_model, current_api_key
-
-
-def _get_default_provider(provider_list: list[str]) -> str:
- if 'anthropic' in provider_list:
- return 'anthropic'
- else:
- return provider_list[0] if provider_list else ''
-
-
-def _get_initial_provider_index(
- verified_providers: list[str],
- current_provider: str,
- default_provider: str,
- provider_choices: list[str],
-) -> int:
- if (current_provider or default_provider) in verified_providers:
- return verified_providers.index(current_provider or default_provider)
- elif current_provider or default_provider:
- return len(provider_choices) - 1
- return 0
-
-
-def _get_initial_model_index(
- verified_models: list[str], current_model: str, default_model: str
-) -> int:
- if (current_model or default_model) in verified_models:
- return verified_models.index(current_model or default_model)
- return 0
-
-
-async def modify_llm_settings_basic(
- config: OpenHandsConfig, settings_store: FileSettingsStore
-) -> None:
- model_list = get_supported_llm_models(config)
- organized_models = organize_models_and_providers(model_list)
-
- provider_list = list(organized_models.keys())
- verified_providers = [p for p in VERIFIED_PROVIDERS if p in provider_list]
- provider_list = [p for p in provider_list if p not in verified_providers]
- provider_list = verified_providers + provider_list
-
- provider_completer = FuzzyWordCompleter(provider_list, WORD=True)
- session = PromptSession(key_bindings=kb_cancel(), style=get_cli_style())
-
- current_provider, current_model, current_api_key = (
- _get_current_values_for_modification_basic(config)
- )
-
- default_provider = _get_default_provider(provider_list)
-
- provider = None
- model = None
- api_key = None
-
- try:
- # Show the default provider but allow changing it
- print_formatted_text(
- HTML(f'\nDefault provider: {default_provider}')
- )
-
- # Show verified providers plus "Select another provider" option
- provider_choices = verified_providers + ['Select another provider']
-
- provider_choice = cli_confirm(
- config,
- '(Step 1/3) Select LLM Provider:',
- provider_choices,
- initial_selection=_get_initial_provider_index(
- verified_providers, current_provider, default_provider, provider_choices
- ),
- )
-
- # Ensure provider_choice is an integer (for test compatibility)
- try:
- choice_index = int(provider_choice)
- except (TypeError, ValueError):
- # If conversion fails (e.g., in tests with mocks), default to 0
- choice_index = 0
-
- if choice_index < len(verified_providers):
- # User selected one of the verified providers
- provider = verified_providers[choice_index]
- else:
- # User selected "Select another provider" - use manual selection
- provider = await get_validated_input(
- session,
- '(Step 1/3) Select LLM Provider (TAB for options, CTRL-c to cancel): ',
- completer=provider_completer,
- validator=lambda x: x in organized_models,
- error_message='Invalid provider selected',
- default_value=(
- # Prefill only for unverified providers.
- current_provider
- if current_provider not in verified_providers
- else ''
- ),
- )
-
- # Reset current model and api key if provider changes
- if provider != current_provider:
- current_model = ''
- current_api_key = ''
-
- # Make sure the provider exists in organized_models
- if provider not in organized_models:
- # If the provider doesn't exist, prefer 'anthropic' if available,
- # otherwise use the first provider
- provider = (
- 'anthropic'
- if 'anthropic' in organized_models
- else next(iter(organized_models.keys()))
- )
-
- provider_models = organized_models[provider]['models']
- if provider == 'openai':
- provider_models = [
- m for m in provider_models if m not in VERIFIED_OPENAI_MODELS
- ]
- provider_models = VERIFIED_OPENAI_MODELS + provider_models
- if provider == 'anthropic':
- provider_models = [
- m for m in provider_models if m not in VERIFIED_ANTHROPIC_MODELS
- ]
- provider_models = VERIFIED_ANTHROPIC_MODELS + provider_models
- if provider == 'mistral':
- provider_models = [
- m for m in provider_models if m not in VERIFIED_MISTRAL_MODELS
- ]
- provider_models = VERIFIED_MISTRAL_MODELS + provider_models
- if provider == 'openhands':
- provider_models = [
- m for m in provider_models if m not in VERIFIED_OPENHANDS_MODELS
- ]
- provider_models = VERIFIED_OPENHANDS_MODELS + provider_models
-
- # Set default model to the best verified model for the provider
- if provider == 'anthropic' and VERIFIED_ANTHROPIC_MODELS:
- # Use the first model in the VERIFIED_ANTHROPIC_MODELS list as it's the best/newest
- default_model = VERIFIED_ANTHROPIC_MODELS[0]
- elif provider == 'openai' and VERIFIED_OPENAI_MODELS:
- # Use the first model in the VERIFIED_OPENAI_MODELS list as it's the best/newest
- default_model = VERIFIED_OPENAI_MODELS[0]
- elif provider == 'mistral' and VERIFIED_MISTRAL_MODELS:
- # Use the first model in the VERIFIED_MISTRAL_MODELS list as it's the best/newest
- default_model = VERIFIED_MISTRAL_MODELS[0]
- elif provider == 'openhands' and VERIFIED_OPENHANDS_MODELS:
- # Use the first model in the VERIFIED_OPENHANDS_MODELS list as it's the best/newest
- default_model = VERIFIED_OPENHANDS_MODELS[0]
- else:
- # For other providers, use the first model in the list
- default_model = (
- provider_models[0] if provider_models else 'claude-sonnet-4-20250514'
- )
-
- # For OpenHands provider, directly show all verified models without the "use default" option
- if provider == 'openhands':
- # Create a list of models for the cli_confirm function
- model_choices = VERIFIED_OPENHANDS_MODELS
-
- model_choice = cli_confirm(
- config,
- (
- '(Step 2/3) Select Available OpenHands Model:\n'
- + 'LLM usage is billed at the providers’ rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms'
- ),
- model_choices,
- initial_selection=_get_initial_model_index(
- VERIFIED_OPENHANDS_MODELS, current_model, default_model
- ),
- )
-
- # Get the selected model from the list
- model = model_choices[model_choice]
-
- else:
- # For other providers, show the default model but allow changing it
- print_formatted_text(
- HTML(f'\nDefault model: {default_model}')
- )
- change_model = (
- cli_confirm(
- config,
- 'Do you want to use a different model?',
- [f'Use {default_model}', 'Select another model'],
- initial_selection=0
- if (current_model or default_model) == default_model
- else 1,
- )
- == 1
- )
-
- if change_model:
- model_completer = FuzzyWordCompleter(provider_models, WORD=True)
-
- # Define a validator function that allows custom models but shows a warning
- def model_validator(x):
- # Allow any non-empty model name
- if not x.strip():
- return False
-
- # Show a warning for models not in the predefined list, but still allow them
- if x not in provider_models:
- print_formatted_text(
- HTML(
- f'Warning: {x} is not in the predefined list for provider {provider}. '
- f'Make sure this model name is correct.'
- )
- )
- return True
-
- model = await get_validated_input(
- session,
- '(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
- completer=model_completer,
- validator=model_validator,
- error_message='Model name cannot be empty',
- default_value=(
- # Prefill only for models that are not the default model.
- current_model if current_model != default_model else ''
- ),
- )
- else:
- # Use the default model
- model = default_model
-
- if provider == 'openhands':
- print_formatted_text(
- HTML(
- '\nYou can find your OpenHands LLM API Key in the API Keys tab of OpenHands Cloud: https://app.all-hands.dev/settings/api-keys'
- )
- )
-
- prompt_text = '(Step 3/3) Enter API Key (CTRL-c to cancel): '
- if current_api_key:
- prompt_text = f'(Step 3/3) Enter API Key [{current_api_key[:4]}***{current_api_key[-4:]}] (CTRL-c to cancel, ENTER to keep current, type new to change): '
- api_key = await get_validated_input(
- session,
- prompt_text,
- error_message='API Key cannot be empty',
- default_value='',
- enter_keeps_value=current_api_key,
- )
-
- except (
- UserCancelledError,
- KeyboardInterrupt,
- EOFError,
- ):
- return # Return on exception
-
- # The try-except block above ensures we either have valid inputs or we've already returned
- # No need to check for None values here
-
- save_settings = save_settings_confirmation(config)
-
- if not save_settings:
- return
-
- llm_config = config.get_llm_config()
- llm_config.model = f'{provider}{organized_models[provider]["separator"]}{model}'
- llm_config.api_key = SecretStr(api_key)
- llm_config.base_url = None
- config.set_llm_config(llm_config)
-
- config.default_agent = OH_DEFAULT_AGENT
- config.enable_default_condenser = True
-
- agent_config = config.get_agent_config(config.default_agent)
- agent_config.condenser = LLMSummarizingCondenserConfig(
- llm_config=llm_config,
- type='llm',
- )
- config.set_agent_config(agent_config, config.default_agent)
-
- settings = await settings_store.load()
- if not settings:
- settings = Settings()
-
- settings.llm_model = f'{provider}{organized_models[provider]["separator"]}{model}'
- settings.llm_api_key = SecretStr(api_key) if api_key and api_key.strip() else None
- settings.llm_base_url = None
- settings.agent = OH_DEFAULT_AGENT
- settings.enable_default_condenser = True
- await settings_store.store(settings)
-
-
-async def modify_llm_settings_advanced(
- config: OpenHandsConfig, settings_store: FileSettingsStore
-) -> None:
- session = PromptSession(key_bindings=kb_cancel(), style=get_cli_style())
- llm_config = config.get_llm_config()
-
- custom_model = None
- base_url = None
- api_key = None
- agent = None
-
- try:
- custom_model = await get_validated_input(
- session,
- '(Step 1/6) Custom Model (CTRL-c to cancel): ',
- error_message='Custom Model cannot be empty',
- default_value=llm_config.model or '',
- )
-
- base_url = await get_validated_input(
- session,
- '(Step 2/6) Base URL (CTRL-c to cancel): ',
- error_message='Base URL cannot be empty',
- default_value=llm_config.base_url or '',
- )
-
- prompt_text = '(Step 3/6) API Key (CTRL-c to cancel): '
- current_api_key = (
- llm_config.api_key.get_secret_value() if llm_config.api_key else ''
- )
- if current_api_key:
- prompt_text = f'(Step 3/6) API Key [{current_api_key[:4]}***{current_api_key[-4:]}] (CTRL-c to cancel, ENTER to keep current, type new to change): '
- api_key = await get_validated_input(
- session,
- prompt_text,
- error_message='API Key cannot be empty',
- default_value='',
- enter_keeps_value=current_api_key,
- )
-
- agent_list = Agent.list_agents()
- agent_completer = FuzzyWordCompleter(agent_list, WORD=True)
- agent = await get_validated_input(
- session,
- '(Step 4/6) Agent (TAB for options, CTRL-c to cancel): ',
- completer=agent_completer,
- validator=lambda x: x in agent_list,
- error_message='Invalid agent selected',
- default_value=config.default_agent or '',
- )
-
- enable_confirmation_mode = (
- cli_confirm(
- config,
- question='(Step 5/6) Confirmation Mode (CTRL-c to cancel):',
- choices=['Enable', 'Disable'],
- initial_selection=0 if config.security.confirmation_mode else 1,
- )
- == 0
- )
-
- enable_memory_condensation = (
- cli_confirm(
- config,
- question='(Step 6/6) Memory Condensation (CTRL-c to cancel):',
- choices=['Enable', 'Disable'],
- initial_selection=0 if config.enable_default_condenser else 1,
- )
- == 0
- )
-
- except (
- UserCancelledError,
- KeyboardInterrupt,
- EOFError,
- ):
- return # Return on exception
-
- # The try-except block above ensures we either have valid inputs or we've already returned
- # No need to check for None values here
-
- save_settings = save_settings_confirmation(config)
-
- if not save_settings:
- return
-
- llm_config = config.get_llm_config()
- llm_config.model = custom_model
- llm_config.base_url = base_url
- llm_config.api_key = SecretStr(api_key)
- config.set_llm_config(llm_config)
-
- config.default_agent = agent
-
- config.security.confirmation_mode = enable_confirmation_mode
- config.enable_default_condenser = enable_memory_condensation
-
- agent_config = config.get_agent_config(config.default_agent)
- if enable_memory_condensation:
- agent_config.condenser = CondenserPipelineConfig(
- type='pipeline',
- condensers=[
- ConversationWindowCondenserConfig(type='conversation_window'),
- # Use LLMSummarizingCondenserConfig with the custom llm_config
- LLMSummarizingCondenserConfig(
- llm_config=llm_config, type='llm', keep_first=4, max_size=120
- ),
- ],
- )
-
- else:
- agent_config.condenser = ConversationWindowCondenserConfig(
- type='conversation_window'
- )
- config.set_agent_config(agent_config)
-
- settings = await settings_store.load()
- if not settings:
- settings = Settings()
-
- settings.llm_model = custom_model
- settings.llm_api_key = SecretStr(api_key) if api_key and api_key.strip() else None
- settings.llm_base_url = base_url
- settings.agent = agent
- settings.confirmation_mode = enable_confirmation_mode
- settings.enable_default_condenser = enable_memory_condensation
- await settings_store.store(settings)
-
-
-async def modify_search_api_settings(
- config: OpenHandsConfig, settings_store: FileSettingsStore
-) -> None:
- """Modify search API settings."""
- session = PromptSession(key_bindings=kb_cancel(), style=get_cli_style())
-
- search_api_key = None
-
- try:
- print_formatted_text(
- HTML(
- '\nConfigure Search API Key for enhanced search capabilities.'
- )
- )
- print_formatted_text(
- HTML('You can get a Tavily API key from: https://tavily.com/')
- )
- print_formatted_text('')
-
- # Show current status
- current_key_status = '********' if config.search_api_key else 'Not Set'
- print_formatted_text(
- HTML(
- f'Current Search API Key: {current_key_status}'
- )
- )
- print_formatted_text('')
-
- # Ask if user wants to modify
- modify_key = cli_confirm(
- config,
- 'Do you want to modify the Search API Key?',
- ['Set/Update API Key', 'Remove API Key', 'Keep current setting'],
- )
-
- if modify_key == 0: # Set/Update API Key
- search_api_key = await get_validated_input(
- session,
- 'Enter Tavily Search API Key. You can get it from https://www.tavily.com/ (starts with tvly-, CTRL-c to cancel): ',
- validator=lambda x: x.startswith('tvly-') if x.strip() else False,
- error_message='Search API Key must start with "tvly-"',
- )
- elif modify_key == 1: # Remove API Key
- search_api_key = '' # Empty string to remove the key
- else: # Keep current setting
- return
-
- except (
- UserCancelledError,
- KeyboardInterrupt,
- EOFError,
- ):
- return # Return on exception
-
- save_settings = save_settings_confirmation(config)
-
- if not save_settings:
- return
-
- # Update config
- config.search_api_key = SecretStr(search_api_key) if search_api_key else None
-
- # Update settings store
- settings = await settings_store.load()
- if not settings:
- settings = Settings()
-
- settings.search_api_key = SecretStr(search_api_key) if search_api_key else None
- await settings_store.store(settings)
diff --git a/openhands/cli/shell_config.py b/openhands/cli/shell_config.py
deleted file mode 100644
index a27e8e2feb..0000000000
--- a/openhands/cli/shell_config.py
+++ /dev/null
@@ -1,297 +0,0 @@
-"""Shell configuration management for OpenHands CLI aliases.
-
-This module provides a simplified, more maintainable approach to managing
-shell aliases across different shell types and platforms.
-"""
-
-import platform
-import re
-from pathlib import Path
-from typing import Optional
-
-from jinja2 import Template
-
-try:
- import shellingham
-except ImportError:
- shellingham = None
-
-
-class ShellConfigManager:
- """Manages shell configuration files and aliases across different shells."""
-
- # Shell configuration templates
- ALIAS_TEMPLATES = {
- 'bash': Template("""
-# OpenHands CLI aliases
-alias openhands="{{ command }}"
-alias oh="{{ command }}"
-"""),
- 'zsh': Template("""
-# OpenHands CLI aliases
-alias openhands="{{ command }}"
-alias oh="{{ command }}"
-"""),
- 'fish': Template("""
-# OpenHands CLI aliases
-alias openhands="{{ command }}"
-alias oh="{{ command }}"
-"""),
- 'powershell': Template("""
-# OpenHands CLI aliases
-function openhands { {{ command }} $args }
-function oh { {{ command }} $args }
-"""),
- }
-
- # Shell configuration file patterns
- SHELL_CONFIG_PATTERNS = {
- 'bash': ['.bashrc', '.bash_profile'],
- 'zsh': ['.zshrc'],
- 'fish': ['.config/fish/config.fish'],
- 'csh': ['.cshrc'],
- 'tcsh': ['.tcshrc'],
- 'ksh': ['.kshrc'],
- 'powershell': [
- 'Documents/PowerShell/Microsoft.PowerShell_profile.ps1',
- 'Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1',
- '.config/powershell/Microsoft.PowerShell_profile.ps1',
- ],
- }
-
- # Regex patterns for detecting existing aliases
- ALIAS_PATTERNS = {
- 'bash': [
- r'^\s*alias\s+openhands\s*=',
- r'^\s*alias\s+oh\s*=',
- ],
- 'zsh': [
- r'^\s*alias\s+openhands\s*=',
- r'^\s*alias\s+oh\s*=',
- ],
- 'fish': [
- r'^\s*alias\s+openhands\s*=',
- r'^\s*alias\s+oh\s*=',
- ],
- 'powershell': [
- r'^\s*function\s+openhands\s*\{',
- r'^\s*function\s+oh\s*\{',
- ],
- }
-
- def __init__(
- self, command: str = 'uvx --python 3.12 --from openhands-ai openhands'
- ):
- """Initialize the shell config manager.
-
- Args:
- command: The command that aliases should point to.
- """
- self.command = command
- self.is_windows = platform.system() == 'Windows'
-
- def detect_shell(self) -> Optional[str]:
- """Detect the current shell using shellingham.
-
- Returns:
- Shell name if detected, None otherwise.
- """
- if not shellingham:
- return None
-
- try:
- shell_name, _ = shellingham.detect_shell()
- return shell_name
- except Exception:
- return None
-
- def get_shell_config_path(self, shell: Optional[str] = None) -> Path:
- """Get the path to the shell configuration file.
-
- Args:
- shell: Shell name. If None, will attempt to detect.
-
- Returns:
- Path to the shell configuration file.
- """
- if shell is None:
- shell = self.detect_shell()
-
- home = Path.home()
-
- # Try to find existing config file for the detected shell
- if shell and shell in self.SHELL_CONFIG_PATTERNS:
- for config_file in self.SHELL_CONFIG_PATTERNS[shell]:
- config_path = home / config_file
- if config_path.exists():
- return config_path
-
- # If no existing file found, return the first option
- return home / self.SHELL_CONFIG_PATTERNS[shell][0]
-
- # Fallback logic
- if self.is_windows:
- # Windows fallback to PowerShell
- ps_profile = (
- home / 'Documents' / 'PowerShell' / 'Microsoft.PowerShell_profile.ps1'
- )
- return ps_profile
- else:
- # Unix fallback to bash
- bashrc = home / '.bashrc'
- if bashrc.exists():
- return bashrc
- return home / '.bash_profile'
-
- def get_shell_type_from_path(self, config_path: Path) -> str:
- """Determine shell type from configuration file path.
-
- Args:
- config_path: Path to the shell configuration file.
-
- Returns:
- Shell type name.
- """
- path_str = str(config_path).lower()
-
- if 'powershell' in path_str:
- return 'powershell'
- elif '.zshrc' in path_str:
- return 'zsh'
- elif 'fish' in path_str:
- return 'fish'
- elif '.bashrc' in path_str or '.bash_profile' in path_str:
- return 'bash'
- else:
- return 'bash' # Default fallback
-
- def aliases_exist(self, config_path: Optional[Path] = None) -> bool:
- """Check if OpenHands aliases already exist in the shell config.
-
- Args:
- config_path: Path to check. If None, will detect automatically.
-
- Returns:
- True if aliases exist, False otherwise.
- """
- if config_path is None:
- config_path = self.get_shell_config_path()
-
- if not config_path.exists():
- return False
-
- shell_type = self.get_shell_type_from_path(config_path)
- patterns = self.ALIAS_PATTERNS.get(shell_type, self.ALIAS_PATTERNS['bash'])
-
- try:
- with open(config_path, 'r', encoding='utf-8', errors='ignore') as f:
- content = f.read()
-
- for pattern in patterns:
- if re.search(pattern, content, re.MULTILINE):
- return True
-
- return False
- except Exception:
- return False
-
- def add_aliases(self, config_path: Optional[Path] = None) -> bool:
- """Add OpenHands aliases to the shell configuration.
-
- Args:
- config_path: Path to modify. If None, will detect automatically.
-
- Returns:
- True if successful, False otherwise.
- """
- if config_path is None:
- config_path = self.get_shell_config_path()
-
- # Check if aliases already exist
- if self.aliases_exist(config_path):
- return True
-
- try:
- # Ensure parent directory exists
- config_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Get the appropriate template
- shell_type = self.get_shell_type_from_path(config_path)
- template = self.ALIAS_TEMPLATES.get(
- shell_type, self.ALIAS_TEMPLATES['bash']
- )
-
- # Render the aliases
- aliases_content = template.render(command=self.command)
-
- # Append to the config file
- with open(config_path, 'a', encoding='utf-8') as f:
- f.write(aliases_content)
-
- return True
- except Exception as e:
- print(f'Error adding aliases: {e}')
- return False
-
- def get_reload_command(self, config_path: Optional[Path] = None) -> str:
- """Get the command to reload the shell configuration.
-
- Args:
- config_path: Path to the config file. If None, will detect automatically.
-
- Returns:
- Command to reload the shell configuration.
- """
- if config_path is None:
- config_path = self.get_shell_config_path()
-
- shell_type = self.get_shell_type_from_path(config_path)
-
- if shell_type == 'zsh':
- return 'source ~/.zshrc'
- elif shell_type == 'fish':
- return 'source ~/.config/fish/config.fish'
- elif shell_type == 'powershell':
- return '. $PROFILE'
- else: # bash and others
- if '.bash_profile' in str(config_path):
- return 'source ~/.bash_profile'
- else:
- return 'source ~/.bashrc'
-
-
-# Convenience functions that use the ShellConfigManager
-def add_aliases_to_shell_config() -> bool:
- """Add OpenHands aliases to the shell configuration."""
- manager = ShellConfigManager()
- return manager.add_aliases()
-
-
-def aliases_exist_in_shell_config() -> bool:
- """Check if OpenHands aliases exist in the shell configuration."""
- manager = ShellConfigManager()
- return manager.aliases_exist()
-
-
-def get_shell_config_path() -> Path:
- """Get the path to the shell configuration file."""
- manager = ShellConfigManager()
- return manager.get_shell_config_path()
-
-
-def alias_setup_declined() -> bool:
- """Check if the user has previously declined alias setup.
-
- Returns:
- True if user has declined alias setup, False otherwise.
- """
- marker_file = Path.home() / '.openhands' / '.cli_alias_setup_declined'
- return marker_file.exists()
-
-
-def mark_alias_setup_declined() -> None:
- """Mark that the user has declined alias setup."""
- openhands_dir = Path.home() / '.openhands'
- openhands_dir.mkdir(exist_ok=True)
- marker_file = openhands_dir / '.cli_alias_setup_declined'
- marker_file.touch()
diff --git a/openhands/cli/suppress_warnings.py b/openhands/cli/suppress_warnings.py
deleted file mode 100644
index 1cc430a998..0000000000
--- a/openhands/cli/suppress_warnings.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""Module to suppress common warnings in CLI mode."""
-
-import warnings
-
-
-def suppress_cli_warnings():
- """Suppress common warnings that appear during CLI usage."""
- # Suppress pydub warning about ffmpeg/avconv
- warnings.filterwarnings(
- 'ignore',
- message="Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work",
- category=RuntimeWarning,
- )
-
- # Suppress Pydantic serialization warnings
- warnings.filterwarnings(
- 'ignore',
- message='.*Pydantic serializer warnings.*',
- category=UserWarning,
- )
-
- # Suppress specific Pydantic serialization unexpected value warnings
- warnings.filterwarnings(
- 'ignore',
- message='.*PydanticSerializationUnexpectedValue.*',
- category=UserWarning,
- )
-
- # Suppress general deprecation warnings from dependencies during CLI usage
- # This catches the "Call to deprecated method get_events" warning
- warnings.filterwarnings(
- 'ignore',
- message='.*Call to deprecated method.*',
- category=DeprecationWarning,
- )
-
- # Suppress other common dependency warnings that don't affect functionality
- warnings.filterwarnings(
- 'ignore',
- message='.*Expected .* fields but got .*',
- category=UserWarning,
- )
-
- # Suppress SyntaxWarnings from pydub.utils about invalid escape sequences
- warnings.filterwarnings(
- 'ignore',
- category=SyntaxWarning,
- module=r'pydub\.utils',
- )
- # Suppress LiteLLM close_litellm_async_clients was never awaited warning
- warnings.filterwarnings(
- 'ignore',
- message="coroutine 'close_litellm_async_clients' was never awaited",
- category=RuntimeWarning,
- )
-
-
-# Apply warning suppressions when module is imported
-suppress_cli_warnings()
diff --git a/openhands/cli/tui.py b/openhands/cli/tui.py
deleted file mode 100644
index 2a8f71a6ee..0000000000
--- a/openhands/cli/tui.py
+++ /dev/null
@@ -1,1066 +0,0 @@
-# CLI TUI input and output functions
-# Handles all input and output to the console
-# CLI Settings are handled separately in cli_settings.py
-
-import asyncio
-import contextlib
-import datetime
-import html
-import json
-import re
-import sys
-import threading
-import time
-from typing import Generator
-
-from prompt_toolkit import PromptSession, print_formatted_text
-from prompt_toolkit.application import Application
-from prompt_toolkit.completion import CompleteEvent, Completer, Completion
-from prompt_toolkit.document import Document
-from prompt_toolkit.formatted_text import HTML, FormattedText, StyleAndTextTuples
-from prompt_toolkit.input import create_input
-from prompt_toolkit.key_binding import KeyBindings
-from prompt_toolkit.key_binding.key_processor import KeyPressEvent
-from prompt_toolkit.keys import Keys
-from prompt_toolkit.layout.containers import HSplit, Window
-from prompt_toolkit.layout.controls import FormattedTextControl
-from prompt_toolkit.layout.dimension import Dimension
-from prompt_toolkit.layout.layout import Layout
-from prompt_toolkit.lexers import Lexer
-from prompt_toolkit.patch_stdout import patch_stdout
-from prompt_toolkit.shortcuts import print_container
-from prompt_toolkit.widgets import Frame, TextArea
-
-from openhands import __version__
-from openhands.cli.deprecation_warning import display_deprecation_warning
-from openhands.cli.pt_style import (
- COLOR_AGENT_BLUE,
- COLOR_GOLD,
- COLOR_GREY,
- get_cli_style,
-)
-from openhands.core.config import OpenHandsConfig
-from openhands.core.schema import AgentState
-from openhands.events import EventSource, EventStream
-from openhands.events.action import (
- Action,
- ActionConfirmationStatus,
- ActionSecurityRisk,
- ChangeAgentStateAction,
- CmdRunAction,
- MCPAction,
- MessageAction,
- TaskTrackingAction,
-)
-from openhands.events.event import Event
-from openhands.events.observation import (
- AgentStateChangedObservation,
- CmdOutputObservation,
- ErrorObservation,
- FileEditObservation,
- FileReadObservation,
- LoopDetectionObservation,
- MCPObservation,
- TaskTrackingObservation,
-)
-from openhands.llm.metrics import Metrics
-from openhands.mcp.error_collector import mcp_error_collector
-
-ENABLE_STREAMING = False # FIXME: this doesn't work
-
-# Global TextArea for streaming output
-streaming_output_text_area: TextArea | None = None
-
-# Track recent thoughts to prevent duplicate display
-recent_thoughts: list[str] = []
-MAX_RECENT_THOUGHTS = 5
-
-# Maximum number of lines to display for command output
-MAX_OUTPUT_LINES = 15
-
-# Color and styling constants
-DEFAULT_STYLE = get_cli_style()
-
-COMMANDS = {
- '/exit': 'Exit the application',
- '/help': 'Display available commands',
- '/init': 'Initialize a new repository',
- '/status': 'Display conversation details and usage metrics',
- '/new': 'Create a new conversation',
- '/settings': 'Display and modify current settings',
- '/resume': 'Resume the agent when paused',
- '/mcp': 'Manage MCP server configuration and view errors',
-}
-
-print_lock = threading.Lock()
-
-pause_task: asyncio.Task | None = None # No more than one pause task
-
-
-class UsageMetrics:
- def __init__(self) -> None:
- self.metrics: Metrics = Metrics()
- self.session_init_time: float = time.time()
-
-
-class CustomDiffLexer(Lexer):
- """Custom lexer for the specific diff format."""
-
- def lex_document(self, document: Document) -> StyleAndTextTuples:
- lines = document.lines
-
- def get_line(lineno: int) -> StyleAndTextTuples:
- line = lines[lineno]
- if line.startswith('+'):
- return [('ansigreen', line)]
- elif line.startswith('-'):
- return [('ansired', line)]
- elif line.startswith('[') or line.startswith('('):
- # Style for metadata lines like [Existing file...] or (content...)
- return [('bold', line)]
- else:
- # Default style for other lines
- return [('', line)]
-
- return get_line
-
-
-# CLI initialization and startup display functions
-def display_runtime_initialization_message(runtime: str) -> None:
- print_formatted_text('')
- if runtime == 'local':
- print_formatted_text(HTML('⚙️ Starting local runtime...'))
- elif runtime == 'docker':
- print_formatted_text(HTML('🐳 Starting Docker runtime...'))
- print_formatted_text('')
-
-
-def display_initialization_animation(text: str, is_loaded: asyncio.Event) -> None:
- ANIMATION_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
-
- i = 0
- while not is_loaded.is_set():
- sys.stdout.write('\n')
- sys.stdout.write(
- f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
- )
- sys.stdout.flush()
- time.sleep(0.1)
- i += 1
-
- sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
- sys.stdout.flush()
-
-
-def display_banner(session_id: str) -> None:
- # Display deprecation warning first
- display_deprecation_warning()
-
- print_formatted_text(
- HTML(r"""
- ___ _ _ _
- / _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
- | | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
- | |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
- \___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
- |_|
- """),
- style=DEFAULT_STYLE,
- )
-
- print_formatted_text(HTML(f'OpenHands CLI v{__version__}'))
-
- print_formatted_text('')
- print_formatted_text(HTML(f'Initialized conversation {session_id}'))
- print_formatted_text('')
-
-
-def display_welcome_message(message: str = '') -> None:
- print_formatted_text(
- HTML("Let's start building!\n"), style=DEFAULT_STYLE
- )
-
- if message:
- print_formatted_text(
- HTML(f'{message} Type /help for help'),
- style=DEFAULT_STYLE,
- )
- else:
- print_formatted_text(
- HTML('What do you want to build? Type /help for help'),
- style=DEFAULT_STYLE,
- )
-
-
-def display_initial_user_prompt(prompt: str) -> None:
- print_formatted_text(
- FormattedText(
- [
- ('', '\n'),
- (COLOR_GOLD, '> '),
- ('', prompt),
- ]
- )
- )
-
-
-def display_mcp_errors() -> None:
- """Display collected MCP errors."""
- errors = mcp_error_collector.get_errors()
-
- if not errors:
- print_formatted_text(HTML('✓ No MCP errors detected\n'))
- return
-
- print_formatted_text(
- HTML(
- f'✗ {len(errors)} MCP error(s) detected during startup:\n'
- )
- )
-
- for i, error in enumerate(errors, 1):
- # Format timestamp
- timestamp = datetime.datetime.fromtimestamp(error.timestamp).strftime(
- '%H:%M:%S'
- )
-
- # Create error display text
- error_text = (
- f'[{timestamp}] {error.server_type.upper()} Server: {error.server_name}\n'
- )
- error_text += f'Error: {error.error_message}\n'
- if error.exception_details:
- error_text += f'Details: {error.exception_details}'
-
- container = Frame(
- TextArea(
- text=error_text,
- read_only=True,
- style='ansired',
- wrap_lines=True,
- ),
- title=f'MCP Error #{i}',
- style='ansired',
- )
- print_container(container)
- print_formatted_text('') # Add spacing between errors
-
-
-# Prompt output display functions
-def display_thought_if_new(thought: str, is_agent_message: bool = False) -> None:
- """Display a thought only if it hasn't been displayed recently.
-
- Args:
- thought: The thought to display
- is_agent_message: If True, apply agent styling and markdown rendering
- """
- global recent_thoughts
- if thought and thought.strip():
- # Check if this thought was recently displayed
- if thought not in recent_thoughts:
- display_message(thought, is_agent_message=is_agent_message)
- recent_thoughts.append(thought)
- # Keep only the most recent thoughts
- if len(recent_thoughts) > MAX_RECENT_THOUGHTS:
- recent_thoughts.pop(0)
-
-
-def display_event(event: Event, config: OpenHandsConfig) -> None:
- global streaming_output_text_area
- with print_lock:
- if isinstance(event, CmdRunAction):
- # For CmdRunAction, display thought first, then command
- if hasattr(event, 'thought') and event.thought:
- display_thought_if_new(event.thought)
-
- # Only display the command if it's not already confirmed
- # Commands are always shown when AWAITING_CONFIRMATION, so we don't need to show them again when CONFIRMED
- if event.confirmation_state != ActionConfirmationStatus.CONFIRMED:
- display_command(event)
-
- if event.confirmation_state == ActionConfirmationStatus.CONFIRMED:
- initialize_streaming_output()
- elif isinstance(event, MCPAction):
- display_mcp_action(event)
- elif isinstance(event, TaskTrackingAction):
- display_task_tracking_action(event)
- elif isinstance(event, Action):
- # For other actions, display thoughts normally
- if hasattr(event, 'thought') and event.thought:
- display_thought_if_new(event.thought)
- if hasattr(event, 'final_thought') and event.final_thought:
- # Display final thoughts with agent styling
- display_message(event.final_thought, is_agent_message=True)
-
- if isinstance(event, MessageAction):
- if event.source == EventSource.AGENT:
- # Display agent messages with styling and markdown rendering
- display_thought_if_new(event.content, is_agent_message=True)
- elif isinstance(event, CmdOutputObservation):
- display_command_output(event.content)
- elif isinstance(event, FileEditObservation):
- display_file_edit(event)
- elif isinstance(event, FileReadObservation):
- display_file_read(event)
- elif isinstance(event, MCPObservation):
- display_mcp_observation(event)
- elif isinstance(event, TaskTrackingObservation):
- display_task_tracking_observation(event)
- elif isinstance(event, AgentStateChangedObservation):
- display_agent_state_change_message(event.agent_state)
- elif isinstance(event, ErrorObservation):
- display_error(event.content)
- elif isinstance(event, LoopDetectionObservation):
- handle_loop_recovery_state_observation(event)
-
-
-def display_message(message: str, is_agent_message: bool = False) -> None:
- """Display a message in the terminal with markdown rendering.
-
- Args:
- message: The message to display
- is_agent_message: If True, apply agent styling (blue color)
- """
- message = message.strip()
-
- if message:
- # Add spacing before the message
- print_formatted_text('')
-
- try:
- # Render only basic markdown (bold/underline), escaping any HTML
- html_content = _render_basic_markdown(message)
-
- if is_agent_message:
- # Use prompt_toolkit's HTML renderer with the agent color
- print_formatted_text(
- HTML(f'')
- )
- else:
- # Regular message display with HTML rendering but default color
- print_formatted_text(HTML(html_content))
- except Exception as e:
- # If HTML rendering fails, fall back to plain text
- print(f'Warning: HTML rendering failed: {str(e)}', file=sys.stderr)
- if is_agent_message:
- print_formatted_text(
- FormattedText([('fg:' + COLOR_AGENT_BLUE, message)])
- )
- else:
- print_formatted_text(message)
-
-
-def _render_basic_markdown(text: str | None) -> str | None:
- """Render a very small subset of markdown directly to prompt_toolkit HTML.
-
- Supported:
- - Bold: **text** -> text
- - Underline: __text__ -> text
-
- Any existing HTML in input is escaped to avoid injection into the renderer.
- If input is None, return None.
- """
- if text is None:
- return None
- if text == '':
- return ''
-
- safe = html.escape(text)
- # Bold: greedy within a line, non-overlapping
- safe = re.sub(r'\*\*(.+?)\*\*', r'\1', safe)
- # Underline: double underscore
- safe = re.sub(r'__(.+?)__', r'\1', safe)
- return safe
-
-
-def display_error(error: str) -> None:
- error = error.strip()
-
- if error:
- container = Frame(
- TextArea(
- text=error,
- read_only=True,
- style='ansired',
- wrap_lines=True,
- ),
- title='Error',
- style='ansired',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def display_command(event: CmdRunAction) -> None:
- # Create simple command frame
- command_text = f'$ {event.command}'
-
- container = Frame(
- TextArea(
- text=command_text,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title='Command',
- style='ansiblue',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def display_command_output(output: str) -> None:
- lines = output.split('\n')
- formatted_lines = []
- for line in lines:
- if line.startswith('[Python Interpreter') or line.startswith('openhands@'):
- # TODO: clean this up once we clean up terminal output
- continue
- formatted_lines.append(line)
-
- # Truncate long outputs
- title = 'Command Output'
- if len(formatted_lines) > MAX_OUTPUT_LINES:
- truncated_lines = formatted_lines[:MAX_OUTPUT_LINES]
- remaining_lines = len(formatted_lines) - MAX_OUTPUT_LINES
- truncated_lines.append(
- f'... and {remaining_lines} more lines \n use --full to see complete output'
- )
- formatted_output = '\n'.join(truncated_lines)
- title = f'Command Output (showing {MAX_OUTPUT_LINES} of {len(formatted_lines)} lines)'
- else:
- formatted_output = '\n'.join(formatted_lines)
-
- container = Frame(
- TextArea(
- text=formatted_output,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title=title,
- style=f'fg:{COLOR_GREY}',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def display_file_edit(event: FileEditObservation) -> None:
- container = Frame(
- TextArea(
- text=event.visualize_diff(n_context_lines=4),
- read_only=True,
- wrap_lines=True,
- lexer=CustomDiffLexer(),
- ),
- title='File Edit',
- style=f'fg:{COLOR_GREY}',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def display_file_read(event: FileReadObservation) -> None:
- content = event.content.replace('\t', ' ')
- container = Frame(
- TextArea(
- text=content,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title='File Read',
- style=f'fg:{COLOR_GREY}',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def display_mcp_action(event: MCPAction) -> None:
- """Display an MCP action in the CLI."""
- # Format the arguments for display
- args_text = ''
- if event.arguments:
- try:
- args_text = json.dumps(event.arguments, indent=2)
- except (TypeError, ValueError):
- args_text = str(event.arguments)
-
- # Create the display text
- display_text = f'Tool: {event.name}'
- if args_text:
- display_text += f'\n\nArguments:\n{args_text}'
-
- container = Frame(
- TextArea(
- text=display_text,
- read_only=True,
- style='ansiblue',
- wrap_lines=True,
- ),
- title='MCP Tool Call',
- style='ansiblue',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def display_mcp_observation(event: MCPObservation) -> None:
- """Display an MCP observation in the CLI."""
- # Format the content for display
- content = event.content.strip() if event.content else 'No output'
-
- # Add tool name and arguments info if available
- display_text = content
- if event.name:
- header = f'Tool: {event.name}'
- if event.arguments:
- try:
- args_text = json.dumps(event.arguments, indent=2)
- header += f'\nArguments: {args_text}'
- except (TypeError, ValueError):
- header += f'\nArguments: {event.arguments}'
- display_text = f'{header}\n\nResult:\n{content}'
-
- container = Frame(
- TextArea(
- text=display_text,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title='MCP Tool Result',
- style=f'fg:{COLOR_GREY}',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def display_task_tracking_action(event: TaskTrackingAction) -> None:
- """Display a TaskTracking action in the CLI."""
- # Display thought first if present
- if hasattr(event, 'thought') and event.thought:
- display_thought_if_new(event.thought)
-
- # Format the command and task list for display
- display_text = f'Command: {event.command}'
-
- if event.command == 'plan':
- if event.task_list:
- display_text += f'\n\nTask List ({len(event.task_list)} items):'
- for i, task in enumerate(event.task_list, 1):
- status = task.get('status', 'unknown')
- title = task.get('title', 'Untitled task')
- task_id = task.get('id', f'task-{i}')
- notes = task.get('notes', '')
-
- # Add status indicator with color
- status_indicator = {
- 'todo': '⏳',
- 'in_progress': '🔄',
- 'done': '✅',
- }.get(status, '❓')
-
- display_text += f'\n {i}. {status_indicator} [{status.upper()}] {title} (ID: {task_id})'
- if notes:
- display_text += f'\n Notes: {notes}'
- else:
- display_text += '\n\nTask List: Empty'
-
- container = Frame(
- TextArea(
- text=display_text,
- read_only=True,
- style='ansigreen',
- wrap_lines=True,
- ),
- title='Task Tracking Action',
- style='ansigreen',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def display_task_tracking_observation(event: TaskTrackingObservation) -> None:
- """Display a TaskTracking observation in the CLI."""
- # Format the content and task list for display
- content = (
- event.content.strip() if event.content else 'Task tracking operation completed'
- )
-
- display_text = f'Result: {content}'
-
- container = Frame(
- TextArea(
- text=display_text,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title='Task Tracking Result',
- style=f'fg:{COLOR_GREY}',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def initialize_streaming_output():
- """Initialize the streaming output TextArea."""
- if not ENABLE_STREAMING:
- return
- global streaming_output_text_area
- streaming_output_text_area = TextArea(
- text='',
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- )
- container = Frame(
- streaming_output_text_area,
- title='Streaming Output',
- style=f'fg:{COLOR_GREY}',
- )
- print_formatted_text('')
- print_container(container)
-
-
-def update_streaming_output(text: str):
- """Update the streaming output TextArea with new text."""
- global streaming_output_text_area
-
- # Append the new text to the existing content
- if streaming_output_text_area is not None:
- current_text = streaming_output_text_area.text
- streaming_output_text_area.text = current_text + text
-
-
-# Interactive command output display functions
-def display_help() -> None:
- # Version header and introduction
- print_formatted_text(
- HTML(
- f'\nOpenHands CLI v{__version__}\n'
- 'OpenHands CLI lets you interact with the OpenHands agent from the command line.\n'
- )
- )
-
- # Usage examples
- print_formatted_text('Things that you can try:')
- print_formatted_text(
- HTML(
- '• Ask questions about the codebase > How does main.py work?\n'
- '• Edit files or add new features > Add a new function to ...\n'
- '• Find and fix issues > Fix the type error in ...\n'
- )
- )
-
- # Tips section
- print_formatted_text(
- 'Some tips to get the most out of OpenHands:\n'
- '• Be as specific as possible about the desired outcome or the problem to be solved.\n'
- '• Provide context, including relevant file paths and line numbers if available.\n'
- '• Break large tasks into smaller, manageable prompts.\n'
- '• Include relevant error messages or logs.\n'
- '• Specify the programming language or framework, if not obvious.\n'
- )
-
- # Commands section
- print_formatted_text(HTML('Interactive commands:'))
- commands_html = ''
- for command, description in COMMANDS.items():
- commands_html += f'{command} - {description}\n'
- print_formatted_text(HTML(commands_html))
-
- # Footer
- print_formatted_text(
- HTML(
- 'Learn more at: https://docs.all-hands.dev/usage/getting-started'
- )
- )
-
-
-def display_usage_metrics(usage_metrics: UsageMetrics) -> None:
- cost_str = f'${usage_metrics.metrics.accumulated_cost:.6f}'
- input_tokens_str = (
- f'{usage_metrics.metrics.accumulated_token_usage.prompt_tokens:,}'
- )
- cache_read_str = (
- f'{usage_metrics.metrics.accumulated_token_usage.cache_read_tokens:,}'
- )
- cache_write_str = (
- f'{usage_metrics.metrics.accumulated_token_usage.cache_write_tokens:,}'
- )
- output_tokens_str = (
- f'{usage_metrics.metrics.accumulated_token_usage.completion_tokens:,}'
- )
- total_tokens_str = f'{usage_metrics.metrics.accumulated_token_usage.prompt_tokens + usage_metrics.metrics.accumulated_token_usage.completion_tokens:,}'
-
- labels_and_values = [
- (' Total Cost (USD):', cost_str),
- ('', ''),
- (' Total Input Tokens:', input_tokens_str),
- (' Cache Hits:', cache_read_str),
- (' Cache Writes:', cache_write_str),
- (' Total Output Tokens:', output_tokens_str),
- ('', ''),
- (' Total Tokens:', total_tokens_str),
- ]
-
- # Calculate max widths for alignment
- max_label_width = max(len(label) for label, _ in labels_and_values)
- max_value_width = max(len(value) for _, value in labels_and_values)
-
- # Construct the summary text with aligned columns
- summary_lines = [
- f'{label:<{max_label_width}} {value:<{max_value_width}}'
- for label, value in labels_and_values
- ]
- summary_text = '\n'.join(summary_lines)
-
- container = Frame(
- TextArea(
- text=summary_text,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title='Usage Metrics',
- style=f'fg:{COLOR_GREY}',
- )
-
- print_container(container)
-
-
-def get_session_duration(session_init_time: float) -> str:
- current_time = time.time()
- session_duration = current_time - session_init_time
- hours, remainder = divmod(session_duration, 3600)
- minutes, seconds = divmod(remainder, 60)
-
- return f'{int(hours)}h {int(minutes)}m {int(seconds)}s'
-
-
-def display_shutdown_message(usage_metrics: UsageMetrics, session_id: str) -> None:
- duration_str = get_session_duration(usage_metrics.session_init_time)
-
- print_formatted_text(HTML('Closing current conversation...'))
- print_formatted_text('')
- display_usage_metrics(usage_metrics)
- print_formatted_text('')
- print_formatted_text(HTML(f'Conversation duration: {duration_str}'))
- print_formatted_text('')
- print_formatted_text(HTML(f'Closed conversation {session_id}'))
- print_formatted_text('')
-
-
-def display_status(usage_metrics: UsageMetrics, session_id: str) -> None:
- duration_str = get_session_duration(usage_metrics.session_init_time)
-
- print_formatted_text('')
- print_formatted_text(HTML(f'Conversation ID: {session_id}'))
- print_formatted_text(HTML(f'Uptime: {duration_str}'))
- print_formatted_text('')
- display_usage_metrics(usage_metrics)
-
-
-def display_agent_running_message() -> None:
- print_formatted_text('')
- print_formatted_text(
- HTML('Agent running... (Press Ctrl-P to pause)')
- )
-
-
-def display_agent_state_change_message(agent_state: str) -> None:
- if agent_state == AgentState.PAUSED:
- print_formatted_text('')
- print_formatted_text(
- HTML(
- 'Agent paused... (Enter /resume to continue)'
- )
- )
- elif agent_state == AgentState.FINISHED:
- print_formatted_text('')
- print_formatted_text(HTML('Task completed...'))
- elif agent_state == AgentState.AWAITING_USER_INPUT:
- print_formatted_text('')
- print_formatted_text(HTML('Agent is waiting for your input...'))
-
-
-# Common input functions
-class CommandCompleter(Completer):
- """Custom completer for commands."""
-
- def __init__(self, agent_state: str) -> None:
- super().__init__()
- self.agent_state = agent_state
-
- def get_completions(
- self, document: Document, complete_event: CompleteEvent
- ) -> Generator[Completion, None, None]:
- text = document.text_before_cursor.lstrip()
- if text.startswith('/'):
- available_commands = dict(COMMANDS)
- if self.agent_state != AgentState.PAUSED:
- available_commands.pop('/resume', None)
-
- for command, description in available_commands.items():
- if command.startswith(text):
- yield Completion(
- command,
- start_position=-len(text),
- display_meta=description,
- style='bg:ansidarkgray fg:gold',
- )
-
-
-def create_prompt_session(config: OpenHandsConfig) -> PromptSession[str]:
- """Creates a prompt session with VI mode enabled if specified in the config."""
- return PromptSession(style=DEFAULT_STYLE, vi_mode=config.cli.vi_mode)
-
-
-async def read_prompt_input(
- config: OpenHandsConfig, agent_state: str, multiline: bool = False
-) -> str:
- try:
- prompt_session = create_prompt_session(config)
- prompt_session.completer = (
- CommandCompleter(agent_state) if not multiline else None
- )
-
- if multiline:
- kb = KeyBindings()
-
- @kb.add('c-d')
- def _(event: KeyPressEvent) -> None:
- event.current_buffer.validate_and_handle()
-
- with patch_stdout():
- print_formatted_text('')
- message = await prompt_session.prompt_async(
- HTML(
- 'Enter your message and press Ctrl-D to finish:\n'
- ),
- multiline=True,
- key_bindings=kb,
- )
- else:
- with patch_stdout():
- print_formatted_text('')
- message = await prompt_session.prompt_async(
- HTML('> '),
- )
- return message if message is not None else ''
- except (KeyboardInterrupt, EOFError):
- return '/exit'
-
-
-async def read_confirmation_input(
- config: OpenHandsConfig, security_risk: ActionSecurityRisk
-) -> str:
- try:
- if security_risk == ActionSecurityRisk.HIGH:
- question = 'HIGH RISK command detected.\nReview carefully before proceeding.\n\nChoose an option:'
- choices = [
- 'Yes, proceed (HIGH RISK - Use with caution)',
- 'No (and allow to enter instructions)',
- "Always proceed (don't ask again - NOT RECOMMENDED)",
- ]
- choice_mapping = {0: 'yes', 1: 'no', 2: 'always'}
- else:
- question = 'Choose an option:'
- choices = [
- 'Yes, proceed',
- 'No (and allow to enter instructions)',
- 'Auto-confirm action with LOW/MEDIUM risk, ask for HIGH risk',
- "Always proceed (don't ask again)",
- ]
- choice_mapping = {0: 'yes', 1: 'no', 2: 'auto_highrisk', 3: 'always'}
-
- # keep the outer coroutine responsive by using asyncio.to_thread which puts the blocking call app.run() of cli_confirm() in a separate thread
- index = await asyncio.to_thread(
- cli_confirm, config, question, choices, 0, security_risk
- )
-
- return choice_mapping.get(index, 'no')
-
- except (KeyboardInterrupt, EOFError):
- return 'no'
-
-
-def start_pause_listener(
- loop: asyncio.AbstractEventLoop,
- done_event: asyncio.Event,
- event_stream,
-) -> None:
- global pause_task
- if pause_task is None or pause_task.done():
- pause_task = loop.create_task(
- process_agent_pause(done_event, event_stream)
- ) # Create a task to track agent pause requests from the user
-
-
-async def stop_pause_listener() -> None:
- global pause_task
- if pause_task and not pause_task.done():
- pause_task.cancel()
- with contextlib.suppress(asyncio.CancelledError):
- await pause_task
- await asyncio.sleep(0)
- pause_task = None
-
-
-async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) -> None:
- input = create_input()
-
- def keys_ready() -> None:
- for key_press in input.read_keys():
- if (
- key_press.key == Keys.ControlP
- or key_press.key == Keys.ControlC
- or key_press.key == Keys.ControlD
- ):
- print_formatted_text('')
- print_formatted_text(HTML('Pausing the agent...'))
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.PAUSED),
- EventSource.USER,
- )
- done.set()
-
- try:
- with input.raw_mode():
- with input.attach(keys_ready):
- await done.wait()
- finally:
- input.close()
-
-
-def cli_confirm(
- config: OpenHandsConfig,
- question: str = 'Are you sure?',
- choices: list[str] | None = None,
- initial_selection: int = 0,
- security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN,
-) -> int:
- """Display a confirmation prompt with the given question and choices.
-
- Returns the index of the selected choice.
- """
- if choices is None:
- choices = ['Yes', 'No']
- selected = [initial_selection] # Using list to allow modification in closure
-
- def get_choice_text() -> list:
- # Use red styling for HIGH risk questions
- question_style = (
- 'class:risk-high'
- if security_risk == ActionSecurityRisk.HIGH
- else 'class:question'
- )
-
- return [
- (question_style, f'{question}\n\n'),
- ] + [
- (
- 'class:selected' if i == selected[0] else 'class:unselected',
- f'{"> " if i == selected[0] else " "}{choice}\n',
- )
- for i, choice in enumerate(choices)
- ]
-
- kb = KeyBindings()
-
- @kb.add('up')
- def _handle_up(event: KeyPressEvent) -> None:
- selected[0] = (selected[0] - 1) % len(choices)
-
- if config.cli.vi_mode:
-
- @kb.add('k')
- def _handle_k(event: KeyPressEvent) -> None:
- selected[0] = (selected[0] - 1) % len(choices)
-
- @kb.add('down')
- def _handle_down(event: KeyPressEvent) -> None:
- selected[0] = (selected[0] + 1) % len(choices)
-
- if config.cli.vi_mode:
-
- @kb.add('j')
- def _handle_j(event: KeyPressEvent) -> None:
- selected[0] = (selected[0] + 1) % len(choices)
-
- @kb.add('enter')
- def _handle_enter(event: KeyPressEvent) -> None:
- event.app.exit(result=selected[0])
-
- # Create layout with risk-based styling - full width but limited height
- content_window = Window(
- FormattedTextControl(get_choice_text),
- always_hide_cursor=True,
- height=Dimension(max=8), # Limit height to prevent screen takeover
- )
-
- # Add frame for HIGH risk commands
- if security_risk == ActionSecurityRisk.HIGH:
- layout = Layout(
- HSplit(
- [
- Frame(
- content_window,
- title='HIGH RISK',
- style='fg:#FF0000 bold', # Red color for HIGH risk
- )
- ]
- )
- )
- else:
- layout = Layout(HSplit([content_window]))
-
- app = Application(
- layout=layout,
- key_bindings=kb,
- style=DEFAULT_STYLE,
- full_screen=False,
- )
-
- return app.run(in_thread=True)
-
-
-def kb_cancel() -> KeyBindings:
- """Custom key bindings to handle ESC as a user cancellation."""
- bindings = KeyBindings()
-
- @bindings.add('escape')
- def _(event: KeyPressEvent) -> None:
- event.app.exit(exception=UserCancelledError, style='class:aborting')
-
- return bindings
-
-
-class UserCancelledError(Exception):
- """Raised when the user cancels an operation via key binding."""
-
- pass
-
-
-def handle_loop_recovery_state_observation(
- observation: LoopDetectionObservation,
-) -> None:
- """Handle loop recovery state observation events.
-
- Updates the global loop recovery state based on the observation.
- """
- content = observation.content
- container = Frame(
- TextArea(
- text=content,
- read_only=True,
- style=COLOR_GREY,
- wrap_lines=True,
- ),
- title='Agent Loop Detection',
- style=f'fg:{COLOR_GREY}',
- )
- print_formatted_text('')
- print_container(container)
diff --git a/openhands/cli/utils.py b/openhands/cli/utils.py
deleted file mode 100644
index ac9175d625..0000000000
--- a/openhands/cli/utils.py
+++ /dev/null
@@ -1,251 +0,0 @@
-from pathlib import Path
-
-import toml
-from pydantic import BaseModel, Field
-
-from openhands.cli.tui import (
- UsageMetrics,
-)
-from openhands.events.event import Event
-from openhands.llm.metrics import Metrics
-
-_LOCAL_CONFIG_FILE_PATH = Path.home() / '.openhands' / 'config.toml'
-_DEFAULT_CONFIG: dict[str, dict[str, list[str]]] = {'sandbox': {'trusted_dirs': []}}
-
-
-def get_local_config_trusted_dirs() -> list[str]:
- if _LOCAL_CONFIG_FILE_PATH.exists():
- with open(_LOCAL_CONFIG_FILE_PATH, 'r') as f:
- try:
- config = toml.load(f)
- except Exception:
- config = _DEFAULT_CONFIG
- if 'sandbox' in config and 'trusted_dirs' in config['sandbox']:
- return config['sandbox']['trusted_dirs']
- return []
-
-
-def add_local_config_trusted_dir(folder_path: str) -> None:
- config = _DEFAULT_CONFIG
- if _LOCAL_CONFIG_FILE_PATH.exists():
- try:
- with open(_LOCAL_CONFIG_FILE_PATH, 'r') as f:
- config = toml.load(f)
- except Exception:
- config = _DEFAULT_CONFIG
- else:
- _LOCAL_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
-
- if 'sandbox' not in config:
- config['sandbox'] = {}
- if 'trusted_dirs' not in config['sandbox']:
- config['sandbox']['trusted_dirs'] = []
-
- if folder_path not in config['sandbox']['trusted_dirs']:
- config['sandbox']['trusted_dirs'].append(folder_path)
-
- with open(_LOCAL_CONFIG_FILE_PATH, 'w') as f:
- toml.dump(config, f)
-
-
-def update_usage_metrics(event: Event, usage_metrics: UsageMetrics) -> None:
- if not hasattr(event, 'llm_metrics'):
- return
-
- llm_metrics: Metrics | None = event.llm_metrics
- if not llm_metrics:
- return
-
- usage_metrics.metrics = llm_metrics
-
-
-class ModelInfo(BaseModel):
- """Information about a model and its provider."""
-
- provider: str = Field(description='The provider of the model')
- model: str = Field(description='The model identifier')
- separator: str = Field(description='The separator used in the model identifier')
-
- def __getitem__(self, key: str) -> str:
- """Allow dictionary-like access to fields."""
- if key == 'provider':
- return self.provider
- elif key == 'model':
- return self.model
- elif key == 'separator':
- return self.separator
- raise KeyError(f'ModelInfo has no key {key}')
-
-
-def extract_model_and_provider(model: str) -> ModelInfo:
- """Extract provider and model information from a model identifier.
-
- Args:
- model: The model identifier string
-
- Returns:
- A ModelInfo object containing provider, model, and separator information
- """
- separator = '/'
- split = model.split(separator)
-
- if len(split) == 1:
- # no "/" separator found, try with "."
- separator = '.'
- split = model.split(separator)
- if split_is_actually_version(split):
- split = [separator.join(split)] # undo the split
-
- if len(split) == 1:
- # no "/" or "." separator found
- if split[0] in VERIFIED_OPENAI_MODELS:
- return ModelInfo(provider='openai', model=split[0], separator='/')
- if split[0] in VERIFIED_ANTHROPIC_MODELS:
- return ModelInfo(provider='anthropic', model=split[0], separator='/')
- if split[0] in VERIFIED_MISTRAL_MODELS:
- return ModelInfo(provider='mistral', model=split[0], separator='/')
- if split[0] in VERIFIED_OPENHANDS_MODELS:
- return ModelInfo(provider='openhands', model=split[0], separator='/')
- # return as model only
- return ModelInfo(provider='', model=model, separator='')
-
- provider = split[0]
- model_id = separator.join(split[1:])
- return ModelInfo(provider=provider, model=model_id, separator=separator)
-
-
-def organize_models_and_providers(
- models: list[str],
-) -> dict[str, 'ProviderInfo']:
- """Organize a list of model identifiers by provider.
-
- Args:
- models: List of model identifiers
-
- Returns:
- A mapping of providers to their information and models
- """
- result_dict: dict[str, ProviderInfo] = {}
-
- for model in models:
- extracted = extract_model_and_provider(model)
- separator = extracted.separator
- provider = extracted.provider
- model_id = extracted.model
-
- # Ignore "anthropic" providers with a separator of "."
- # These are outdated and incompatible providers.
- if provider == 'anthropic' and separator == '.':
- continue
-
- key = provider or 'other'
- if key not in result_dict:
- result_dict[key] = ProviderInfo(separator=separator, models=[])
-
- result_dict[key].models.append(model_id)
-
- return result_dict
-
-
-VERIFIED_PROVIDERS = ['openhands', 'anthropic', 'openai', 'mistral']
-
-VERIFIED_OPENAI_MODELS = [
- 'gpt-5-2025-08-07',
- 'gpt-5-mini-2025-08-07',
- 'o4-mini',
- 'gpt-4o',
- 'gpt-4o-mini',
- 'gpt-4-32k',
- 'gpt-4.1',
- 'gpt-4.1-2025-04-14',
- 'o1-mini',
- 'o3',
- 'codex-mini-latest',
-]
-
-VERIFIED_ANTHROPIC_MODELS = [
- 'claude-sonnet-4-20250514',
- 'claude-sonnet-4-5-20250929',
- 'claude-haiku-4-5-20251001',
- 'claude-opus-4-20250514',
- 'claude-opus-4-1-20250805',
- 'claude-3-7-sonnet-20250219',
- 'claude-3-sonnet-20240229',
- 'claude-3-opus-20240229',
- 'claude-3-haiku-20240307',
- 'claude-3-5-haiku-20241022',
- 'claude-3-5-sonnet-20241022',
- 'claude-3-5-sonnet-20240620',
- 'claude-2.1',
- 'claude-2',
-]
-
-VERIFIED_MISTRAL_MODELS = [
- 'devstral-small-2505',
- 'devstral-small-2507',
- 'devstral-medium-2507',
-]
-
-VERIFIED_OPENHANDS_MODELS = [
- 'claude-sonnet-4-20250514',
- 'claude-sonnet-4-5-20250929',
- 'claude-haiku-4-5-20251001',
- 'gpt-5-2025-08-07',
- 'gpt-5-mini-2025-08-07',
- 'claude-opus-4-20250514',
- 'claude-opus-4-1-20250805',
- 'devstral-small-2507',
- 'devstral-medium-2507',
- 'o3',
- 'o4-mini',
- 'gemini-2.5-pro',
- 'kimi-k2-0711-preview',
- 'qwen3-coder-480b',
-]
-
-
-class ProviderInfo(BaseModel):
- """Information about a provider and its models."""
-
- separator: str = Field(description='The separator used in model identifiers')
- models: list[str] = Field(
- default_factory=list, description='List of model identifiers'
- )
-
- def __getitem__(self, key: str) -> str | list[str]:
- """Allow dictionary-like access to fields."""
- if key == 'separator':
- return self.separator
- elif key == 'models':
- return self.models
- raise KeyError(f'ProviderInfo has no key {key}')
-
- def get(self, key: str, default: None = None) -> str | list[str] | None:
- """Dictionary-like get method with default value."""
- try:
- return self[key]
- except KeyError:
- return default
-
-
-def is_number(char: str) -> bool:
- return char.isdigit()
-
-
-def split_is_actually_version(split: list[str]) -> bool:
- return (
- len(split) > 1
- and bool(split[1])
- and bool(split[1][0])
- and is_number(split[1][0])
- )
-
-
-def read_file(file_path: str | Path) -> str:
- with open(file_path, 'r') as f:
- return f.read()
-
-
-def write_to_file(file_path: str | Path, content: str) -> None:
- with open(file_path, 'w') as f:
- f.write(content)
diff --git a/openhands/cli/vscode_extension.py b/openhands/cli/vscode_extension.py
deleted file mode 100644
index 3cc92e8b96..0000000000
--- a/openhands/cli/vscode_extension.py
+++ /dev/null
@@ -1,316 +0,0 @@
-import importlib.resources
-import json
-import os
-import pathlib
-import subprocess
-import tempfile
-import urllib.request
-from urllib.error import URLError
-
-from openhands.core.logger import openhands_logger as logger
-
-
-def download_latest_vsix_from_github() -> str | None:
- """Download latest .vsix from GitHub releases.
-
- Returns:
- Path to downloaded .vsix file, or None if failed
- """
- api_url = 'https://api.github.com/repos/OpenHands/OpenHands/releases'
- try:
- with urllib.request.urlopen(api_url, timeout=10) as response:
- if response.status != 200:
- logger.debug(
- f'GitHub API request failed with status: {response.status}'
- )
- return None
- releases = json.loads(response.read().decode())
- # The GitHub API returns releases in reverse chronological order (newest first).
- # We iterate through them and use the first one that matches our extension prefix.
- for release in releases:
- if release.get('tag_name', '').startswith('ext-v'):
- for asset in release.get('assets', []):
- if asset.get('name', '').endswith('.vsix'):
- download_url = asset.get('browser_download_url')
- if not download_url:
- continue
- with urllib.request.urlopen(
- download_url, timeout=30
- ) as download_response:
- if download_response.status != 200:
- logger.debug(
- f'Failed to download .vsix with status: {download_response.status}'
- )
- continue
- with tempfile.NamedTemporaryFile(
- delete=False, suffix='.vsix'
- ) as tmp_file:
- tmp_file.write(download_response.read())
- return tmp_file.name
- # Found the latest extension release but no .vsix asset
- return None
- except (URLError, TimeoutError, json.JSONDecodeError) as e:
- logger.debug(f'Failed to download from GitHub releases: {e}')
- return None
- return None
-
-
-def attempt_vscode_extension_install():
- """Checks if running in a supported editor and attempts to install the OpenHands companion extension.
- This is a best-effort, one-time attempt.
- """
- # 1. Check if we are in a supported editor environment
- is_vscode_like = os.environ.get('TERM_PROGRAM') == 'vscode'
- is_windsurf = (
- os.environ.get('__CFBundleIdentifier') == 'com.exafunction.windsurf'
- or 'windsurf' in os.environ.get('PATH', '').lower()
- or any(
- 'windsurf' in val.lower()
- for val in os.environ.values()
- if isinstance(val, str)
- )
- )
- if not (is_vscode_like or is_windsurf):
- return
-
- # 2. Determine editor-specific commands and flags
- if is_windsurf:
- editor_command, editor_name, flag_suffix = 'surf', 'Windsurf', 'windsurf'
- else:
- editor_command, editor_name, flag_suffix = 'code', 'VS Code', 'vscode'
-
- # 3. Check if we've already successfully installed the extension.
- flag_dir = pathlib.Path.home() / '.openhands'
- flag_file = flag_dir / f'.{flag_suffix}_extension_installed'
- extension_id = 'openhands.openhands-vscode'
-
- try:
- flag_dir.mkdir(parents=True, exist_ok=True)
- if flag_file.exists():
- return # Already successfully installed, exit.
- except OSError as e:
- logger.debug(
- f'Could not create or check {editor_name} extension flag directory: {e}'
- )
- return # Don't proceed if we can't manage the flag.
-
- # 4. Check if the extension is already installed (even without our flag).
- if _is_extension_installed(editor_command, extension_id):
- print(f'INFO: OpenHands {editor_name} extension is already installed.')
- # Create flag to avoid future checks
- _mark_installation_successful(flag_file, editor_name)
- return
-
- # 5. Extension is not installed, attempt installation.
- print(
- f'INFO: First-time setup: attempting to install the OpenHands {editor_name} extension...'
- )
-
- # Attempt 1: Install from bundled .vsix
- if _attempt_bundled_install(editor_command, editor_name):
- _mark_installation_successful(flag_file, editor_name)
- return # Success! We are done.
-
- # Attempt 2: Download from GitHub Releases
- if _attempt_github_install(editor_command, editor_name):
- _mark_installation_successful(flag_file, editor_name)
- return # Success! We are done.
-
- # TODO: Attempt 3: Install from Marketplace (when extension is published)
- # if _attempt_marketplace_install(editor_command, editor_name, extension_id):
- # _mark_installation_successful(flag_file, editor_name)
- # return # Success! We are done.
-
- # If all attempts failed, inform the user (but don't create flag - allow retry).
- print(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
- print(
- f'INFO: Will retry installation next time you run OpenHands in {editor_name}.'
- )
-
-
-def _mark_installation_successful(flag_file: pathlib.Path, editor_name: str) -> None:
- """Mark the extension installation as successful by creating the flag file.
-
- Args:
- flag_file: Path to the flag file to create
- editor_name: Human-readable name of the editor for logging
- """
- try:
- flag_file.touch()
- logger.debug(f'{editor_name} extension installation marked as successful.')
- except OSError as e:
- logger.debug(f'Could not create {editor_name} extension success flag file: {e}')
-
-
-def _is_extension_installed(editor_command: str, extension_id: str) -> bool:
- """Check if the OpenHands extension is already installed.
-
- Args:
- editor_command: The command to run the editor (e.g., 'code', 'windsurf')
- extension_id: The extension ID to check for
-
- Returns:
- bool: True if extension is already installed, False otherwise
- """
- try:
- process = subprocess.run(
- [editor_command, '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- if process.returncode == 0:
- installed_extensions = process.stdout.strip().split('\n')
- return extension_id in installed_extensions
- except Exception as e:
- logger.debug(f'Could not check installed extensions: {e}')
-
- return False
-
-
-def _attempt_github_install(editor_command: str, editor_name: str) -> bool:
- """Attempt to install the extension from GitHub Releases.
-
- Downloads the latest VSIX file from GitHub releases and attempts to install it.
- Ensures proper cleanup of temporary files.
-
- Args:
- editor_command: The command to run the editor (e.g., 'code', 'windsurf')
- editor_name: Human-readable name of the editor (e.g., 'VS Code', 'Windsurf')
-
- Returns:
- bool: True if installation succeeded, False otherwise
- """
- vsix_path_from_github = download_latest_vsix_from_github()
- if not vsix_path_from_github:
- return False
-
- github_success = False
- try:
- process = subprocess.run(
- [
- editor_command,
- '--install-extension',
- vsix_path_from_github,
- '--force',
- ],
- capture_output=True,
- text=True,
- check=False,
- )
- if process.returncode == 0:
- print(
- f'INFO: OpenHands {editor_name} extension installed successfully from GitHub.'
- )
- github_success = True
- else:
- logger.debug(
- f'Failed to install .vsix from GitHub: {process.stderr.strip()}'
- )
- finally:
- # Clean up the downloaded file
- if os.path.exists(vsix_path_from_github):
- try:
- os.remove(vsix_path_from_github)
- except OSError as e:
- logger.debug(
- f'Failed to delete temporary file {vsix_path_from_github}: {e}'
- )
-
- return github_success
-
-
-def _attempt_bundled_install(editor_command: str, editor_name: str) -> bool:
- """Attempt to install the extension from the bundled VSIX file.
-
- Uses the VSIX file packaged with the OpenHands installation.
-
- Args:
- editor_command: The command to run the editor (e.g., 'code', 'windsurf')
- editor_name: Human-readable name of the editor (e.g., 'VS Code', 'Windsurf')
-
- Returns:
- bool: True if installation succeeded, False otherwise
- """
- try:
- vsix_filename = 'openhands-vscode-0.0.1.vsix'
- with importlib.resources.as_file(
- importlib.resources.files('openhands').joinpath(
- 'integrations', 'vscode', vsix_filename
- )
- ) as vsix_path:
- if vsix_path.exists():
- process = subprocess.run(
- [
- editor_command,
- '--install-extension',
- str(vsix_path),
- '--force',
- ],
- capture_output=True,
- text=True,
- check=False,
- )
- if process.returncode == 0:
- print(
- f'INFO: Bundled {editor_name} extension installed successfully.'
- )
- return True
- else:
- logger.debug(
- f'Bundled .vsix installation failed: {process.stderr.strip()}'
- )
- else:
- logger.debug(f'Bundled .vsix not found at {vsix_path}.')
- except Exception as e:
- logger.warning(
- f'Could not auto-install extension. Please make sure "code" command is in PATH. Error: {e}'
- )
-
- return False
-
-
-def _attempt_marketplace_install(
- editor_command: str, editor_name: str, extension_id: str
-) -> bool:
- """Attempt to install the extension from the marketplace.
-
- This method is currently unused as the OpenHands extension is not yet published
- to the VS Code/Windsurf marketplace. It's kept here for future use when the
- extension becomes available.
-
- Args:
- editor_command: The command to use ('code' or 'surf')
- editor_name: Human-readable editor name ('VS Code' or 'Windsurf')
- extension_id: The extension ID to install
-
- Returns:
- True if installation succeeded, False otherwise
- """
- try:
- process = subprocess.run(
- [editor_command, '--install-extension', extension_id, '--force'],
- capture_output=True,
- text=True,
- check=False,
- )
- if process.returncode == 0:
- print(
- f'INFO: {editor_name} extension installed successfully from the Marketplace.'
- )
- return True
- else:
- logger.debug(f'Marketplace installation failed: {process.stderr.strip()}')
- return False
- except FileNotFoundError:
- print(
- f"INFO: To complete {editor_name} integration, please ensure the '{editor_command}' command-line tool is in your PATH."
- )
- return False
- except Exception as e:
- logger.debug(
- f'An unexpected error occurred trying to install from the Marketplace: {e}'
- )
- return False
diff --git a/openhands/core/main.py b/openhands/core/main.py
index f1f5cce6fb..633fed3a17 100644
--- a/openhands/core/main.py
+++ b/openhands/core/main.py
@@ -7,7 +7,6 @@ from pathlib import Path
from typing import Callable, Protocol
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
-import openhands.cli.suppress_warnings # noqa: F401
from openhands.controller.replay import ReplayManager
from openhands.controller.state.state import State
from openhands.core.config import (
diff --git a/pyproject.toml b/pyproject.toml
index 09075bdce0..0b8945e6b9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -187,9 +187,6 @@ joblib = "*"
swebench = { git = "https://github.com/ryanhoangt/SWE-bench.git", rev = "fix-modal-patch-eval" }
multi-swe-bench = "0.1.2"
-[tool.poetry.scripts]
-openhands = "openhands.cli.entry:main"
-
[tool.poetry.group.testgeneval.dependencies]
fuzzywuzzy = "^0.18.0"
rouge = "^1.0.1"
diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py
deleted file mode 100644
index a6c9e90161..0000000000
--- a/tests/unit/cli/test_cli.py
+++ /dev/null
@@ -1,1016 +0,0 @@
-import asyncio
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-import pytest_asyncio
-
-from openhands.cli import main as cli
-from openhands.controller.state.state import State
-from openhands.core.config.llm_config import LLMConfig
-from openhands.events import EventSource
-from openhands.events.action import MessageAction
-
-
-@pytest_asyncio.fixture
-def mock_agent():
- agent = AsyncMock()
- agent.reset = MagicMock()
- return agent
-
-
-@pytest_asyncio.fixture
-def mock_runtime():
- runtime = AsyncMock()
- runtime.close = MagicMock()
- runtime.event_stream = MagicMock()
- return runtime
-
-
-@pytest_asyncio.fixture
-def mock_controller():
- controller = AsyncMock()
- controller.close = AsyncMock()
-
- # Setup for get_state() and the returned state's save_to_session()
- mock_state = MagicMock()
- mock_state.save_to_session = MagicMock()
- controller.get_state = MagicMock(return_value=mock_state)
- return controller
-
-
-@pytest.mark.asyncio
-async def test_cleanup_session_closes_resources(
- mock_agent, mock_runtime, mock_controller
-):
- """Test that cleanup_session calls close methods on agent, runtime, and controller."""
- loop = asyncio.get_running_loop()
- await cli.cleanup_session(loop, mock_agent, mock_runtime, mock_controller)
-
- mock_agent.reset.assert_called_once()
- mock_runtime.close.assert_called_once()
- mock_controller.close.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_cleanup_session_cancels_pending_tasks(
- mock_agent, mock_runtime, mock_controller
-):
- """Test that cleanup_session cancels other pending tasks."""
- loop = asyncio.get_running_loop()
- other_task_ran = False
- other_task_cancelled = False
-
- async def _other_task_func():
- nonlocal other_task_ran, other_task_cancelled
- try:
- other_task_ran = True
- await asyncio.sleep(5) # Sleep long enough to be cancelled
- except asyncio.CancelledError:
- other_task_cancelled = True
- raise
-
- other_task = loop.create_task(_other_task_func())
-
- # Allow the other task to start running
- await asyncio.sleep(0)
- assert other_task_ran is True
-
- # Run cleanup session directly from the test task
- await cli.cleanup_session(loop, mock_agent, mock_runtime, mock_controller)
- await asyncio.sleep(0)
-
- # Check that the other task was indeed cancelled
- assert other_task.cancelled() or other_task_cancelled is True
-
- # Ensure the cleanup finishes (awaiting the task raises CancelledError if cancelled)
- try:
- await other_task
- except asyncio.CancelledError:
- pass # Expected
-
- # Verify cleanup still called mocks
- mock_agent.reset.assert_called_once()
- mock_runtime.close.assert_called_once()
- mock_controller.close.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_cleanup_session_handles_exceptions(
- mock_agent, mock_runtime, mock_controller
-):
- """Test that cleanup_session handles exceptions during cleanup gracefully."""
- loop = asyncio.get_running_loop()
- mock_controller.close.side_effect = Exception('Test cleanup error')
- with patch('openhands.cli.main.logger.error') as mock_log_error:
- await cli.cleanup_session(loop, mock_agent, mock_runtime, mock_controller)
-
- # Check that cleanup continued despite the error
- mock_agent.reset.assert_called_once()
- mock_runtime.close.assert_called_once()
- # Check that the error was logged
- mock_log_error.assert_called_once()
- assert 'Test cleanup error' in mock_log_error.call_args[0][0]
-
-
-@pytest_asyncio.fixture
-def mock_config():
- config = MagicMock()
- config.runtime = 'local'
- config.cli_multiline_input = False
- config.workspace_base = '/test/dir'
-
- # Mock search_api_key with get_secret_value method
- search_api_key_mock = MagicMock()
- search_api_key_mock.get_secret_value.return_value = (
- '' # Empty string, not starting with 'tvly-'
- )
- config.search_api_key = search_api_key_mock
- config.get_llm_config_from_agent.return_value = LLMConfig(model='model')
-
- # Mock sandbox with volumes attribute to prevent finalize_config issues
- config.sandbox = MagicMock()
- config.sandbox.volumes = (
- None # This prevents finalize_config from overriding workspace_base
- )
- config.model_name = 'model'
-
- return config
-
-
-@pytest_asyncio.fixture
-def mock_settings_store():
- settings_store = AsyncMock()
- return settings_store
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.display_runtime_initialization_message')
-@patch('openhands.cli.main.display_initialization_animation')
-@patch('openhands.cli.main.create_agent')
-@patch('openhands.cli.main.add_mcp_tools_to_agent')
-@patch('openhands.cli.main.create_runtime')
-@patch('openhands.cli.main.create_controller')
-@patch(
- 'openhands.cli.main.create_memory',
-)
-@patch('openhands.cli.main.run_agent_until_done')
-@patch('openhands.cli.main.cleanup_session')
-@patch('openhands.cli.main.initialize_repository_for_runtime')
-async def test_run_session_without_initial_action(
- mock_initialize_repo,
- mock_cleanup_session,
- mock_run_agent_until_done,
- mock_create_memory,
- mock_create_controller,
- mock_create_runtime,
- mock_add_mcp_tools,
- mock_create_agent,
- mock_display_animation,
- mock_display_runtime_init,
- mock_config,
- mock_settings_store,
-):
- """Test run_session function with no initial user action."""
- loop = asyncio.get_running_loop()
-
- # Mock initialize_repository_for_runtime to return a valid path
- mock_initialize_repo.return_value = '/test/dir'
-
- # Mock objects returned by the setup functions
- mock_agent = AsyncMock()
- mock_create_agent.return_value = mock_agent
-
- mock_runtime = AsyncMock()
- mock_runtime.event_stream = MagicMock()
- mock_create_runtime.return_value = mock_runtime
-
- mock_controller = AsyncMock()
- mock_controller_task = MagicMock()
- mock_create_controller.return_value = (mock_controller, mock_controller_task)
-
- # Create a regular MagicMock for memory to avoid coroutine issues
- mock_memory = MagicMock()
- mock_create_memory.return_value = mock_memory
-
- with patch(
- 'openhands.cli.main.read_prompt_input', new_callable=AsyncMock
- ) as mock_read_prompt:
- # Set up read_prompt_input to return a string that will trigger the command handler
- mock_read_prompt.return_value = '/exit'
-
- # Mock handle_commands to return values that will exit the loop
- with patch(
- 'openhands.cli.main.handle_commands', new_callable=AsyncMock
- ) as mock_handle_commands:
- mock_handle_commands.return_value = (
- True,
- False,
- False,
- ) # close_repl, reload_microagents, new_session_requested
-
- # Run the function
- result = await cli.run_session(
- loop, mock_config, mock_settings_store, '/test/dir'
- )
-
- # Assertions for initialization flow
- mock_display_runtime_init.assert_called_once_with('local')
- mock_display_animation.assert_called_once()
- # Check that mock_config is the first parameter to create_agent
- mock_create_agent.assert_called_once()
- assert mock_create_agent.call_args[0][0] == mock_config, (
- 'First parameter to create_agent should be mock_config'
- )
- mock_add_mcp_tools.assert_called_once_with(mock_agent, mock_runtime, mock_memory)
- mock_create_runtime.assert_called_once()
- mock_create_controller.assert_called_once()
- mock_create_memory.assert_called_once()
-
- # Check that run_agent_until_done was called
- mock_run_agent_until_done.assert_called_once()
-
- # Check that cleanup_session was called
- mock_cleanup_session.assert_called_once()
-
- # Check that the function returns the expected value
- assert result is False
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.display_runtime_initialization_message')
-@patch('openhands.cli.main.display_initialization_animation')
-@patch('openhands.cli.main.create_agent')
-@patch('openhands.cli.main.add_mcp_tools_to_agent')
-@patch('openhands.cli.main.create_runtime')
-@patch('openhands.cli.main.create_controller')
-@patch('openhands.cli.main.create_memory', new_callable=AsyncMock)
-@patch('openhands.cli.main.run_agent_until_done')
-@patch('openhands.cli.main.cleanup_session')
-@patch('openhands.cli.main.initialize_repository_for_runtime')
-async def test_run_session_with_initial_action(
- mock_initialize_repo,
- mock_cleanup_session,
- mock_run_agent_until_done,
- mock_create_memory,
- mock_create_controller,
- mock_create_runtime,
- mock_add_mcp_tools,
- mock_create_agent,
- mock_display_animation,
- mock_display_runtime_init,
- mock_config,
- mock_settings_store,
-):
- """Test run_session function with an initial user action."""
- loop = asyncio.get_running_loop()
-
- # Mock initialize_repository_for_runtime to return a valid path
- mock_initialize_repo.return_value = '/test/dir'
-
- # Mock objects returned by the setup functions
- mock_agent = AsyncMock()
- mock_create_agent.return_value = mock_agent
-
- mock_runtime = AsyncMock()
- mock_runtime.event_stream = MagicMock()
- mock_create_runtime.return_value = mock_runtime
-
- mock_controller = AsyncMock()
- mock_create_controller.return_value = (
- mock_controller,
- None,
- ) # Ensure initial_state is None for this test
-
- mock_memory = AsyncMock()
- mock_create_memory.return_value = mock_memory
-
- # Create an initial action
- initial_action_content = 'Test initial message'
-
- # Run the function with the initial action
- with patch(
- 'openhands.cli.main.read_prompt_input', new_callable=AsyncMock
- ) as mock_read_prompt:
- # Set up read_prompt_input to return a string that will trigger the command handler
- mock_read_prompt.return_value = '/exit'
-
- # Mock handle_commands to return values that will exit the loop
- with patch(
- 'openhands.cli.main.handle_commands', new_callable=AsyncMock
- ) as mock_handle_commands:
- mock_handle_commands.return_value = (
- True,
- False,
- False,
- ) # close_repl, reload_microagents, new_session_requested
-
- # Run the function
- result = await cli.run_session(
- loop,
- mock_config,
- mock_settings_store,
- '/test/dir',
- initial_action_content,
- )
-
- # Check that the initial action was added to the event stream
- # It should be converted to a MessageAction in the code
- mock_runtime.event_stream.add_event.assert_called_once()
- call_args = mock_runtime.event_stream.add_event.call_args[0]
- assert isinstance(call_args[0], MessageAction)
- assert call_args[0].content == initial_action_content
- assert call_args[1] == EventSource.USER
-
- # Check that run_agent_until_done was called
- mock_run_agent_until_done.assert_called_once()
-
- # Check that cleanup_session was called
- mock_cleanup_session.assert_called_once()
-
- # Check that the function returns the expected value
- assert result is False
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.setup_config_from_args')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.check_folder_security_agreement')
-@patch('openhands.cli.main.read_task')
-@patch('openhands.cli.main.run_session')
-@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
-@patch('openhands.cli.main.NoOpCondenserConfig')
-@patch('openhands.cli.main.finalize_config')
-@patch('openhands.cli.main.aliases_exist_in_shell_config')
-async def test_main_without_task(
- mock_aliases_exist,
- mock_finalize_config,
- mock_noop_condenser,
- mock_llm_condenser,
- mock_run_session,
- mock_read_task,
- mock_check_security,
- mock_get_settings_store,
- mock_setup_config,
-):
- """Test main function without a task."""
- loop = asyncio.get_running_loop()
-
- # Mock alias setup functions to prevent the alias setup flow
- mock_aliases_exist.return_value = True
-
- # Mock arguments
- mock_args = MagicMock()
- mock_args.agent_cls = None
- mock_args.llm_config = None
- mock_args.name = None
- mock_args.file = None
- mock_args.conversation = None
- mock_args.log_level = None
- mock_args.config_file = 'config.toml'
- mock_args.override_cli_mode = None
-
- # Mock config
- mock_config = MagicMock()
- mock_config.workspace_base = '/test/dir'
- mock_config.cli_multiline_input = False
- mock_setup_config.return_value = mock_config
-
- # Mock settings store
- mock_settings_store = AsyncMock()
- mock_settings = MagicMock()
- mock_settings.agent = 'test-agent'
- mock_settings.llm_model = 'test-model'
- mock_settings.llm_api_key = 'test-api-key'
- mock_settings.llm_base_url = 'test-base-url'
- mock_settings.confirmation_mode = True
- mock_settings.enable_default_condenser = True
- mock_settings_store.load.return_value = mock_settings
- mock_get_settings_store.return_value = mock_settings_store
-
- # Mock condenser config to return a mock instead of validating
- mock_llm_condenser_instance = MagicMock()
- mock_llm_condenser.return_value = mock_llm_condenser_instance
-
- # Mock security check
- mock_check_security.return_value = True
-
- # Mock read_task to return no task
- mock_read_task.return_value = None
-
- # Mock run_session to return False (no new session requested)
- mock_run_session.return_value = False
-
- # Run the function
- await cli.main_with_loop(loop, mock_args)
-
- # Assertions
- mock_setup_config.assert_called_once_with(mock_args)
- mock_get_settings_store.assert_called_once()
- mock_settings_store.load.assert_called_once()
- mock_check_security.assert_called_once_with(mock_config, '/test/dir')
- mock_read_task.assert_called_once()
-
- # Check that run_session was called with expected arguments
- mock_run_session.assert_called_once_with(
- loop,
- mock_config,
- mock_settings_store,
- '/test/dir',
- None,
- session_name=None,
- skip_banner=False,
- conversation_id=None,
- )
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.setup_config_from_args')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.check_folder_security_agreement')
-@patch('openhands.cli.main.read_task')
-@patch('openhands.cli.main.run_session')
-@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
-@patch('openhands.cli.main.NoOpCondenserConfig')
-@patch('openhands.cli.main.finalize_config')
-@patch('openhands.cli.main.aliases_exist_in_shell_config')
-async def test_main_with_task(
- mock_aliases_exist,
- mock_finalize_config,
- mock_noop_condenser,
- mock_llm_condenser,
- mock_run_session,
- mock_read_task,
- mock_check_security,
- mock_get_settings_store,
- mock_setup_config,
-):
- """Test main function with a task."""
- loop = asyncio.get_running_loop()
-
- # Mock alias setup functions to prevent the alias setup flow
- mock_aliases_exist.return_value = True
-
- # Mock arguments
- mock_args = MagicMock()
- mock_args.agent_cls = 'custom-agent'
- mock_args.llm_config = 'custom-config'
- mock_args.file = None
- mock_args.name = None
- mock_args.conversation = None
- mock_args.log_level = None
- mock_args.config_file = 'config.toml'
- mock_args.override_cli_mode = None
-
- # Mock config
- mock_config = MagicMock()
- mock_config.workspace_base = '/test/dir'
- mock_config.cli_multiline_input = False
- mock_setup_config.return_value = mock_config
-
- # Mock settings store
- mock_settings_store = AsyncMock()
- mock_settings = MagicMock()
- mock_settings.agent = 'test-agent'
- mock_settings.llm_model = 'test-model'
- mock_settings.llm_api_key = 'test-api-key'
- mock_settings.llm_base_url = 'test-base-url'
- mock_settings.confirmation_mode = True
- mock_settings.enable_default_condenser = False
- mock_settings_store.load.return_value = mock_settings
- mock_get_settings_store.return_value = mock_settings_store
-
- # Mock condenser config to return a mock instead of validating
- mock_noop_condenser_instance = MagicMock()
- mock_noop_condenser.return_value = mock_noop_condenser_instance
-
- # Mock security check
- mock_check_security.return_value = True
-
- # Mock read_task to return a task
- task_str = 'Build a simple web app'
- mock_read_task.return_value = task_str
-
- # Mock run_session to return True and then False (one new session requested)
- mock_run_session.side_effect = [True, False]
-
- # Run the function
- await cli.main_with_loop(loop, mock_args)
-
- # Assertions
- mock_setup_config.assert_called_once_with(mock_args)
- mock_get_settings_store.assert_called_once()
- mock_settings_store.load.assert_called_once()
- mock_check_security.assert_called_once_with(mock_config, '/test/dir')
- mock_read_task.assert_called_once()
-
- # Verify that run_session was called twice:
- # - First with the initial MessageAction
- # - Second with None after new_session_requested=True
- assert mock_run_session.call_count == 2
-
- # First call should include a string with the task content
- first_call_args = mock_run_session.call_args_list[0][0]
- assert first_call_args[0] == loop
- assert first_call_args[1] == mock_config
- assert first_call_args[2] == mock_settings_store
- assert first_call_args[3] == '/test/dir'
- assert isinstance(first_call_args[4], str)
- assert first_call_args[4] == task_str
-
- # Second call should have None for the action
- second_call_args = mock_run_session.call_args_list[1][0]
- assert second_call_args[0] == loop
- assert second_call_args[1] == mock_config
- assert second_call_args[2] == mock_settings_store
- assert second_call_args[3] == '/test/dir'
- assert second_call_args[4] is None
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.setup_config_from_args')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.check_folder_security_agreement')
-@patch('openhands.cli.main.read_task')
-@patch('openhands.cli.main.run_session')
-@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
-@patch('openhands.cli.main.NoOpCondenserConfig')
-@patch('openhands.cli.main.finalize_config')
-@patch('openhands.cli.main.aliases_exist_in_shell_config')
-async def test_main_with_session_name_passes_name_to_run_session(
- mock_aliases_exist,
- mock_finalize_config,
- mock_noop_condenser,
- mock_llm_condenser,
- mock_run_session,
- mock_read_task,
- mock_check_security,
- mock_get_settings_store,
- mock_setup_config,
-):
- """Test main function with a session name passes it to run_session."""
- loop = asyncio.get_running_loop()
- test_session_name = 'my_named_session'
-
- # Mock alias setup functions to prevent the alias setup flow
- mock_aliases_exist.return_value = True
-
- # Mock arguments
- mock_args = MagicMock()
- mock_args.agent_cls = None
- mock_args.llm_config = None
- mock_args.name = test_session_name # Set the session name
- mock_args.file = None
- mock_args.conversation = None
- mock_args.log_level = None
- mock_args.config_file = 'config.toml'
- mock_args.override_cli_mode = None
-
- # Mock config
- mock_config = MagicMock()
- mock_config.workspace_base = '/test/dir'
- mock_config.cli_multiline_input = False
- mock_setup_config.return_value = mock_config
-
- # Mock settings store
- mock_settings_store = AsyncMock()
- mock_settings = MagicMock()
- mock_settings.agent = 'test-agent'
- mock_settings.llm_model = 'test-model' # Copied from test_main_without_task
- mock_settings.llm_api_key = 'test-api-key' # Copied from test_main_without_task
- mock_settings.llm_base_url = 'test-base-url' # Copied from test_main_without_task
- mock_settings.confirmation_mode = True # Copied from test_main_without_task
- mock_settings.enable_default_condenser = True # Copied from test_main_without_task
- mock_settings_store.load.return_value = mock_settings
- mock_get_settings_store.return_value = mock_settings_store
-
- # Mock condenser config (as in test_main_without_task)
- mock_llm_condenser_instance = MagicMock()
- mock_llm_condenser.return_value = mock_llm_condenser_instance
-
- # Mock security check
- mock_check_security.return_value = True
-
- # Mock read_task to return no task
- mock_read_task.return_value = None
-
- # Mock run_session to return False (no new session requested)
- mock_run_session.return_value = False
-
- # Run the function
- await cli.main_with_loop(loop, mock_args)
-
- # Assertions
- mock_setup_config.assert_called_once_with(mock_args)
- mock_get_settings_store.assert_called_once()
- mock_settings_store.load.assert_called_once()
- mock_check_security.assert_called_once_with(mock_config, '/test/dir')
- mock_read_task.assert_called_once()
-
- # Check that run_session was called with the correct session_name
- mock_run_session.assert_called_once_with(
- loop,
- mock_config,
- mock_settings_store,
- '/test/dir',
- None,
- session_name=test_session_name,
- skip_banner=False,
- conversation_id=None,
- )
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.generate_sid')
-@patch('openhands.cli.main.create_agent')
-@patch('openhands.cli.main.create_runtime') # Returns mock_runtime
-@patch('openhands.cli.main.create_memory')
-@patch('openhands.cli.main.add_mcp_tools_to_agent')
-@patch('openhands.cli.main.run_agent_until_done')
-@patch('openhands.cli.main.cleanup_session')
-@patch(
- 'openhands.cli.main.read_prompt_input', new_callable=AsyncMock
-) # For REPL control
-@patch('openhands.cli.main.handle_commands', new_callable=AsyncMock) # For REPL control
-@patch('openhands.core.setup.State.restore_from_session') # Key mock
-@patch('openhands.cli.main.create_controller') # To check initial_state
-@patch('openhands.cli.main.display_runtime_initialization_message') # Cosmetic
-@patch('openhands.cli.main.display_initialization_animation') # Cosmetic
-@patch('openhands.cli.main.initialize_repository_for_runtime') # Cosmetic / setup
-@patch('openhands.cli.main.display_initial_user_prompt') # Cosmetic
-@patch('openhands.cli.main.finalize_config')
-async def test_run_session_with_name_attempts_state_restore(
- mock_finalize_config,
- mock_display_initial_user_prompt,
- mock_initialize_repo,
- mock_display_init_anim,
- mock_display_runtime_init,
- mock_create_controller,
- mock_restore_from_session,
- mock_handle_commands,
- mock_read_prompt_input,
- mock_cleanup_session,
- mock_run_agent_until_done,
- mock_add_mcp_tools,
- mock_create_memory,
- mock_create_runtime,
- mock_create_agent,
- mock_generate_sid,
- mock_config, # Fixture
- mock_settings_store, # Fixture
-):
- """Test run_session with a session_name attempts to restore state and passes it to AgentController."""
- loop = asyncio.get_running_loop()
- test_session_name = 'my_restore_test_session'
- expected_sid = f'sid_for_{test_session_name}'
-
- mock_generate_sid.return_value = expected_sid
-
- mock_agent = AsyncMock()
- mock_create_agent.return_value = mock_agent
-
- mock_runtime = AsyncMock()
- mock_runtime.event_stream = MagicMock() # This is the EventStream instance
- mock_runtime.event_stream.sid = expected_sid
- mock_runtime.event_stream.file_store = (
- MagicMock()
- ) # Mock the file_store attribute on the EventStream
- mock_create_runtime.return_value = mock_runtime
-
- # This is what State.restore_from_session will return
- mock_loaded_state = MagicMock(spec=State)
- mock_restore_from_session.return_value = mock_loaded_state
-
- # Create a mock controller with state attribute
- mock_controller = MagicMock()
- mock_controller.state = MagicMock()
- mock_controller.state.agent_state = None
- mock_controller.state.last_error = None
-
- # Mock create_controller to return the mock controller and loaded state
- # but still call the real restore_from_session
- def create_controller_side_effect(*args, **kwargs):
- # Call the real restore_from_session to verify it's called
- mock_restore_from_session(expected_sid, mock_runtime.event_stream.file_store)
- return (mock_controller, mock_loaded_state)
-
- mock_create_controller.side_effect = create_controller_side_effect
-
- # To make run_session exit cleanly after one loop
- mock_read_prompt_input.return_value = '/exit'
- mock_handle_commands.return_value = (
- True,
- False,
- False,
- ) # close_repl, reload_microagents, new_session_requested
-
- # Mock other functions called by run_session to avoid side effects
- mock_initialize_repo.return_value = '/mocked/repo/dir'
- mock_create_memory.return_value = AsyncMock() # Memory instance
-
- await cli.run_session(
- loop,
- mock_config,
- mock_settings_store, # This is FileSettingsStore, not directly used for restore in this path
- '/test/dir',
- task_content=None,
- session_name=test_session_name,
- )
-
- mock_generate_sid.assert_called_once_with(mock_config, test_session_name)
-
- # State.restore_from_session is called from within core.setup.create_controller,
- # which receives the runtime object (and thus its event_stream with sid and file_store).
- mock_restore_from_session.assert_called_once_with(
- expected_sid, mock_runtime.event_stream.file_store
- )
-
- # Check that create_controller was called and returned the loaded state
- mock_create_controller.assert_called_once()
- # The create_controller should have been called with the loaded state
- # (this is verified by the fact that restore_from_session was called and returned mock_loaded_state)
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.setup_config_from_args')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.check_folder_security_agreement')
-@patch('openhands.cli.main.read_task')
-@patch('openhands.cli.main.run_session')
-@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
-@patch('openhands.cli.main.NoOpCondenserConfig')
-@patch('openhands.cli.main.finalize_config')
-@patch('openhands.cli.main.aliases_exist_in_shell_config')
-async def test_main_security_check_fails(
- mock_aliases_exist,
- mock_finalize_config,
- mock_noop_condenser,
- mock_llm_condenser,
- mock_run_session,
- mock_read_task,
- mock_check_security,
- mock_get_settings_store,
- mock_setup_config,
-):
- """Test main function when security check fails."""
- loop = asyncio.get_running_loop()
-
- # Mock alias setup functions to prevent the alias setup flow
- mock_aliases_exist.return_value = True
-
- # Mock arguments
- mock_args = MagicMock()
- mock_args.agent_cls = None
- mock_args.llm_config = None
- mock_args.name = None
- mock_args.file = None
- mock_args.conversation = None
- mock_args.log_level = None
- mock_args.config_file = 'config.toml'
- mock_args.override_cli_mode = None
-
- # Mock config
- mock_config = MagicMock()
- mock_config.workspace_base = '/test/dir'
- mock_setup_config.return_value = mock_config
-
- # Mock settings store
- mock_settings_store = AsyncMock()
- mock_settings = MagicMock()
- mock_settings.enable_default_condenser = False
- mock_settings_store.load.return_value = mock_settings
- mock_get_settings_store.return_value = mock_settings_store
-
- # Mock condenser config to return a mock instead of validating
- mock_noop_condenser_instance = MagicMock()
- mock_noop_condenser.return_value = mock_noop_condenser_instance
-
- # Mock security check to fail
- mock_check_security.return_value = False
-
- # Run the function
- await cli.main_with_loop(loop, mock_args)
-
- # Assertions
- mock_setup_config.assert_called_once_with(mock_args)
- mock_get_settings_store.assert_called_once()
- mock_settings_store.load.assert_called_once()
- mock_check_security.assert_called_once_with(mock_config, '/test/dir')
-
- # Since security check fails, no further action should happen
- # (This is an implicit assertion - we don't need to check further function calls)
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.setup_config_from_args')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.check_folder_security_agreement')
-@patch('openhands.cli.main.read_task')
-@patch('openhands.cli.main.run_session')
-@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
-@patch('openhands.cli.main.NoOpCondenserConfig')
-@patch('openhands.cli.main.finalize_config')
-@patch('openhands.cli.main.aliases_exist_in_shell_config')
-async def test_config_loading_order(
- mock_aliases_exist,
- mock_finalize_config,
- mock_noop_condenser,
- mock_llm_condenser,
- mock_run_session,
- mock_read_task,
- mock_check_security,
- mock_get_settings_store,
- mock_setup_config,
-):
- """Test the order of configuration loading in the main function.
-
- This test verifies:
- 1. Command line arguments override settings store values
- 2. Settings from store are used when command line args are not provided
- 3. Default condenser is configured correctly based on settings
- """
- loop = asyncio.get_running_loop()
-
- # Mock alias setup functions to prevent the alias setup flow
- mock_aliases_exist.return_value = True
-
- # Mock arguments with specific agent but no LLM config
- mock_args = MagicMock()
- mock_args.agent_cls = 'cmd-line-agent' # This should override settings
- mock_args.llm_config = None # This should allow settings to be used
- # Add a file property to avoid file I/O errors
- mock_args.file = None
- mock_args.log_level = 'INFO'
- mock_args.name = None
- mock_args.conversation = None
- mock_args.config_file = 'config.toml'
- mock_args.override_cli_mode = None
-
- # Mock read_task to return a dummy task
- mock_read_task.return_value = 'Test task'
-
- # Mock config with mock methods to track changes
- mock_config = MagicMock()
- mock_config.workspace_base = '/test/dir'
- mock_config.cli_multiline_input = False
-
- # Create a mock LLM config that has no model or API key set
- # This simulates the case where config.toml doesn't have LLM settings
- mock_llm_config = MagicMock()
- mock_llm_config.model = None
- mock_llm_config.api_key = None
-
- mock_config.get_llm_config = MagicMock(return_value=mock_llm_config)
- mock_config.set_llm_config = MagicMock()
- mock_config.get_agent_config = MagicMock(return_value=MagicMock())
- mock_config.set_agent_config = MagicMock()
- mock_setup_config.return_value = mock_config
-
- # Mock settings store with specific values
- mock_settings_store = AsyncMock()
- mock_settings = MagicMock()
- mock_settings.agent = 'settings-agent' # Should be overridden by cmd line
- mock_settings.llm_model = 'settings-model' # Should be used (no cmd line)
- mock_settings.llm_api_key = 'settings-api-key' # Should be used
- mock_settings.llm_base_url = 'settings-base-url' # Should be used
- mock_settings.confirmation_mode = True
- mock_settings.enable_default_condenser = True # Test condenser setup
- mock_settings_store.load.return_value = mock_settings
- mock_get_settings_store.return_value = mock_settings_store
-
- # Mock condenser configs
- mock_llm_condenser_instance = MagicMock()
- mock_llm_condenser.return_value = mock_llm_condenser_instance
-
- # Mock security check and run_session to succeed
- mock_check_security.return_value = True
- mock_run_session.return_value = False # No new session requested
-
- # Run the function
- await cli.main_with_loop(loop, mock_args)
-
- # Assertions for argument parsing and config setup
- mock_setup_config.assert_called_once_with(mock_args)
- mock_get_settings_store.assert_called_once()
- mock_settings_store.load.assert_called_once()
-
- # Verify agent is set from command line args (overriding settings)
- # In the actual implementation, default_agent is set in setup_config_from_args
- # We need to set it on our mock to simulate this behavior
- mock_config.default_agent = 'cmd-line-agent'
-
- # Verify LLM config is set from settings (since no cmd line arg)
- assert mock_config.set_llm_config.called
- llm_config_call = mock_config.set_llm_config.call_args[0][0]
- assert llm_config_call.model == 'settings-model'
- assert llm_config_call.api_key == 'settings-api-key'
- assert llm_config_call.base_url == 'settings-base-url'
-
- # Verify confirmation mode is set from settings
- assert mock_config.security.confirmation_mode is True
-
- # Verify default condenser is set up correctly
- assert mock_config.set_agent_config.called
- assert mock_llm_condenser.called
- assert mock_config.enable_default_condenser is True
-
- # Verify that run_session was called with the correct arguments
- mock_run_session.assert_called_once()
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.setup_config_from_args')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.check_folder_security_agreement')
-@patch('openhands.cli.main.read_task')
-@patch('openhands.cli.main.run_session')
-@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
-@patch('openhands.cli.main.NoOpCondenserConfig')
-@patch('openhands.cli.main.finalize_config')
-@patch('openhands.cli.main.aliases_exist_in_shell_config')
-@patch('builtins.open', new_callable=MagicMock)
-async def test_main_with_file_option(
- mock_open,
- mock_aliases_exist,
- mock_finalize_config,
- mock_noop_condenser,
- mock_llm_condenser,
- mock_run_session,
- mock_read_task,
- mock_check_security,
- mock_get_settings_store,
- mock_setup_config,
-):
- """Test main function with a file option."""
- loop = asyncio.get_running_loop()
-
- # Mock alias setup functions to prevent the alias setup flow
- mock_aliases_exist.return_value = True
-
- # Mock arguments
- mock_args = MagicMock()
- mock_args.agent_cls = None
- mock_args.llm_config = None
- mock_args.name = None
- mock_args.file = '/path/to/test/file.txt'
- mock_args.task = None
- mock_args.conversation = None
- mock_args.log_level = None
- mock_args.config_file = 'config.toml'
- mock_args.override_cli_mode = None
-
- # Mock config
- mock_config = MagicMock()
- mock_config.workspace_base = '/test/dir'
- mock_config.cli_multiline_input = False
- mock_setup_config.return_value = mock_config
-
- # Mock settings store
- mock_settings_store = AsyncMock()
- mock_settings = MagicMock()
- mock_settings.agent = 'test-agent'
- mock_settings.llm_model = 'test-model'
- mock_settings.llm_api_key = 'test-api-key'
- mock_settings.llm_base_url = 'test-base-url'
- mock_settings.confirmation_mode = True
- mock_settings.enable_default_condenser = True
- mock_settings_store.load.return_value = mock_settings
- mock_get_settings_store.return_value = mock_settings_store
-
- # Mock condenser config to return a mock instead of validating
- mock_llm_condenser_instance = MagicMock()
- mock_llm_condenser.return_value = mock_llm_condenser_instance
-
- # Mock security check
- mock_check_security.return_value = True
-
- # Mock file open
- mock_file = MagicMock()
- mock_file.__enter__.return_value.read.return_value = 'This is a test file content.'
- mock_open.return_value = mock_file
-
- # Mock run_session to return False (no new session requested)
- mock_run_session.return_value = False
-
- # Run the function
- await cli.main_with_loop(loop, mock_args)
-
- # Assertions
- mock_setup_config.assert_called_once_with(mock_args)
- mock_get_settings_store.assert_called_once()
- mock_settings_store.load.assert_called_once()
- mock_check_security.assert_called_once_with(mock_config, '/test/dir')
-
- # Verify file was opened
- mock_open.assert_called_once_with('/path/to/test/file.txt', 'r', encoding='utf-8')
-
- # Check that run_session was called with expected arguments
- mock_run_session.assert_called_once()
- # Extract the task_str from the call
- task_str = mock_run_session.call_args[0][4]
- assert "The user has tagged a file '/path/to/test/file.txt'" in task_str
- assert 'Please read and understand the following file content first:' in task_str
- assert 'This is a test file content.' in task_str
- assert (
- 'After reviewing the file, please ask the user what they would like to do with it.'
- in task_str
- )
diff --git a/tests/unit/cli/test_cli_alias_setup.py b/tests/unit/cli/test_cli_alias_setup.py
deleted file mode 100644
index f2feeffe9e..0000000000
--- a/tests/unit/cli/test_cli_alias_setup.py
+++ /dev/null
@@ -1,368 +0,0 @@
-"""Unit tests for CLI alias setup functionality."""
-
-import tempfile
-from pathlib import Path
-from unittest.mock import patch
-
-from openhands.cli.main import alias_setup_declined as main_alias_setup_declined
-from openhands.cli.main import aliases_exist_in_shell_config, run_alias_setup_flow
-from openhands.cli.shell_config import (
- ShellConfigManager,
- add_aliases_to_shell_config,
- alias_setup_declined,
- get_shell_config_path,
- mark_alias_setup_declined,
-)
-from openhands.core.config import OpenHandsConfig
-
-
-def test_get_shell_config_path_no_files_fallback():
- """Test shell config path fallback when no shell detection and no config files exist."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Mock shellingham to raise an exception (detection failure)
- with patch(
- 'shellingham.detect_shell',
- side_effect=Exception('Shell detection failed'),
- ):
- profile_path = get_shell_config_path()
- assert profile_path.name == '.bash_profile'
-
-
-def test_get_shell_config_path_bash_fallback():
- """Test shell config path fallback to bash when it exists."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Create .bashrc
- bashrc = Path(temp_dir) / '.bashrc'
- bashrc.touch()
-
- # Mock shellingham to raise an exception (detection failure)
- with patch(
- 'shellingham.detect_shell',
- side_effect=Exception('Shell detection failed'),
- ):
- profile_path = get_shell_config_path()
- assert profile_path.name == '.bashrc'
-
-
-def test_get_shell_config_path_with_bash_detection():
- """Test shell config path when bash is detected."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Create .bashrc
- bashrc = Path(temp_dir) / '.bashrc'
- bashrc.touch()
-
- # Mock shellingham to return bash
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- profile_path = get_shell_config_path()
- assert profile_path.name == '.bashrc'
-
-
-def test_get_shell_config_path_with_zsh_detection():
- """Test shell config path when zsh is detected."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Create .zshrc
- zshrc = Path(temp_dir) / '.zshrc'
- zshrc.touch()
-
- # Mock shellingham to return zsh
- with patch('shellingham.detect_shell', return_value=('zsh', 'zsh')):
- profile_path = get_shell_config_path()
- assert profile_path.name == '.zshrc'
-
-
-def test_get_shell_config_path_with_fish_detection():
- """Test shell config path when fish is detected."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Create fish config directory and file
- fish_config_dir = Path(temp_dir) / '.config' / 'fish'
- fish_config_dir.mkdir(parents=True)
- fish_config = fish_config_dir / 'config.fish'
- fish_config.touch()
-
- # Mock shellingham to return fish
- with patch('shellingham.detect_shell', return_value=('fish', 'fish')):
- profile_path = get_shell_config_path()
- assert profile_path.name == 'config.fish'
- assert 'fish' in str(profile_path)
-
-
-def test_add_aliases_to_shell_config_bash():
- """Test adding aliases to bash config."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Mock shellingham to return bash
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- # Add aliases
- success = add_aliases_to_shell_config()
- assert success is True
-
- # Get the actual path that was used
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- profile_path = get_shell_config_path()
-
- # Check that the aliases were added
- with open(profile_path, 'r') as f:
- content = f.read()
- assert 'alias openhands=' in content
- assert 'alias oh=' in content
- assert 'uvx --python 3.12 --from openhands-ai openhands' in content
-
-
-def test_add_aliases_to_shell_config_zsh():
- """Test adding aliases to zsh config."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Mock shellingham to return zsh
- with patch('shellingham.detect_shell', return_value=('zsh', 'zsh')):
- # Add aliases
- success = add_aliases_to_shell_config()
- assert success is True
-
- # Check that the aliases were added to .zshrc
- profile_path = Path(temp_dir) / '.zshrc'
- with open(profile_path, 'r') as f:
- content = f.read()
- assert 'alias openhands=' in content
- assert 'alias oh=' in content
- assert 'uvx --python 3.12 --from openhands-ai openhands' in content
-
-
-def test_add_aliases_handles_existing_aliases():
- """Test that adding aliases handles existing aliases correctly."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Mock shellingham to return bash
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- # Add aliases first time
- success = add_aliases_to_shell_config()
- assert success is True
-
- # Try adding again - should detect existing aliases
- success = add_aliases_to_shell_config()
- assert success is True
-
- # Get the actual path that was used
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- profile_path = get_shell_config_path()
-
- # Check that aliases weren't duplicated
- with open(profile_path, 'r') as f:
- content = f.read()
- # Count occurrences of the alias
- openhands_count = content.count('alias openhands=')
- oh_count = content.count('alias oh=')
- assert openhands_count == 1
- assert oh_count == 1
-
-
-def test_aliases_exist_in_shell_config_no_file():
- """Test alias detection when no shell config exists."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Mock shellingham to return bash
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- assert aliases_exist_in_shell_config() is False
-
-
-def test_aliases_exist_in_shell_config_no_aliases():
- """Test alias detection when shell config exists but has no aliases."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Mock shellingham to return bash
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- # Create bash profile with other content
- profile_path = get_shell_config_path()
- with open(profile_path, 'w') as f:
- f.write('export PATH=$PATH:/usr/local/bin\n')
-
- assert aliases_exist_in_shell_config() is False
-
-
-def test_aliases_exist_in_shell_config_with_aliases():
- """Test alias detection when aliases exist."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Mock shellingham to return bash
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- # Add aliases first
- add_aliases_to_shell_config()
-
- assert aliases_exist_in_shell_config() is True
-
-
-def test_shell_config_manager_basic_functionality():
- """Test basic ShellConfigManager functionality."""
- manager = ShellConfigManager()
-
- # Test command customization
- custom_manager = ShellConfigManager(command='custom-command')
- assert custom_manager.command == 'custom-command'
-
- # Test shell type detection from path
- assert manager.get_shell_type_from_path(Path('/home/user/.bashrc')) == 'bash'
- assert manager.get_shell_type_from_path(Path('/home/user/.zshrc')) == 'zsh'
- assert (
- manager.get_shell_type_from_path(Path('/home/user/.config/fish/config.fish'))
- == 'fish'
- )
-
-
-def test_shell_config_manager_reload_commands():
- """Test reload command generation."""
- manager = ShellConfigManager()
-
- # Test different shell reload commands
- assert 'source ~/.zshrc' in manager.get_reload_command(Path('/home/user/.zshrc'))
- assert 'source ~/.bashrc' in manager.get_reload_command(Path('/home/user/.bashrc'))
- assert 'source ~/.bash_profile' in manager.get_reload_command(
- Path('/home/user/.bash_profile')
- )
- assert 'source ~/.config/fish/config.fish' in manager.get_reload_command(
- Path('/home/user/.config/fish/config.fish')
- )
-
-
-def test_shell_config_manager_template_rendering():
- """Test that templates are properly rendered."""
- manager = ShellConfigManager(command='test-command')
-
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Create a bash config file
- bashrc = Path(temp_dir) / '.bashrc'
- bashrc.touch()
-
- # Mock shell detection
- with patch.object(manager, 'detect_shell', return_value='bash'):
- success = manager.add_aliases()
- assert success is True
-
- # Check that the custom command was used
- with open(bashrc, 'r') as f:
- content = f.read()
- assert 'test-command' in content
- assert 'alias openhands="test-command"' in content
- assert 'alias oh="test-command"' in content
-
-
-def test_alias_setup_declined_false():
- """Test alias setup declined check when marker file doesn't exist."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- assert alias_setup_declined() is False
-
-
-def test_alias_setup_declined_true():
- """Test alias setup declined check when marker file exists."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Create the marker file
- mark_alias_setup_declined()
- assert alias_setup_declined() is True
-
-
-def test_mark_alias_setup_declined():
- """Test marking alias setup as declined creates the marker file."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Initially should be False
- assert alias_setup_declined() is False
-
- # Mark as declined
- mark_alias_setup_declined()
-
- # Should now be True
- assert alias_setup_declined() is True
-
- # Verify the file exists
- marker_file = Path(temp_dir) / '.openhands' / '.cli_alias_setup_declined'
- assert marker_file.exists()
-
-
-def test_alias_setup_declined_persisted():
- """Test that when user declines alias setup, their choice is persisted."""
- config = OpenHandsConfig()
-
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- with patch(
- 'openhands.cli.shell_config.aliases_exist_in_shell_config',
- return_value=False,
- ):
- with patch(
- 'openhands.cli.main.cli_confirm', return_value=1
- ): # User chooses "No"
- with patch('prompt_toolkit.print_formatted_text'):
- # Initially, user hasn't declined
- assert not alias_setup_declined()
-
- # Run the alias setup flow
- run_alias_setup_flow(config)
-
- # After declining, the marker should be set
- assert alias_setup_declined()
-
-
-def test_alias_setup_skipped_when_previously_declined():
- """Test that alias setup is skipped when user has previously declined."""
- OpenHandsConfig()
-
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- # Mark that user has previously declined
- mark_alias_setup_declined()
- assert alias_setup_declined()
-
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- with patch(
- 'openhands.cli.shell_config.aliases_exist_in_shell_config',
- return_value=False,
- ):
- with patch('openhands.cli.main.cli_confirm'):
- with patch('prompt_toolkit.print_formatted_text'):
- # This should not show the setup flow since user previously declined
- # We test this by checking the main logic conditions
-
- should_show = (
- not aliases_exist_in_shell_config()
- and not main_alias_setup_declined()
- )
-
- assert not should_show, (
- 'Alias setup should be skipped when user previously declined'
- )
-
-
-def test_alias_setup_accepted_does_not_set_declined_flag():
- """Test that when user accepts alias setup, no declined marker is created."""
- config = OpenHandsConfig()
-
- with tempfile.TemporaryDirectory() as temp_dir:
- with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
- with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
- with patch(
- 'openhands.cli.shell_config.aliases_exist_in_shell_config',
- return_value=False,
- ):
- with patch(
- 'openhands.cli.main.cli_confirm', return_value=0
- ): # User chooses "Yes"
- with patch(
- 'openhands.cli.shell_config.add_aliases_to_shell_config',
- return_value=True,
- ):
- with patch('prompt_toolkit.print_formatted_text'):
- # Initially, user hasn't declined
- assert not alias_setup_declined()
-
- # Run the alias setup flow
- run_alias_setup_flow(config)
-
- # After accepting, the declined marker should still be False
- assert not alias_setup_declined()
diff --git a/tests/unit/cli/test_cli_commands.py b/tests/unit/cli/test_cli_commands.py
deleted file mode 100644
index aca58b0516..0000000000
--- a/tests/unit/cli/test_cli_commands.py
+++ /dev/null
@@ -1,637 +0,0 @@
-from unittest.mock import MagicMock, patch
-
-import pytest
-from prompt_toolkit.formatted_text import HTML
-
-from openhands.cli.commands import (
- display_mcp_servers,
- handle_commands,
- handle_exit_command,
- handle_help_command,
- handle_init_command,
- handle_mcp_command,
- handle_new_command,
- handle_resume_command,
- handle_settings_command,
- handle_status_command,
-)
-from openhands.cli.tui import UsageMetrics
-from openhands.core.config import OpenHandsConfig
-from openhands.core.schema import AgentState
-from openhands.events import EventSource
-from openhands.events.action import ChangeAgentStateAction, MessageAction
-from openhands.events.stream import EventStream
-from openhands.storage.settings.file_settings_store import FileSettingsStore
-
-
-class TestHandleCommands:
- @pytest.fixture
- def mock_dependencies(self):
- event_stream = MagicMock(spec=EventStream)
- usage_metrics = MagicMock(spec=UsageMetrics)
- sid = 'test-session-id'
- config = MagicMock(spec=OpenHandsConfig)
- current_dir = '/test/dir'
- settings_store = MagicMock(spec=FileSettingsStore)
- agent_state = AgentState.RUNNING
-
- return {
- 'event_stream': event_stream,
- 'usage_metrics': usage_metrics,
- 'sid': sid,
- 'config': config,
- 'current_dir': current_dir,
- 'settings_store': settings_store,
- 'agent_state': agent_state,
- }
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.handle_exit_command')
- async def test_handle_exit_command(self, mock_handle_exit, mock_dependencies):
- mock_handle_exit.return_value = True
-
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- '/exit', **mock_dependencies
- )
-
- mock_handle_exit.assert_called_once_with(
- mock_dependencies['config'],
- mock_dependencies['event_stream'],
- mock_dependencies['usage_metrics'],
- mock_dependencies['sid'],
- )
- assert close_repl is True
- assert reload_microagents is False
- assert new_session is False
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.handle_help_command')
- async def test_handle_help_command(self, mock_handle_help, mock_dependencies):
- mock_handle_help.return_value = (False, False, False)
-
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- '/help', **mock_dependencies
- )
-
- mock_handle_help.assert_called_once()
- assert close_repl is False
- assert reload_microagents is False
- assert new_session is False
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.handle_init_command')
- async def test_handle_init_command(self, mock_handle_init, mock_dependencies):
- mock_handle_init.return_value = (True, True)
-
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- '/init', **mock_dependencies
- )
-
- mock_handle_init.assert_called_once_with(
- mock_dependencies['config'],
- mock_dependencies['event_stream'],
- mock_dependencies['current_dir'],
- )
- assert close_repl is True
- assert reload_microagents is True
- assert new_session is False
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.handle_status_command')
- async def test_handle_status_command(self, mock_handle_status, mock_dependencies):
- mock_handle_status.return_value = (False, False, False)
-
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- '/status', **mock_dependencies
- )
-
- mock_handle_status.assert_called_once_with(
- mock_dependencies['usage_metrics'], mock_dependencies['sid']
- )
- assert close_repl is False
- assert reload_microagents is False
- assert new_session is False
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.handle_new_command')
- async def test_handle_new_command(self, mock_handle_new, mock_dependencies):
- mock_handle_new.return_value = (True, True)
-
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- '/new', **mock_dependencies
- )
-
- mock_handle_new.assert_called_once_with(
- mock_dependencies['config'],
- mock_dependencies['event_stream'],
- mock_dependencies['usage_metrics'],
- mock_dependencies['sid'],
- )
- assert close_repl is True
- assert reload_microagents is False
- assert new_session is True
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.handle_settings_command')
- async def test_handle_settings_command(
- self, mock_handle_settings, mock_dependencies
- ):
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- '/settings', **mock_dependencies
- )
-
- mock_handle_settings.assert_called_once_with(
- mock_dependencies['config'],
- mock_dependencies['settings_store'],
- )
- assert close_repl is False
- assert reload_microagents is False
- assert new_session is False
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.handle_mcp_command')
- async def test_handle_mcp_command(self, mock_handle_mcp, mock_dependencies):
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- '/mcp', **mock_dependencies
- )
-
- mock_handle_mcp.assert_called_once_with(mock_dependencies['config'])
- assert close_repl is False
- assert reload_microagents is False
- assert new_session is False
-
- @pytest.mark.asyncio
- async def test_handle_unknown_command(self, mock_dependencies):
- user_message = 'Hello, this is not a command'
-
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- user_message, **mock_dependencies
- )
-
- # The command should be treated as a message and added to the event stream
- mock_dependencies['event_stream'].add_event.assert_called_once()
- # Check the first argument is a MessageAction with the right content
- args, kwargs = mock_dependencies['event_stream'].add_event.call_args
- assert isinstance(args[0], MessageAction)
- assert args[0].content == user_message
- assert args[1] == EventSource.USER
-
- assert close_repl is True
- assert reload_microagents is False
- assert new_session is False
-
-
-class TestHandleExitCommand:
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.display_shutdown_message')
- def test_exit_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
- config = MagicMock(spec=OpenHandsConfig)
- event_stream = MagicMock(spec=EventStream)
- usage_metrics = MagicMock(spec=UsageMetrics)
- sid = 'test-session-id'
-
- # Mock user confirming exit
- mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
-
- # Call the function under test
- result = handle_exit_command(config, event_stream, usage_metrics, sid)
-
- # Verify correct behavior
- mock_cli_confirm.assert_called_once()
- event_stream.add_event.assert_called_once()
- # Check event is the right type
- args, kwargs = event_stream.add_event.call_args
- assert isinstance(args[0], ChangeAgentStateAction)
- assert args[0].agent_state == AgentState.STOPPED
- assert args[1] == EventSource.ENVIRONMENT
-
- mock_display_shutdown.assert_called_once_with(usage_metrics, sid)
- assert result is True
-
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.display_shutdown_message')
- def test_exit_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
- config = MagicMock(spec=OpenHandsConfig)
- event_stream = MagicMock(spec=EventStream)
- usage_metrics = MagicMock(spec=UsageMetrics)
- sid = 'test-session-id'
-
- # Mock user rejecting exit
- mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
-
- # Call the function under test
- result = handle_exit_command(config, event_stream, usage_metrics, sid)
-
- # Verify correct behavior
- mock_cli_confirm.assert_called_once()
- event_stream.add_event.assert_not_called()
- mock_display_shutdown.assert_not_called()
- assert result is False
-
-
-class TestHandleHelpCommand:
- @patch('openhands.cli.commands.display_help')
- def test_help_command(self, mock_display_help):
- handle_help_command()
- mock_display_help.assert_called_once()
-
-
-class TestDisplayMcpServers:
- @patch('openhands.cli.commands.print_formatted_text')
- def test_display_mcp_servers_no_servers(self, mock_print):
- from openhands.core.config.mcp_config import MCPConfig
-
- config = MagicMock(spec=OpenHandsConfig)
- config.mcp = MCPConfig() # Empty config with no servers
-
- display_mcp_servers(config)
-
- mock_print.assert_called_once()
- call_args = mock_print.call_args[0][0]
- assert 'No custom MCP servers configured' in call_args
- assert (
- 'https://docs.all-hands.dev/usage/how-to/cli-mode#using-mcp-servers'
- in call_args
- )
-
- @patch('openhands.cli.commands.print_formatted_text')
- def test_display_mcp_servers_with_servers(self, mock_print):
- from openhands.core.config.mcp_config import (
- MCPConfig,
- MCPSHTTPServerConfig,
- MCPSSEServerConfig,
- MCPStdioServerConfig,
- )
-
- config = MagicMock(spec=OpenHandsConfig)
- config.mcp = MCPConfig(
- sse_servers=[MCPSSEServerConfig(url='https://example.com/sse')],
- stdio_servers=[MCPStdioServerConfig(name='tavily', command='npx')],
- shttp_servers=[MCPSHTTPServerConfig(url='http://localhost:3000/mcp')],
- )
-
- display_mcp_servers(config)
-
- # Should be called multiple times for different sections
- assert mock_print.call_count >= 4
-
- # Check that the summary is printed
- first_call = mock_print.call_args_list[0][0][0]
- assert 'Configured MCP servers:' in first_call
- assert 'SSE servers: 1' in first_call
- assert 'Stdio servers: 1' in first_call
- assert 'SHTTP servers: 1' in first_call
- assert 'Total: 3' in first_call
-
-
-class TestHandleMcpCommand:
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.display_mcp_servers')
- async def test_handle_mcp_command_list_action(self, mock_display, mock_cli_confirm):
- config = MagicMock(spec=OpenHandsConfig)
- mock_cli_confirm.return_value = 0 # List action
-
- await handle_mcp_command(config)
-
- mock_cli_confirm.assert_called_once_with(
- config,
- 'MCP Server Configuration',
- [
- 'List configured servers',
- 'Add new server',
- 'Remove server',
- 'View errors',
- 'Go back',
- ],
- )
- mock_display.assert_called_once_with(config)
-
-
-class TestHandleStatusCommand:
- @patch('openhands.cli.commands.display_status')
- def test_status_command(self, mock_display_status):
- usage_metrics = MagicMock(spec=UsageMetrics)
- sid = 'test-session-id'
-
- handle_status_command(usage_metrics, sid)
-
- mock_display_status.assert_called_once_with(usage_metrics, sid)
-
-
-class TestHandleNewCommand:
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.display_shutdown_message')
- def test_new_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
- config = MagicMock(spec=OpenHandsConfig)
- event_stream = MagicMock(spec=EventStream)
- usage_metrics = MagicMock(spec=UsageMetrics)
- sid = 'test-session-id'
-
- # Mock user confirming new session
- mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
-
- # Call the function under test
- close_repl, new_session = handle_new_command(
- config, event_stream, usage_metrics, sid
- )
-
- # Verify correct behavior
- mock_cli_confirm.assert_called_once()
- event_stream.add_event.assert_called_once()
- # Check event is the right type
- args, kwargs = event_stream.add_event.call_args
- assert isinstance(args[0], ChangeAgentStateAction)
- assert args[0].agent_state == AgentState.STOPPED
- assert args[1] == EventSource.ENVIRONMENT
-
- mock_display_shutdown.assert_called_once_with(usage_metrics, sid)
- assert close_repl is True
- assert new_session is True
-
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.display_shutdown_message')
- def test_new_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
- config = MagicMock(spec=OpenHandsConfig)
- event_stream = MagicMock(spec=EventStream)
- usage_metrics = MagicMock(spec=UsageMetrics)
- sid = 'test-session-id'
-
- # Mock user rejecting new session
- mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
-
- # Call the function under test
- close_repl, new_session = handle_new_command(
- config, event_stream, usage_metrics, sid
- )
-
- # Verify correct behavior
- mock_cli_confirm.assert_called_once()
- event_stream.add_event.assert_not_called()
- mock_display_shutdown.assert_not_called()
- assert close_repl is False
- assert new_session is False
-
-
-class TestHandleInitCommand:
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.init_repository')
- async def test_init_local_runtime_successful(self, mock_init_repository):
- config = MagicMock(spec=OpenHandsConfig)
- config.runtime = 'local'
- event_stream = MagicMock(spec=EventStream)
- current_dir = '/test/dir'
-
- # Mock successful repository initialization
- mock_init_repository.return_value = True
-
- # Call the function under test
- close_repl, reload_microagents = await handle_init_command(
- config, event_stream, current_dir
- )
-
- # Verify correct behavior
- mock_init_repository.assert_called_once_with(config, current_dir)
- event_stream.add_event.assert_called_once()
- # Check event is the right type
- args, kwargs = event_stream.add_event.call_args
- assert isinstance(args[0], MessageAction)
- assert 'Please explore this repository' in args[0].content
- assert args[1] == EventSource.USER
-
- assert close_repl is True
- assert reload_microagents is True
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.init_repository')
- async def test_init_local_runtime_unsuccessful(self, mock_init_repository):
- config = MagicMock(spec=OpenHandsConfig)
- config.runtime = 'local'
- event_stream = MagicMock(spec=EventStream)
- current_dir = '/test/dir'
-
- # Mock unsuccessful repository initialization
- mock_init_repository.return_value = False
-
- # Call the function under test
- close_repl, reload_microagents = await handle_init_command(
- config, event_stream, current_dir
- )
-
- # Verify correct behavior
- mock_init_repository.assert_called_once_with(config, current_dir)
- event_stream.add_event.assert_not_called()
-
- assert close_repl is False
- assert reload_microagents is False
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.print_formatted_text')
- @patch('openhands.cli.commands.init_repository')
- async def test_init_non_local_runtime(self, mock_init_repository, mock_print):
- config = MagicMock(spec=OpenHandsConfig)
- config.runtime = 'remote' # Not local
- event_stream = MagicMock(spec=EventStream)
- current_dir = '/test/dir'
-
- # Call the function under test
- close_repl, reload_microagents = await handle_init_command(
- config, event_stream, current_dir
- )
-
- # Verify correct behavior
- mock_init_repository.assert_not_called()
- mock_print.assert_called_once()
- event_stream.add_event.assert_not_called()
-
- assert close_repl is False
- assert reload_microagents is False
-
-
-class TestHandleSettingsCommand:
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.display_settings')
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.modify_llm_settings_basic')
- async def test_settings_basic_with_changes(
- self,
- mock_modify_basic,
- mock_cli_confirm,
- mock_display_settings,
- ):
- config = MagicMock(spec=OpenHandsConfig)
- settings_store = MagicMock(spec=FileSettingsStore)
-
- # Mock user selecting "Basic" settings
- mock_cli_confirm.return_value = 0
-
- # Call the function under test
- await handle_settings_command(config, settings_store)
-
- # Verify correct behavior
- mock_display_settings.assert_called_once_with(config)
- mock_cli_confirm.assert_called_once()
- mock_modify_basic.assert_called_once_with(config, settings_store)
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.display_settings')
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.modify_llm_settings_basic')
- async def test_settings_basic_without_changes(
- self,
- mock_modify_basic,
- mock_cli_confirm,
- mock_display_settings,
- ):
- config = MagicMock(spec=OpenHandsConfig)
- settings_store = MagicMock(spec=FileSettingsStore)
-
- # Mock user selecting "Basic" settings
- mock_cli_confirm.return_value = 0
-
- # Call the function under test
- await handle_settings_command(config, settings_store)
-
- # Verify correct behavior
- mock_display_settings.assert_called_once_with(config)
- mock_cli_confirm.assert_called_once()
- mock_modify_basic.assert_called_once_with(config, settings_store)
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.display_settings')
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.modify_llm_settings_advanced')
- async def test_settings_advanced_with_changes(
- self,
- mock_modify_advanced,
- mock_cli_confirm,
- mock_display_settings,
- ):
- config = MagicMock(spec=OpenHandsConfig)
- settings_store = MagicMock(spec=FileSettingsStore)
-
- # Mock user selecting "Advanced" settings
- mock_cli_confirm.return_value = 1
-
- # Call the function under test
- await handle_settings_command(config, settings_store)
-
- # Verify correct behavior
- mock_display_settings.assert_called_once_with(config)
- mock_cli_confirm.assert_called_once()
- mock_modify_advanced.assert_called_once_with(config, settings_store)
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.display_settings')
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.modify_llm_settings_advanced')
- async def test_settings_advanced_without_changes(
- self,
- mock_modify_advanced,
- mock_cli_confirm,
- mock_display_settings,
- ):
- config = MagicMock(spec=OpenHandsConfig)
- settings_store = MagicMock(spec=FileSettingsStore)
-
- # Mock user selecting "Advanced" settings
- mock_cli_confirm.return_value = 1
-
- # Call the function under test
- await handle_settings_command(config, settings_store)
-
- # Verify correct behavior
- mock_display_settings.assert_called_once_with(config)
- mock_cli_confirm.assert_called_once()
- mock_modify_advanced.assert_called_once_with(config, settings_store)
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.display_settings')
- @patch('openhands.cli.commands.cli_confirm')
- async def test_settings_go_back(self, mock_cli_confirm, mock_display_settings):
- config = MagicMock(spec=OpenHandsConfig)
- settings_store = MagicMock(spec=FileSettingsStore)
-
- # Mock user selecting "Go back" (now option 4, index 3)
- mock_cli_confirm.return_value = 3
-
- # Call the function under test
- await handle_settings_command(config, settings_store)
-
- # Verify correct behavior
- mock_display_settings.assert_called_once_with(config)
- mock_cli_confirm.assert_called_once()
-
-
-class TestHandleResumeCommand:
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.print_formatted_text')
- async def test_handle_resume_command_paused_state(self, mock_print):
- """Test that handle_resume_command works when agent is in PAUSED state."""
- # Create a mock event stream
- event_stream = MagicMock(spec=EventStream)
-
- # Call the function with PAUSED state
- close_repl, new_session_requested = await handle_resume_command(
- '/resume', event_stream, AgentState.PAUSED
- )
-
- # Check that the event stream add_event was called with the correct message action
- event_stream.add_event.assert_called_once()
- args, kwargs = event_stream.add_event.call_args
- message_action, source = args
-
- assert isinstance(message_action, MessageAction)
- assert message_action.content == 'continue'
- assert source == EventSource.USER
-
- # Check the return values
- assert close_repl is True
- assert new_session_requested is False
-
- # Verify no error message was printed
- mock_print.assert_not_called()
-
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- 'invalid_state', [AgentState.RUNNING, AgentState.FINISHED, AgentState.ERROR]
- )
- @patch('openhands.cli.commands.print_formatted_text')
- async def test_handle_resume_command_invalid_states(
- self, mock_print, invalid_state
- ):
- """Test that handle_resume_command shows error for all non-PAUSED states."""
- event_stream = MagicMock(spec=EventStream)
-
- close_repl, new_session_requested = await handle_resume_command(
- '/resume', event_stream, invalid_state
- )
-
- # Check that no event was added to the stream
- event_stream.add_event.assert_not_called()
-
- # Verify print was called with the error message
- assert mock_print.call_count == 1
- error_call = mock_print.call_args_list[0][0][0]
- assert isinstance(error_call, HTML)
- assert 'Error: Agent is not paused' in str(error_call)
- assert '/resume command is only available when agent is paused' in str(
- error_call
- )
-
- # Check the return values
- assert close_repl is False
- assert new_session_requested is False
-
-
-class TestMCPErrorHandling:
- """Test MCP error handling in commands."""
-
- @patch('openhands.cli.commands.display_mcp_errors')
- def test_handle_mcp_errors_command(self, mock_display_errors):
- """Test handling MCP errors command."""
- from openhands.cli.commands import handle_mcp_errors_command
-
- handle_mcp_errors_command()
-
- mock_display_errors.assert_called_once()
diff --git a/tests/unit/cli/test_cli_config_management.py b/tests/unit/cli/test_cli_config_management.py
deleted file mode 100644
index c43f760659..0000000000
--- a/tests/unit/cli/test_cli_config_management.py
+++ /dev/null
@@ -1,106 +0,0 @@
-"""Tests for CLI server management functionality."""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from openhands.cli.commands import (
- display_mcp_servers,
- remove_mcp_server,
-)
-from openhands.core.config import OpenHandsConfig
-from openhands.core.config.mcp_config import (
- MCPConfig,
- MCPSSEServerConfig,
- MCPStdioServerConfig,
-)
-
-
-class TestMCPServerManagement:
- """Test MCP server management functions."""
-
- def setup_method(self):
- """Set up test fixtures."""
- self.config = MagicMock(spec=OpenHandsConfig)
- self.config.cli = MagicMock()
- self.config.cli.vi_mode = False
-
- @patch('openhands.cli.commands.print_formatted_text')
- def test_display_mcp_servers_no_servers(self, mock_print):
- """Test displaying MCP servers when none are configured."""
- self.config.mcp = MCPConfig() # Empty config
-
- display_mcp_servers(self.config)
-
- mock_print.assert_called_once()
- call_args = mock_print.call_args[0][0]
- assert 'No custom MCP servers configured' in call_args
-
- @patch('openhands.cli.commands.print_formatted_text')
- def test_display_mcp_servers_with_servers(self, mock_print):
- """Test displaying MCP servers when some are configured."""
- self.config.mcp = MCPConfig(
- sse_servers=[MCPSSEServerConfig(url='http://test.com')],
- stdio_servers=[MCPStdioServerConfig(name='test-stdio', command='python')],
- )
-
- display_mcp_servers(self.config)
-
- # Should be called multiple times for different sections
- assert mock_print.call_count >= 2
-
- # Check that the summary is printed
- first_call = mock_print.call_args_list[0][0][0]
- assert 'Configured MCP servers:' in first_call
- assert 'SSE servers: 1' in first_call
- assert 'Stdio servers: 1' in first_call
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.print_formatted_text')
- async def test_remove_mcp_server_no_servers(self, mock_print, mock_cli_confirm):
- """Test removing MCP server when none are configured."""
- self.config.mcp = MCPConfig() # Empty config
-
- await remove_mcp_server(self.config)
-
- mock_print.assert_called_once_with('No MCP servers configured to remove.')
- mock_cli_confirm.assert_not_called()
-
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.cli_confirm')
- @patch('openhands.cli.commands.load_config_file')
- @patch('openhands.cli.commands.save_config_file')
- @patch('openhands.cli.commands.print_formatted_text')
- async def test_remove_mcp_server_success(
- self, mock_print, mock_save, mock_load, mock_cli_confirm
- ):
- """Test successfully removing an MCP server."""
- # Set up config with servers
- self.config.mcp = MCPConfig(
- sse_servers=[MCPSSEServerConfig(url='http://test.com')],
- stdio_servers=[MCPStdioServerConfig(name='test-stdio', command='python')],
- )
-
- # Mock user selections
- mock_cli_confirm.side_effect = [0, 0] # Select first server, confirm removal
-
- # Mock config file operations
- mock_load.return_value = {
- 'mcp': {
- 'sse_servers': [{'url': 'http://test.com'}],
- 'stdio_servers': [{'name': 'test-stdio', 'command': 'python'}],
- }
- }
-
- await remove_mcp_server(self.config)
-
- # Should have been called twice (select server, confirm removal)
- assert mock_cli_confirm.call_count == 2
- mock_save.assert_called_once()
-
- # Check that success message was printed
- success_calls = [
- call for call in mock_print.call_args_list if 'removed' in str(call[0][0])
- ]
- assert len(success_calls) >= 1
diff --git a/tests/unit/cli/test_cli_default_model.py b/tests/unit/cli/test_cli_default_model.py
deleted file mode 100644
index b0eaff13fc..0000000000
--- a/tests/unit/cli/test_cli_default_model.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-from openhands.cli.settings import modify_llm_settings_basic
-from openhands.cli.utils import VERIFIED_ANTHROPIC_MODELS
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.settings.get_supported_llm_models')
-@patch('openhands.cli.settings.organize_models_and_providers')
-@patch('openhands.cli.settings.PromptSession')
-@patch('openhands.cli.settings.cli_confirm')
-@patch('openhands.cli.settings.print_formatted_text')
-async def test_anthropic_default_model_is_best_verified(
- mock_print,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
-):
- """Test that the default model for anthropic is the best verified model."""
- # Setup mocks
- mock_get_models.return_value = [
- 'anthropic/claude-sonnet-4-20250514',
- 'anthropic/claude-2',
- ]
- mock_organize.return_value = {
- 'anthropic': {
- 'models': ['claude-sonnet-4-20250514', 'claude-2'],
- 'separator': '/',
- },
- }
-
- # Mock session to avoid actual user input
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(side_effect=KeyboardInterrupt())
- mock_session.return_value = session_instance
-
- # Mock config and settings store
- app_config = MagicMock()
- llm_config = MagicMock()
- llm_config.model = 'anthropic/claude-sonnet-4-20250514'
- app_config.get_llm_config.return_value = llm_config
- settings_store = AsyncMock()
-
- # Mock cli_confirm to avoid actual user input
- # We need enough values to handle all the calls in the function
- mock_confirm.side_effect = [
- 0,
- 0,
- 0,
- ] # Use default provider, use default model, etc.
-
- try:
- # Call the function (it will exit early due to KeyboardInterrupt)
- await modify_llm_settings_basic(app_config, settings_store)
- except KeyboardInterrupt:
- pass # Expected exception
-
- # Check that the default model displayed is the best verified model
- best_verified_model = VERIFIED_ANTHROPIC_MODELS[
- 0
- ] # First model in the list is the best
- default_model_displayed = False
-
- for call in mock_print.call_args_list:
- args, _ = call
- if (
- args
- and hasattr(args[0], 'value')
- and f'Default model: {best_verified_model}'
- in args[0].value
- ):
- default_model_displayed = True
- break
-
- assert default_model_displayed, (
- f'Default model displayed was not {best_verified_model}'
- )
diff --git a/tests/unit/cli/test_cli_loop_recovery.py b/tests/unit/cli/test_cli_loop_recovery.py
deleted file mode 100644
index 32b2b3b6c2..0000000000
--- a/tests/unit/cli/test_cli_loop_recovery.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""Tests for CLI loop recovery functionality."""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from openhands.cli.commands import handle_resume_command
-from openhands.controller.agent_controller import AgentController
-from openhands.controller.stuck import StuckDetector
-from openhands.core.schema import AgentState
-from openhands.events import EventSource
-from openhands.events.action import LoopRecoveryAction, MessageAction
-from openhands.events.stream import EventStream
-
-
-class TestCliLoopRecoveryIntegration:
- """Integration tests for CLI loop recovery functionality."""
-
- @pytest.mark.asyncio
- async def test_loop_recovery_resume_option_1(self):
- """Test that resume option 1 triggers loop recovery with memory truncation."""
- # Create a mock agent controller with stuck analysis
- mock_controller = MagicMock(spec=AgentController)
- mock_controller._stuck_detector = MagicMock(spec=StuckDetector)
- mock_controller._stuck_detector.stuck_analysis = MagicMock()
- mock_controller._stuck_detector.stuck_analysis.loop_start_idx = 5
-
- # Mock the loop recovery methods
- mock_controller._perform_loop_recovery = MagicMock()
- mock_controller._restart_with_last_user_message = MagicMock()
- mock_controller.set_agent_state_to = MagicMock()
- mock_controller._loop_recovery_info = None
-
- # Create a mock event stream
- event_stream = MagicMock(spec=EventStream)
-
- # Call handle_resume_command with option 1
- close_repl, new_session_requested = await handle_resume_command(
- '/resume 1', event_stream, AgentState.PAUSED
- )
-
- # Verify that LoopRecoveryAction was added to the event stream
- event_stream.add_event.assert_called_once()
- args, kwargs = event_stream.add_event.call_args
- loop_recovery_action, source = args
-
- assert isinstance(loop_recovery_action, LoopRecoveryAction)
- assert loop_recovery_action.option == 1
- assert source == EventSource.USER
-
- # Check the return values
- assert close_repl is True
- assert new_session_requested is False
-
- @pytest.mark.asyncio
- async def test_loop_recovery_resume_option_2(self):
- """Test that resume option 2 triggers restart with last user message."""
- # Create a mock event stream
- event_stream = MagicMock(spec=EventStream)
-
- # Call handle_resume_command with option 2
- close_repl, new_session_requested = await handle_resume_command(
- '/resume 2', event_stream, AgentState.PAUSED
- )
-
- # Verify that LoopRecoveryAction was added to the event stream
- event_stream.add_event.assert_called_once()
- args, kwargs = event_stream.add_event.call_args
- loop_recovery_action, source = args
-
- assert isinstance(loop_recovery_action, LoopRecoveryAction)
- assert loop_recovery_action.option == 2
- assert source == EventSource.USER
-
- # Check the return values
- assert close_repl is True
- assert new_session_requested is False
-
- @pytest.mark.asyncio
- async def test_regular_resume_without_loop_recovery(self):
- """Test that regular resume without option sends continue message."""
- # Create a mock event stream
- event_stream = MagicMock(spec=EventStream)
-
- # Call handle_resume_command without loop recovery option
- close_repl, new_session_requested = await handle_resume_command(
- '/resume', event_stream, AgentState.PAUSED
- )
-
- # Verify that MessageAction was added to the event stream
- event_stream.add_event.assert_called_once()
- args, kwargs = event_stream.add_event.call_args
- message_action, source = args
-
- assert isinstance(message_action, MessageAction)
- assert message_action.content == 'continue'
- assert source == EventSource.USER
-
- # Check the return values
- assert close_repl is True
- assert new_session_requested is False
-
- @pytest.mark.asyncio
- async def test_handle_commands_with_loop_recovery_resume(self):
- """Test that handle_commands properly routes loop recovery resume commands."""
- from openhands.cli.commands import handle_commands
-
- # Create mock dependencies
- event_stream = MagicMock(spec=EventStream)
- usage_metrics = MagicMock()
- sid = 'test-session-id'
- config = MagicMock()
- current_dir = '/test/dir'
- settings_store = MagicMock()
- agent_state = AgentState.PAUSED
-
- # Mock handle_resume_command
- with patch(
- 'openhands.cli.commands.handle_resume_command'
- ) as mock_handle_resume:
- mock_handle_resume.return_value = (False, False)
-
- # Call handle_commands with loop recovery resume
- close_repl, reload_microagents, new_session, _ = await handle_commands(
- '/resume 1',
- event_stream,
- usage_metrics,
- sid,
- config,
- current_dir,
- settings_store,
- agent_state,
- )
-
- # Check that handle_resume_command was called with correct args
- mock_handle_resume.assert_called_once_with(
- '/resume 1', event_stream, agent_state
- )
-
- # Check the return values
- assert close_repl is False
- assert reload_microagents is False
- assert new_session is False
diff --git a/tests/unit/cli/test_cli_openhands_provider_auth_error.py b/tests/unit/cli/test_cli_openhands_provider_auth_error.py
deleted file mode 100644
index 60579ca693..0000000000
--- a/tests/unit/cli/test_cli_openhands_provider_auth_error.py
+++ /dev/null
@@ -1,205 +0,0 @@
-import asyncio
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-import pytest_asyncio
-from litellm.exceptions import AuthenticationError
-from pydantic import SecretStr
-
-from openhands.cli import main as cli
-from openhands.core.config.llm_config import LLMConfig
-from openhands.events import EventSource
-from openhands.events.action import MessageAction
-
-
-@pytest_asyncio.fixture
-def mock_agent():
- agent = AsyncMock()
- agent.reset = MagicMock()
- return agent
-
-
-@pytest_asyncio.fixture
-def mock_runtime():
- runtime = AsyncMock()
- runtime.close = MagicMock()
- runtime.event_stream = MagicMock()
- return runtime
-
-
-@pytest_asyncio.fixture
-def mock_controller():
- controller = AsyncMock()
- controller.close = AsyncMock()
-
- # Setup for get_state() and the returned state's save_to_session()
- mock_state = MagicMock()
- mock_state.save_to_session = MagicMock()
- controller.get_state = MagicMock(return_value=mock_state)
- return controller
-
-
-@pytest_asyncio.fixture
-def mock_config():
- config = MagicMock()
- config.runtime = 'local'
- config.cli_multiline_input = False
- config.workspace_base = '/test/dir'
-
- # Set up LLM config to use OpenHands provider
- llm_config = LLMConfig(model='openhands/o3', api_key=SecretStr('invalid-api-key'))
- llm_config.model = 'openhands/o3' # Use OpenHands provider with o3 model
- config.get_llm_config.return_value = llm_config
- config.get_llm_config_from_agent.return_value = llm_config
-
- # Mock search_api_key with get_secret_value method
- search_api_key_mock = MagicMock()
- search_api_key_mock.get_secret_value.return_value = (
- '' # Empty string, not starting with 'tvly-'
- )
- config.search_api_key = search_api_key_mock
-
- # Mock sandbox with volumes attribute to prevent finalize_config issues
- config.sandbox = MagicMock()
- config.sandbox.volumes = (
- None # This prevents finalize_config from overriding workspace_base
- )
-
- return config
-
-
-@pytest_asyncio.fixture
-def mock_settings_store():
- settings_store = AsyncMock()
- return settings_store
-
-
-@pytest.mark.asyncio
-@patch('openhands.cli.main.display_runtime_initialization_message')
-@patch('openhands.cli.main.display_initialization_animation')
-@patch('openhands.cli.main.create_agent')
-@patch('openhands.cli.main.add_mcp_tools_to_agent')
-@patch('openhands.cli.main.create_runtime')
-@patch('openhands.cli.main.create_controller')
-@patch('openhands.cli.main.create_memory')
-@patch('openhands.cli.main.run_agent_until_done')
-@patch('openhands.cli.main.cleanup_session')
-@patch('openhands.cli.main.initialize_repository_for_runtime')
-@patch('openhands.llm.llm.litellm_completion')
-async def test_openhands_provider_authentication_error(
- mock_litellm_completion,
- mock_initialize_repo,
- mock_cleanup_session,
- mock_run_agent_until_done,
- mock_create_memory,
- mock_create_controller,
- mock_create_runtime,
- mock_add_mcp_tools,
- mock_create_agent,
- mock_display_animation,
- mock_display_runtime_init,
- mock_config,
- mock_settings_store,
-):
- """Test that authentication errors with the OpenHands provider are handled correctly.
-
- This test reproduces the error seen in the CLI when using the OpenHands provider:
-
- ```
- litellm.exceptions.AuthenticationError: litellm.AuthenticationError: AuthenticationError: Litellm_proxyException -
- Authentication Error, Invalid proxy server token passed. Received API Key = sk-...7hlQ,
- Key Hash (Token) =e316fa114498880be11f2e236d6f482feee5e324a4a148b98af247eded5290c4.
- Unable to find token in cache or `LiteLLM_VerificationTokenTable`
-
- 18:38:53 - openhands:ERROR: loop.py:25 - STATUS$ERROR_LLM_AUTHENTICATION
- ```
-
- The test mocks the litellm_completion function to raise an AuthenticationError
- with the OpenHands provider and verifies that the CLI handles the error gracefully.
- """
- loop = asyncio.get_running_loop()
-
- # Mock initialize_repository_for_runtime to return a valid path
- mock_initialize_repo.return_value = '/test/dir'
-
- # Mock objects returned by the setup functions
- mock_agent = AsyncMock()
- mock_create_agent.return_value = mock_agent
-
- mock_runtime = AsyncMock()
- mock_runtime.event_stream = MagicMock()
- mock_create_runtime.return_value = mock_runtime
-
- mock_controller = AsyncMock()
- mock_controller_task = MagicMock()
- mock_create_controller.return_value = (mock_controller, mock_controller_task)
-
- # Create a regular MagicMock for memory to avoid coroutine issues
- mock_memory = MagicMock()
- mock_create_memory.return_value = mock_memory
-
- # Mock the litellm_completion function to raise an AuthenticationError
- # This simulates the exact error seen in the user's issue
- auth_error_message = (
- 'litellm.AuthenticationError: AuthenticationError: Litellm_proxyException - '
- 'Authentication Error, Invalid proxy server token passed. Received API Key = sk-...7hlQ, '
- 'Key Hash (Token) =e316fa114498880be11f2e236d6f482feee5e324a4a148b98af247eded5290c4. '
- 'Unable to find token in cache or `LiteLLM_VerificationTokenTable`'
- )
- mock_litellm_completion.side_effect = AuthenticationError(
- message=auth_error_message, llm_provider='litellm_proxy', model='o3'
- )
-
- with patch(
- 'openhands.cli.main.read_prompt_input', new_callable=AsyncMock
- ) as mock_read_prompt:
- # Set up read_prompt_input to return a string that will trigger the command handler
- mock_read_prompt.return_value = '/exit'
-
- # Mock handle_commands to return values that will exit the loop
- with patch(
- 'openhands.cli.main.handle_commands', new_callable=AsyncMock
- ) as mock_handle_commands:
- mock_handle_commands.return_value = (
- True,
- False,
- False,
- ) # close_repl, reload_microagents, new_session_requested
-
- # Mock logger.error to capture the error message
- with patch('openhands.core.logger.openhands_logger.error'):
- # Run the function with an initial action that will trigger the OpenHands provider
- initial_action_content = 'Hello, I need help with a task'
-
- # Run the function
- result = await cli.run_session(
- loop,
- mock_config,
- mock_settings_store,
- '/test/dir',
- initial_action_content,
- )
-
- # Check that an event was added to the event stream
- mock_runtime.event_stream.add_event.assert_called_once()
- call_args = mock_runtime.event_stream.add_event.call_args[0]
- assert isinstance(call_args[0], MessageAction)
- # The CLI might modify the initial message, so we don't check the exact content
- assert call_args[1] == EventSource.USER
-
- # Check that run_agent_until_done was called
- mock_run_agent_until_done.assert_called_once()
-
- # Since we're mocking the litellm_completion function to raise an AuthenticationError,
- # we can verify that the error was handled by checking that the run_agent_until_done
- # function was called and the session was cleaned up properly
-
- # We can't directly check the error message in the test since the logger.error
- # method isn't being called in our mocked environment. In a real environment,
- # the error would be logged and the user would see the improved error message.
-
- # Check that cleanup_session was called
- mock_cleanup_session.assert_called_once()
-
- # Check that the function returns the expected value
- assert result is False
diff --git a/tests/unit/cli/test_cli_pause_resume.py b/tests/unit/cli/test_cli_pause_resume.py
deleted file mode 100644
index b76e0330c4..0000000000
--- a/tests/unit/cli/test_cli_pause_resume.py
+++ /dev/null
@@ -1,416 +0,0 @@
-import asyncio
-from unittest.mock import MagicMock, call, patch
-
-import pytest
-from prompt_toolkit.formatted_text import HTML
-from prompt_toolkit.keys import Keys
-
-from openhands.cli.tui import process_agent_pause
-from openhands.core.schema import AgentState
-from openhands.events import EventSource
-from openhands.events.action import ChangeAgentStateAction
-from openhands.events.observation import AgentStateChangedObservation
-
-
-class TestProcessAgentPause:
- @pytest.mark.asyncio
- @patch('openhands.cli.tui.create_input')
- @patch('openhands.cli.tui.print_formatted_text')
- async def test_process_agent_pause_ctrl_p(self, mock_print, mock_create_input):
- """Test that process_agent_pause sets the done event when Ctrl+P is pressed."""
- # Create the done event
- done = asyncio.Event()
-
- # Set up the mock input
- mock_input = MagicMock()
- mock_create_input.return_value = mock_input
-
- # Mock the context managers
- mock_raw_mode = MagicMock()
- mock_input.raw_mode.return_value = mock_raw_mode
- mock_raw_mode.__enter__ = MagicMock()
- mock_raw_mode.__exit__ = MagicMock()
-
- mock_attach = MagicMock()
- mock_input.attach.return_value = mock_attach
- mock_attach.__enter__ = MagicMock()
- mock_attach.__exit__ = MagicMock()
-
- # Capture the keys_ready function
- keys_ready_func = None
-
- def fake_attach(callback):
- nonlocal keys_ready_func
- keys_ready_func = callback
- return mock_attach
-
- mock_input.attach.side_effect = fake_attach
-
- # Create a task to run process_agent_pause
- task = asyncio.create_task(process_agent_pause(done, event_stream=MagicMock()))
-
- # Give it a moment to start and capture the callback
- await asyncio.sleep(0.1)
-
- # Make sure we captured the callback
- assert keys_ready_func is not None
-
- # Create a key press that simulates Ctrl+P
- key_press = MagicMock()
- key_press.key = Keys.ControlP
- mock_input.read_keys.return_value = [key_press]
-
- # Manually call the callback to simulate key press
- keys_ready_func()
-
- # Verify done was set
- assert done.is_set()
-
- # Verify print was called with the pause message
- assert mock_print.call_count == 2
- assert mock_print.call_args_list[0] == call('')
-
- # Check that the second call contains the pause message HTML
- second_call = mock_print.call_args_list[1][0][0]
- assert isinstance(second_call, HTML)
- assert 'Pausing the agent' in str(second_call)
-
- # Cancel the task
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
-
-
-class TestCliPauseResumeInRunSession:
- @pytest.mark.asyncio
- async def test_on_event_async_pause_processing(self):
- """Test that on_event_async processes the pause event when is_paused is set."""
- # Create a mock event
- event = MagicMock()
-
- # Create mock dependencies
- event_stream = MagicMock()
- is_paused = asyncio.Event()
- reload_microagents = False
- config = MagicMock()
-
- # Patch the display_event function
- with (
- patch('openhands.cli.main.display_event') as mock_display_event,
- patch('openhands.cli.main.update_usage_metrics') as mock_update_metrics,
- ):
- # Create a closure to capture the current context
- async def test_func():
- # Set the pause event
- is_paused.set()
-
- # Create a context similar to run_session to call on_event_async
- # We're creating a function that mimics the environment of on_event_async
- async def on_event_async_test(event):
- nonlocal reload_microagents, is_paused
- mock_display_event(event, config)
- mock_update_metrics(event, usage_metrics=MagicMock())
-
- # Pause the agent if the pause event is set (through Ctrl-P)
- if is_paused.is_set():
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.PAUSED),
- EventSource.USER,
- )
- # The pause event is not cleared here because we want to simulate
- # the PAUSED event processing in a future event
-
- # Call on_event_async_test
- await on_event_async_test(event)
-
- # Check that event_stream.add_event was called with the correct action
- event_stream.add_event.assert_called_once()
- args, kwargs = event_stream.add_event.call_args
- action, source = args
-
- assert isinstance(action, ChangeAgentStateAction)
- assert action.agent_state == AgentState.PAUSED
- assert source == EventSource.USER
-
- # Check that is_paused is still set (will be cleared when PAUSED state is processed)
- assert is_paused.is_set()
-
- # Run the test function
- await test_func()
-
- @pytest.mark.asyncio
- async def test_awaiting_user_input_paused_skip(self):
- """Test that when is_paused is set, awaiting user input events do not trigger prompting."""
- # Create a mock event with AgentStateChangedObservation
- event = MagicMock()
- event.observation = AgentStateChangedObservation(
- agent_state=AgentState.AWAITING_USER_INPUT, content='Agent awaiting input'
- )
-
- # Create mock dependencies
- is_paused = asyncio.Event()
- reload_microagents = False
-
- # Mock function that would be called if code reaches that point
- mock_prompt_task = MagicMock()
-
- # Create a closure to capture the current context
- async def test_func():
- # Set the pause event
- is_paused.set()
-
- # Create a context similar to run_session to call on_event_async
- async def on_event_async_test(event):
- nonlocal reload_microagents, is_paused
-
- if isinstance(event.observation, AgentStateChangedObservation):
- if event.observation.agent_state in [
- AgentState.AWAITING_USER_INPUT,
- AgentState.FINISHED,
- ]:
- # If the agent is paused, do not prompt for input
- if is_paused.is_set():
- return
-
- # This code should not be reached if is_paused is set
- mock_prompt_task()
-
- # Call on_event_async_test
- await on_event_async_test(event)
-
- # Verify that mock_prompt_task was not called
- mock_prompt_task.assert_not_called()
-
- # Run the test
- await test_func()
-
- @pytest.mark.asyncio
- async def test_awaiting_confirmation_paused_skip(self):
- """Test that when is_paused is set, awaiting confirmation events do not trigger prompting."""
- # Create a mock event with AgentStateChangedObservation
- event = MagicMock()
- event.observation = AgentStateChangedObservation(
- agent_state=AgentState.AWAITING_USER_CONFIRMATION,
- content='Agent awaiting confirmation',
- )
-
- # Create mock dependencies
- is_paused = asyncio.Event()
-
- # Mock function that would be called if code reaches that point
- mock_confirmation = MagicMock()
-
- # Create a closure to capture the current context
- async def test_func():
- # Set the pause event
- is_paused.set()
-
- # Create a context similar to run_session to call on_event_async
- async def on_event_async_test(event):
- nonlocal is_paused
-
- if isinstance(event.observation, AgentStateChangedObservation):
- if (
- event.observation.agent_state
- == AgentState.AWAITING_USER_CONFIRMATION
- ):
- if is_paused.is_set():
- return
-
- # This code should not be reached if is_paused is set
- mock_confirmation()
-
- # Call on_event_async_test
- await on_event_async_test(event)
-
- # Verify that confirmation function was not called
- mock_confirmation.assert_not_called()
-
- # Run the test
- await test_func()
-
-
-class TestCliCommandsPauseResume:
- @pytest.mark.asyncio
- @patch('openhands.cli.commands.handle_resume_command')
- async def test_handle_commands_resume(self, mock_handle_resume):
- """Test that the handle_commands function properly calls handle_resume_command."""
- # Import here to avoid circular imports in test
- from openhands.cli.commands import handle_commands
-
- # Create mocks
- message = '/resume'
- event_stream = MagicMock()
- usage_metrics = MagicMock()
- sid = 'test-session-id'
- config = MagicMock()
- current_dir = '/test/dir'
- settings_store = MagicMock()
- agent_state = AgentState.PAUSED
-
- # Mock return value
- mock_handle_resume.return_value = (False, False)
-
- # Call handle_commands
- (
- close_repl,
- reload_microagents,
- new_session_requested,
- _,
- ) = await handle_commands(
- message,
- event_stream,
- usage_metrics,
- sid,
- config,
- current_dir,
- settings_store,
- agent_state,
- )
-
- # Check that handle_resume_command was called with correct args
- mock_handle_resume.assert_called_once_with(message, event_stream, agent_state)
-
- # Check the return values
- assert close_repl is False
- assert reload_microagents is False
- assert new_session_requested is False
-
-
-class TestAgentStatePauseResume:
- @pytest.mark.asyncio
- @patch('openhands.cli.main.display_agent_running_message')
- @patch('openhands.cli.tui.process_agent_pause')
- async def test_agent_running_enables_pause(
- self, mock_process_agent_pause, mock_display_message
- ):
- """Test that when the agent is running, pause functionality is enabled."""
- # Create a mock event and event stream
- event = MagicMock()
- event.observation = AgentStateChangedObservation(
- agent_state=AgentState.RUNNING, content='Agent is running'
- )
- event_stream = MagicMock()
-
- # Create mock dependencies
- is_paused = asyncio.Event()
- loop = MagicMock()
- reload_microagents = False
-
- # Create a closure to capture the current context
- async def test_func():
- # Create a context similar to run_session to call on_event_async
- async def on_event_async_test(event):
- nonlocal reload_microagents
-
- if isinstance(event.observation, AgentStateChangedObservation):
- if event.observation.agent_state == AgentState.RUNNING:
- mock_display_message()
- loop.create_task(
- mock_process_agent_pause(is_paused, event_stream)
- )
-
- # Call on_event_async_test
- await on_event_async_test(event)
-
- # Check that display_agent_running_message was called
- mock_display_message.assert_called_once()
-
- # Check that loop.create_task was called
- loop.create_task.assert_called_once()
-
- # Run the test function
- await test_func()
-
- @pytest.mark.asyncio
- @patch('openhands.cli.main.display_event')
- @patch('openhands.cli.main.update_usage_metrics')
- async def test_pause_event_changes_agent_state(
- self, mock_update_metrics, mock_display_event
- ):
- """Test that when is_paused is set, a PAUSED state change event is added to the stream."""
- # Create mock dependencies
- event = MagicMock()
- event_stream = MagicMock()
- is_paused = asyncio.Event()
- config = MagicMock()
- reload_microagents = False
-
- # Set the pause event
- is_paused.set()
-
- # Create a closure to capture the current context
- async def test_func():
- # Create a context similar to run_session to call on_event_async
- async def on_event_async_test(event):
- nonlocal reload_microagents
- mock_display_event(event, config)
- mock_update_metrics(event, MagicMock())
-
- # Pause the agent if the pause event is set (through Ctrl-P)
- if is_paused.is_set():
- event_stream.add_event(
- ChangeAgentStateAction(AgentState.PAUSED),
- EventSource.USER,
- )
- is_paused.clear()
-
- # Call the function
- await on_event_async_test(event)
-
- # Check that the event_stream.add_event was called with the correct action
- event_stream.add_event.assert_called_once()
- args, kwargs = event_stream.add_event.call_args
- action, source = args
-
- assert isinstance(action, ChangeAgentStateAction)
- assert action.agent_state == AgentState.PAUSED
- assert source == EventSource.USER
-
- # Check that is_paused was cleared
- assert not is_paused.is_set()
-
- # Run the test
- await test_func()
-
- @pytest.mark.asyncio
- async def test_paused_agent_awaits_input(self):
- """Test that when the agent is paused, it awaits user input."""
- # Create mock dependencies
- event = MagicMock()
- # AgentStateChangedObservation requires a content parameter
- event.observation = AgentStateChangedObservation(
- agent_state=AgentState.PAUSED, content='Agent state changed to PAUSED'
- )
- is_paused = asyncio.Event()
-
- # Mock function that would be called for prompting
- mock_prompt_task = MagicMock()
-
- # Create a closure to capture the current context
- async def test_func():
- # Create a simplified version of on_event_async
- async def on_event_async_test(event):
- nonlocal is_paused
-
- if isinstance(event.observation, AgentStateChangedObservation):
- if event.observation.agent_state == AgentState.PAUSED:
- is_paused.clear() # Revert the event state before prompting for user input
- mock_prompt_task(event.observation.agent_state)
-
- # Set is_paused to test that it gets cleared
- is_paused.set()
-
- # Call the function
- await on_event_async_test(event)
-
- # Check that is_paused was cleared
- assert not is_paused.is_set()
-
- # Check that prompt task was called with the correct state
- mock_prompt_task.assert_called_once_with(AgentState.PAUSED)
-
- # Run the test
- await test_func()
diff --git a/tests/unit/cli/test_cli_runtime_mcp.py b/tests/unit/cli/test_cli_runtime_mcp.py
deleted file mode 100644
index 9330b73711..0000000000
--- a/tests/unit/cli/test_cli_runtime_mcp.py
+++ /dev/null
@@ -1,161 +0,0 @@
-"""Tests for CLI Runtime MCP functionality."""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from openhands.core.config import OpenHandsConfig
-from openhands.core.config.mcp_config import (
- MCPConfig,
- MCPSSEServerConfig,
- MCPStdioServerConfig,
-)
-from openhands.events.action.mcp import MCPAction
-from openhands.events.observation import ErrorObservation
-from openhands.events.observation.mcp import MCPObservation
-from openhands.llm.llm_registry import LLMRegistry
-from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
-
-
-class TestCLIRuntimeMCP:
- """Test MCP functionality in CLI Runtime."""
-
- def setup_method(self):
- """Set up test fixtures."""
- self.config = OpenHandsConfig()
- self.event_stream = MagicMock()
- llm_registry = LLMRegistry(config=OpenHandsConfig())
- self.runtime = CLIRuntime(
- config=self.config,
- event_stream=self.event_stream,
- sid='test-session',
- llm_registry=llm_registry,
- )
-
- @pytest.mark.asyncio
- async def test_call_tool_mcp_no_servers_configured(self):
- """Test MCP call with no servers configured."""
- # Set up empty MCP config
- self.runtime.config.mcp = MCPConfig()
-
- action = MCPAction(name='test_tool', arguments={'arg1': 'value1'})
-
- with patch('sys.platform', 'linux'):
- result = await self.runtime.call_tool_mcp(action)
-
- assert isinstance(result, ErrorObservation)
- assert 'No MCP servers configured' in result.content
-
- @pytest.mark.asyncio
- @patch('openhands.mcp.utils.create_mcp_clients')
- async def test_call_tool_mcp_no_clients_created(self, mock_create_clients):
- """Test MCP call when no clients can be created."""
- # Set up MCP config with servers
- self.runtime.config.mcp = MCPConfig(
- sse_servers=[MCPSSEServerConfig(url='http://test.com')]
- )
-
- # Mock create_mcp_clients to return empty list
- mock_create_clients.return_value = []
-
- action = MCPAction(name='test_tool', arguments={'arg1': 'value1'})
-
- with patch('sys.platform', 'linux'):
- result = await self.runtime.call_tool_mcp(action)
-
- assert isinstance(result, ErrorObservation)
- assert 'No MCP clients could be created' in result.content
- mock_create_clients.assert_called_once()
-
- @pytest.mark.asyncio
- @patch('openhands.mcp.utils.create_mcp_clients')
- @patch('openhands.mcp.utils.call_tool_mcp')
- async def test_call_tool_mcp_success(self, mock_call_tool, mock_create_clients):
- """Test successful MCP tool call."""
- # Set up MCP config with servers
- self.runtime.config.mcp = MCPConfig(
- sse_servers=[MCPSSEServerConfig(url='http://test.com')],
- stdio_servers=[MCPStdioServerConfig(name='test-stdio', command='python')],
- )
-
- # Mock successful client creation
- mock_client = MagicMock()
- mock_create_clients.return_value = [mock_client]
-
- # Mock successful tool call
- expected_observation = MCPObservation(
- content='{"result": "success"}',
- name='test_tool',
- arguments={'arg1': 'value1'},
- )
- mock_call_tool.return_value = expected_observation
-
- action = MCPAction(name='test_tool', arguments={'arg1': 'value1'})
-
- with patch('sys.platform', 'linux'):
- result = await self.runtime.call_tool_mcp(action)
-
- assert result == expected_observation
- mock_create_clients.assert_called_once_with(
- self.runtime.config.mcp.sse_servers,
- self.runtime.config.mcp.shttp_servers,
- self.runtime.sid,
- self.runtime.config.mcp.stdio_servers,
- )
- mock_call_tool.assert_called_once_with([mock_client], action)
-
- @pytest.mark.asyncio
- @patch('openhands.mcp.utils.create_mcp_clients')
- async def test_call_tool_mcp_exception_handling(self, mock_create_clients):
- """Test exception handling in MCP tool call."""
- # Set up MCP config with servers
- self.runtime.config.mcp = MCPConfig(
- sse_servers=[MCPSSEServerConfig(url='http://test.com')]
- )
-
- # Mock create_mcp_clients to raise an exception
- mock_create_clients.side_effect = Exception('Connection error')
-
- action = MCPAction(name='test_tool', arguments={'arg1': 'value1'})
-
- with patch('sys.platform', 'linux'):
- result = await self.runtime.call_tool_mcp(action)
-
- assert isinstance(result, ErrorObservation)
- assert 'Error executing MCP tool test_tool' in result.content
- assert 'Connection error' in result.content
-
- def test_get_mcp_config_basic(self):
- """Test basic MCP config retrieval."""
- # Set up MCP config
- expected_config = MCPConfig(
- sse_servers=[MCPSSEServerConfig(url='http://test.com')],
- stdio_servers=[MCPStdioServerConfig(name='test-stdio', command='python')],
- )
- self.runtime.config.mcp = expected_config
-
- with patch('sys.platform', 'linux'):
- result = self.runtime.get_mcp_config()
-
- assert result == expected_config
-
- def test_get_mcp_config_with_extra_stdio_servers(self):
- """Test MCP config with extra stdio servers."""
- # Set up initial MCP config
- initial_stdio_server = MCPStdioServerConfig(name='initial', command='python')
- self.runtime.config.mcp = MCPConfig(stdio_servers=[initial_stdio_server])
-
- # Add extra stdio servers
- extra_servers = [
- MCPStdioServerConfig(name='extra1', command='node'),
- MCPStdioServerConfig(name='extra2', command='java'),
- ]
-
- with patch('sys.platform', 'linux'):
- result = self.runtime.get_mcp_config(extra_stdio_servers=extra_servers)
-
- # Should have all three servers
- assert len(result.stdio_servers) == 3
- assert initial_stdio_server in result.stdio_servers
- assert extra_servers[0] in result.stdio_servers
- assert extra_servers[1] in result.stdio_servers
diff --git a/tests/unit/cli/test_cli_settings.py b/tests/unit/cli/test_cli_settings.py
deleted file mode 100644
index a2cba1fffb..0000000000
--- a/tests/unit/cli/test_cli_settings.py
+++ /dev/null
@@ -1,1449 +0,0 @@
-from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from prompt_toolkit.formatted_text import HTML
-from pydantic import SecretStr
-
-from openhands.cli.settings import (
- display_settings,
- modify_llm_settings_advanced,
- modify_llm_settings_basic,
- modify_search_api_settings,
-)
-from openhands.cli.tui import UserCancelledError
-from openhands.core.config import OpenHandsConfig
-from openhands.storage.data_models.settings import Settings
-from openhands.storage.settings.file_settings_store import FileSettingsStore
-
-
-# Mock classes for condensers
-class MockLLMSummarizingCondenserConfig:
- def __init__(self, llm_config, type, keep_first=4, max_size=120):
- self.llm_config = llm_config
- self.type = type
- self.keep_first = keep_first
- self.max_size = max_size
-
-
-class MockConversationWindowCondenserConfig:
- def __init__(self, type):
- self.type = type
-
-
-class MockCondenserPipelineConfig:
- def __init__(self, type, condensers):
- self.type = type
- self.condensers = condensers
-
-
-class TestDisplaySettings:
- @pytest.fixture
- def app_config(self):
- config = MagicMock(spec=OpenHandsConfig)
- llm_config = MagicMock()
- llm_config.base_url = None
- llm_config.model = 'openai/gpt-4'
- llm_config.api_key = SecretStr('test-api-key')
- config.get_llm_config.return_value = llm_config
- config.default_agent = 'test-agent'
- config.file_store_path = '/tmp'
-
- # Set up security as a separate mock
- security_mock = MagicMock(spec=OpenHandsConfig)
- security_mock.confirmation_mode = True
- config.security = security_mock
-
- config.enable_default_condenser = True
- config.search_api_key = SecretStr('tvly-test-key')
- return config
-
- @pytest.fixture
- def advanced_app_config(self):
- config = MagicMock()
- llm_config = MagicMock()
- llm_config.base_url = 'https://custom-api.com'
- llm_config.model = 'custom-model'
- llm_config.api_key = SecretStr('test-api-key')
- config.get_llm_config.return_value = llm_config
- config.default_agent = 'test-agent'
- config.file_store_path = '/tmp'
-
- # Set up security as a separate mock
- security_mock = MagicMock()
- security_mock.confirmation_mode = True
- config.security = security_mock
-
- config.enable_default_condenser = True
- config.search_api_key = SecretStr('tvly-test-key')
- return config
-
- @patch('openhands.cli.settings.print_container')
- def test_display_settings_standard_config(self, mock_print_container, app_config):
- display_settings(app_config)
- mock_print_container.assert_called_once()
-
- # Verify the container was created with the correct settings
- container = mock_print_container.call_args[0][0]
- text_area = container.body
-
- # Check that the text area contains expected labels and values
- settings_text = text_area.text
- assert 'LLM Provider:' in settings_text
- assert 'openai' in settings_text
- assert 'LLM Model:' in settings_text
- assert 'gpt-4' in settings_text
- assert 'API Key:' in settings_text
- assert '********' in settings_text
- assert 'Agent:' in settings_text
- assert 'test-agent' in settings_text
- assert 'Confirmation Mode:' in settings_text
- assert 'Enabled' in settings_text
- assert 'Memory Condensation:' in settings_text
- assert 'Enabled' in settings_text
- assert 'Search API Key:' in settings_text
- assert '********' in settings_text # Search API key should be masked
- assert 'Configuration File' in settings_text
- assert str(Path(app_config.file_store_path)) in settings_text
-
- @patch('openhands.cli.settings.print_container')
- def test_display_settings_advanced_config(
- self, mock_print_container, advanced_app_config
- ):
- display_settings(advanced_app_config)
- mock_print_container.assert_called_once()
-
- # Verify the container was created with the correct settings
- container = mock_print_container.call_args[0][0]
- text_area = container.body
-
- # Check that the text area contains expected labels and values
- settings_text = text_area.text
- assert 'Custom Model:' in settings_text
- assert 'custom-model' in settings_text
- assert 'Base URL:' in settings_text
- assert 'https://custom-api.com' in settings_text
- assert 'API Key:' in settings_text
- assert '********' in settings_text
- assert 'Agent:' in settings_text
- assert 'test-agent' in settings_text
-
-
-class TestModifyLLMSettingsBasic:
- @pytest.fixture
- def app_config(self):
- config = MagicMock(spec=OpenHandsConfig)
- llm_config = MagicMock()
- llm_config.model = 'openai/gpt-4'
- llm_config.api_key = SecretStr('test-api-key')
- llm_config.base_url = None
- config.get_llm_config.return_value = llm_config
- config.set_llm_config = MagicMock()
- config.set_agent_config = MagicMock()
-
- agent_config = MagicMock()
- config.get_agent_config.return_value = agent_config
-
- # Set up security as a separate mock
- security_mock = MagicMock()
- security_mock.confirmation_mode = True
- config.security = security_mock
-
- return config
-
- @pytest.fixture
- def settings_store(self):
- store = MagicMock(spec=FileSettingsStore)
- store.load = AsyncMock(return_value=Settings())
- store.store = AsyncMock()
- return store
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.get_supported_llm_models')
- @patch('openhands.cli.settings.organize_models_and_providers')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- async def test_modify_llm_settings_basic_success(
- self,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
- app_config,
- settings_store,
- ):
- # Setup mocks
- mock_get_models.return_value = ['openai/gpt-4', 'anthropic/claude-3-opus']
- mock_organize.return_value = {
- 'openai': {'models': ['gpt-4', 'gpt-3.5-turbo'], 'separator': '/'},
- 'anthropic': {
- 'models': ['claude-3-opus', 'claude-3-sonnet'],
- 'separator': '/',
- },
- }
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'gpt-4', # Model
- 'new-api-key', # API Key
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock cli_confirm to:
- # 1. Select the first provider (openai) from the list
- # 2. Select "Select another model" option
- # 3. Select "Yes, save" option
- mock_confirm.side_effect = [0, 1, 0]
-
- # Call the function
- await modify_llm_settings_basic(app_config, settings_store)
-
- # Verify LLM config was updated
- app_config.set_llm_config.assert_called_once()
- args, kwargs = app_config.set_llm_config.call_args
- # The model name might be different based on the default model in the list
- # Just check that it contains 'gpt-4' instead of checking for prefix
- assert 'gpt-4' in args[0].model
- assert args[0].api_key.get_secret_value() == 'new-api-key'
- assert args[0].base_url is None
-
- # Verify settings were saved
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- # The model name might be different based on the default model in the list
- # Just check that it contains 'gpt-4' instead of checking for prefix
- assert 'gpt-4' in settings.llm_model
- assert settings.llm_api_key.get_secret_value() == 'new-api-key'
- assert settings.llm_base_url is None
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.get_supported_llm_models')
- @patch('openhands.cli.settings.organize_models_and_providers')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- async def test_modify_llm_settings_basic_user_cancels(
- self,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
- app_config,
- settings_store,
- ):
- # Setup mocks
- mock_get_models.return_value = ['openai/gpt-4', 'anthropic/claude-3-opus']
- mock_organize.return_value = {
- 'openai': {'models': ['gpt-4', 'gpt-3.5-turbo'], 'separator': '/'}
- }
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(side_effect=UserCancelledError())
- mock_session.return_value = session_instance
-
- # Call the function
- await modify_llm_settings_basic(app_config, settings_store)
-
- # Verify settings were not changed
- app_config.set_llm_config.assert_not_called()
- settings_store.store.assert_not_called()
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.get_supported_llm_models')
- @patch('openhands.cli.settings.organize_models_and_providers')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch('openhands.cli.settings.print_formatted_text')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- async def test_modify_llm_settings_basic_invalid_provider_input(
- self,
- mock_print,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
- app_config,
- settings_store,
- ):
- # Setup mocks
- mock_get_models.return_value = ['openai/gpt-4', 'anthropic/claude-3-opus']
- mock_organize.return_value = {
- 'openai': {'models': ['gpt-4', 'gpt-3.5-turbo'], 'separator': '/'}
- }
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'invalid-provider', # First invalid provider
- 'openai', # Valid provider
- 'custom-model', # Custom model (now allowed with warning)
- 'new-api-key', # API key
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock cli_confirm to select the second option (change provider/model) for the first two calls
- # and then select the first option (save settings) for the last call
- mock_confirm.side_effect = [1, 1, 0]
-
- # Call the function
- await modify_llm_settings_basic(app_config, settings_store)
-
- # Verify error message was shown for invalid provider and warning for custom model
- assert mock_print.call_count >= 2 # At least two messages should be printed
-
- # Check for invalid provider error and custom model warning
- provider_error_found = False
- model_warning_found = False
-
- for call in mock_print.call_args_list:
- args, _ = call
- if args and isinstance(args[0], HTML):
- if 'Invalid provider selected' in args[0].value:
- provider_error_found = True
- if 'Warning:' in args[0].value and 'custom-model' in args[0].value:
- model_warning_found = True
-
- assert provider_error_found, 'No error message for invalid provider'
- assert model_warning_found, 'No warning message for custom model'
-
- # Verify LLM config was updated with the custom model
- app_config.set_llm_config.assert_called_once()
-
- # Verify settings were saved with the custom model
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert 'custom-model' in settings.llm_model
- assert settings.llm_api_key.get_secret_value() == 'new-api-key'
- assert settings.llm_base_url is None
-
- def test_default_model_selection(self):
- """Test that the default model selection uses the first model in the list."""
- # This is a simple test to verify that the default model selection uses the first model in the list
- # We're directly checking the code in settings.py where the default model is set
-
- import inspect
-
- import openhands.cli.settings as settings_module
-
- source_lines = inspect.getsource(
- settings_module.modify_llm_settings_basic
- ).splitlines()
-
- # Look for the block that sets the default model
- default_model_block = []
- in_default_model_block = False
- for line in source_lines:
- if (
- '# Set default model to the best verified model for the provider'
- in line
- ):
- in_default_model_block = True
- default_model_block.append(line)
- elif in_default_model_block:
- default_model_block.append(line)
- if '# Show the default model' in line:
- break
-
- # Assert that we found the default model selection logic
- assert default_model_block, (
- 'Could not find the block that sets the default model'
- )
-
- # Print the actual lines for debugging
- print('Default model block found:')
- for line in default_model_block:
- print(f' {line.strip()}')
-
- # Check that the logic uses the first model in the list
- first_model_check = any(
- 'provider_models[0]' in line for line in default_model_block
- )
-
- assert first_model_check, (
- 'Default model selection should use the first model in the list'
- )
-
- @pytest.mark.asyncio
- @patch(
- 'openhands.cli.settings.VERIFIED_PROVIDERS',
- ['openhands', 'anthropic', 'openai'],
- )
- @patch('openhands.cli.settings.VERIFIED_ANTHROPIC_MODELS', ['claude-3-opus'])
- @patch('openhands.cli.settings.get_supported_llm_models')
- @patch('openhands.cli.settings.organize_models_and_providers')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch('openhands.cli.settings.print_formatted_text')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- async def test_default_provider_print_and_initial_selection(
- self,
- mock_print,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
- app_config,
- settings_store,
- ):
- """Verify default provider printing and initial provider selection index."""
- mock_get_models.return_value = [
- 'openhands/o3',
- 'anthropic/claude-3-opus',
- 'openai/gpt-4',
- ]
- mock_organize.return_value = {
- 'openhands': {'models': ['o3'], 'separator': '/'},
- 'anthropic': {'models': ['claude-3-opus'], 'separator': '/'},
- 'openai': {'models': ['gpt-4'], 'separator': '/'},
- }
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(side_effect=['api-key-123'])
- mock_session.return_value = session_instance
- mock_confirm.side_effect = [1, 0, 0]
-
- await modify_llm_settings_basic(app_config, settings_store)
-
- # Assert printing of default provider
- default_print_calls = [
- c
- for c in mock_print.call_args_list
- if c
- and c[0]
- and isinstance(c[0][0], HTML)
- and 'Default provider:' in c[0][0].value
- ]
- assert default_print_calls, 'Default provider line was not printed'
- printed_html = default_print_calls[0][0][0].value
- assert 'anthropic' in printed_html
-
- # Assert initial_selection for provider prompt
- provider_confirm_call = mock_confirm.call_args_list[0]
- # initial_selection prefers current_provider (openai) over default_provider (anthropic)
- # VERIFIED_PROVIDERS = ['openhands', 'anthropic', 'openai'] → index of 'openai' is 2
- assert provider_confirm_call[1]['initial_selection'] == 2
-
- @pytest.fixture
- def app_config_with_existing(self):
- config = MagicMock(spec=OpenHandsConfig)
- llm_config = MagicMock()
- # Set existing configuration
- llm_config.model = 'anthropic/claude-3-opus'
- llm_config.api_key = SecretStr('existing-api-key')
- llm_config.base_url = None
- config.get_llm_config.return_value = llm_config
- config.set_llm_config = MagicMock()
- config.set_agent_config = MagicMock()
-
- agent_config = MagicMock()
- config.get_agent_config.return_value = agent_config
-
- # Set up security as a separate mock
- security_mock = MagicMock()
- security_mock.confirmation_mode = True
- config.security = security_mock
-
- return config
-
- @pytest.mark.asyncio
- @patch(
- 'openhands.cli.settings.VERIFIED_PROVIDERS',
- ['openhands', 'anthropic'],
- )
- @patch(
- 'openhands.cli.settings.VERIFIED_ANTHROPIC_MODELS',
- ['claude-3-opus', 'claude-3-sonnet'],
- )
- @patch('openhands.cli.settings.get_supported_llm_models')
- @patch('openhands.cli.settings.organize_models_and_providers')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- async def test_modify_llm_settings_basic_keep_existing_values(
- self,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
- app_config_with_existing,
- settings_store,
- ):
- """Test keeping existing configuration values by pressing Enter/selecting defaults."""
- # Setup mocks
- mock_get_models.return_value = ['anthropic/claude-3-opus', 'openai/gpt-4']
- mock_organize.return_value = {
- 'openhands': {'models': [], 'separator': '/'},
- 'anthropic': {
- 'models': ['claude-3-opus', 'claude-3-sonnet'],
- 'separator': '/',
- },
- }
-
- session_instance = MagicMock()
- # Simulate user pressing Enter to keep existing values
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- '',
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock cli_confirm to select existing provider and model (keeping current values)
- mock_confirm.side_effect = [
- 1, # Select anthropic (matches initial_selection position)
- 0, # Select first model option (use default)
- 0, # Save settings
- ]
-
- await modify_llm_settings_basic(app_config_with_existing, settings_store)
-
- # Check that initial_selection was used for provider selection
- provider_confirm_call = mock_confirm.call_args_list[0]
- # anthropic is at index 1 in VERIFIED_PROVIDERS ['openhands', 'anthropic']
- assert provider_confirm_call[1]['initial_selection'] == 1
-
- # Check that initial_selection was used for model selection
- model_confirm_call = mock_confirm.call_args_list[1]
- # claude-3-opus should be at index 0 in our mocked VERIFIED_OPENHANDS_MODELS
- assert 'initial_selection' in model_confirm_call[1]
- assert (
- model_confirm_call[1]['initial_selection'] == 0
- ) # claude-3-opus is at index 0
-
- # Verify API key prompt shows existing key indicator
- api_key_prompt_call = session_instance.prompt_async.call_args_list[0]
- prompt_text = api_key_prompt_call[0][0]
- assert 'exis***-key' in prompt_text
- assert 'ENTER to keep current' in prompt_text
-
- # Verify settings were saved with existing values (no changes)
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.llm_model == 'anthropic/claude-3-opus'
- assert settings.llm_api_key.get_secret_value() == 'existing-api-key'
-
- @pytest.mark.asyncio
- @patch(
- 'openhands.cli.settings.VERIFIED_PROVIDERS',
- ['openhands', 'anthropic'],
- )
- @patch(
- 'openhands.cli.settings.VERIFIED_ANTHROPIC_MODELS',
- ['claude-3-opus', 'claude-3-sonnet'],
- )
- @patch('openhands.cli.settings.get_supported_llm_models')
- @patch('openhands.cli.settings.organize_models_and_providers')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- async def test_modify_llm_settings_basic_change_only_api_key(
- self,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
- app_config_with_existing,
- settings_store,
- ):
- """Test changing only the API key while keeping provider and model."""
- # Setup mocks
- mock_get_models.return_value = ['anthropic/claude-3-opus']
- mock_organize.return_value = {
- 'openhands': {'models': [], 'separator': '/'},
- 'anthropic': {
- 'models': ['claude-3-opus', 'claude-3-sonnet'],
- 'separator': '/',
- },
- }
-
- session_instance = MagicMock()
- # User enters a new API key
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'new-api-key-12345',
- ]
- )
- mock_session.return_value = session_instance
-
- # Keep existing provider and model
- mock_confirm.side_effect = [
- 1, # Select anthropic (matches initial_selection position)
- 0, # Select first model option (use default)
- 0, # Save settings
- ]
-
- await modify_llm_settings_basic(app_config_with_existing, settings_store)
-
- # Verify settings were saved with only API key changed
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- # Model should remain the same
- assert settings.llm_model == 'anthropic/claude-3-opus'
- # API key should be the new one
- assert settings.llm_api_key.get_secret_value() == 'new-api-key-12345'
-
- @pytest.mark.asyncio
- @patch(
- 'openhands.cli.settings.VERIFIED_PROVIDERS',
- ['openhands', 'anthropic'],
- )
- @patch(
- 'openhands.cli.settings.VERIFIED_OPENHANDS_MODELS',
- [
- 'claude-sonnet-4-20250514',
- 'claude-sonnet-4-5-20250929',
- 'claude-opus-4-20250514',
- 'o3',
- ],
- )
- @patch('openhands.cli.settings.get_supported_llm_models')
- @patch('openhands.cli.settings.organize_models_and_providers')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- async def test_modify_llm_settings_basic_change_provider_and_model(
- self,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
- app_config_with_existing,
- settings_store,
- ):
- """Test changing provider and model requires re-entering API key when provider changes."""
- # Setup mocks
- mock_get_models.return_value = [
- 'openhands/claude-sonnet-4-20250514',
- 'openhands/claude-sonnet-4-5-20250929',
- 'openhands/claude-opus-4-20250514',
- 'openhands/o3',
- ]
- mock_organize.return_value = {
- 'openhands': {
- 'models': [
- 'claude-sonnet-4-20250514',
- 'claude-sonnet-4-5-20250929',
- 'claude-opus-4-20250514',
- 'o3',
- ],
- 'separator': '/',
- },
- 'anthropic': {
- 'models': ['claude-3-opus', 'claude-3-sonnet'],
- 'separator': '/',
- },
- }
-
- session_instance = MagicMock()
- # Must enter a new API key because provider changed (current key cleared)
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'new-api-key-after-provider-change',
- ]
- )
- mock_session.return_value = session_instance
-
- # Change provider and model
- mock_confirm.side_effect = [
- 0, # Select openhands (index 0 in ['openhands', 'anthropic'])
- 3, # Select o3 (index 3 in ['claude-sonnet-4-20250514', 'claude-sonnet-4-5-20250929', 'claude-opus-4-20250514', 'o3'])
- 0, # Save settings
- ]
-
- await modify_llm_settings_basic(app_config_with_existing, settings_store)
-
- # Verify API key prompt does NOT show existing key indicator after provider change
- api_key_prompt_call = session_instance.prompt_async.call_args_list[0]
- prompt_text = api_key_prompt_call[0][0]
- assert '***' not in prompt_text
- assert 'ENTER to keep current' not in prompt_text
-
- # Verify settings were saved with new provider/model and new API key
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.llm_model == 'openhands/o3'
- # API key should be the newly entered one
- assert (
- settings.llm_api_key.get_secret_value()
- == 'new-api-key-after-provider-change'
- )
-
- @pytest.mark.asyncio
- @patch(
- 'openhands.cli.settings.VERIFIED_PROVIDERS',
- ['openhands', 'anthropic'],
- )
- @patch(
- 'openhands.cli.settings.VERIFIED_OPENHANDS_MODELS',
- ['anthropic/claude-3-opus', 'anthropic/claude-3-sonnet'],
- )
- @patch(
- 'openhands.cli.settings.VERIFIED_ANTHROPIC_MODELS',
- ['claude-sonnet-4-20250514', 'claude-sonnet-4-5-20250929', 'claude-3-opus'],
- )
- @patch('openhands.cli.settings.get_supported_llm_models')
- @patch('openhands.cli.settings.organize_models_and_providers')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- async def test_modify_llm_settings_basic_from_scratch(
- self,
- mock_confirm,
- mock_session,
- mock_organize,
- mock_get_models,
- settings_store,
- ):
- """Test setting up LLM configuration from scratch (no existing settings)."""
- # Create a fresh config with no existing LLM settings
- config = MagicMock(spec=OpenHandsConfig)
- llm_config = MagicMock()
- llm_config.model = None # No existing model
- llm_config.api_key = None # No existing API key
- llm_config.base_url = None
- config.get_llm_config.return_value = llm_config
- config.set_llm_config = MagicMock()
- config.set_agent_config = MagicMock()
-
- agent_config = MagicMock()
- config.get_agent_config.return_value = agent_config
-
- # Set up security as a separate mock
- security_mock = MagicMock()
- security_mock.confirmation_mode = True
- config.security = security_mock
-
- config.enable_default_condenser = True
- config.default_agent = 'test-agent'
- config.file_store_path = '/tmp'
-
- # Setup mocks
- mock_get_models.return_value = [
- 'anthropic/claude-sonnet-4-20250514',
- 'anthropic/claude-3-opus',
- ]
- mock_organize.return_value = {
- 'openhands': {'models': [], 'separator': '/'},
- 'anthropic': {
- 'models': ['claude-sonnet-4-20250514', 'claude-3-opus'],
- 'separator': '/',
- },
- }
-
- session_instance = MagicMock()
- # User enters a new API key (no existing key to keep)
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'new-api-key-12345',
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock cli_confirm to select anthropic provider and use default model
- mock_confirm.side_effect = [
- 1, # Select anthropic (index 1 in ['openhands', 'anthropic'])
- 0, # Use default model (claude-sonnet-4-20250514)
- 0, # Save settings
- ]
-
- await modify_llm_settings_basic(config, settings_store)
-
- # Check that initial_selection was used for provider selection
- provider_confirm_call = mock_confirm.call_args_list[0]
- # Since there's no existing provider, it defaults to 'anthropic' which is at index 1
- assert provider_confirm_call[1]['initial_selection'] == 1
-
- # Check that initial_selection was used for model selection
- model_confirm_call = mock_confirm.call_args_list[1]
- # Since there's no existing model, it should default to using the first option (default model)
- assert 'initial_selection' in model_confirm_call[1]
- assert model_confirm_call[1]['initial_selection'] == 0
-
- # Verify API key prompt does NOT show existing key indicator
- api_key_prompt_call = session_instance.prompt_async.call_args_list[0]
- prompt_text = api_key_prompt_call[0][0]
- assert '***' not in prompt_text
- assert 'ENTER to keep current' not in prompt_text
-
- # Verify settings were saved with new values
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.llm_model == 'anthropic/claude-sonnet-4-20250514'
- assert settings.llm_api_key.get_secret_value() == 'new-api-key-12345'
-
-
-class TestModifyLLMSettingsAdvanced:
- @pytest.fixture
- def app_config(self):
- config = MagicMock(spec=OpenHandsConfig)
- llm_config = MagicMock()
- llm_config.model = 'custom-model'
- llm_config.api_key = SecretStr('test-api-key')
- llm_config.base_url = 'https://custom-api.com'
- config.get_llm_config.return_value = llm_config
- config.set_llm_config = MagicMock()
- config.set_agent_config = MagicMock()
- config.default_agent = 'test-agent'
- config.enable_default_condenser = True
-
- agent_config = MagicMock()
- config.get_agent_config.return_value = agent_config
-
- # Set up security as a separate mock
- security_mock = MagicMock()
- security_mock.confirmation_mode = True
- config.security = security_mock
-
- return config
-
- @pytest.fixture
- def settings_store(self):
- store = MagicMock(spec=FileSettingsStore)
- store.load = AsyncMock(return_value=Settings())
- store.store = AsyncMock()
- return store
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.Agent.list_agents')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.ConversationWindowCondenserConfig',
- MockConversationWindowCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.CondenserPipelineConfig', MockCondenserPipelineConfig
- )
- async def test_modify_llm_settings_advanced_success(
- self, mock_confirm, mock_session, mock_list_agents, app_config, settings_store
- ):
- # Setup mocks
- mock_list_agents.return_value = ['default', 'test-agent']
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'new-model', # Custom model
- 'https://new-url', # Base URL
- 'new-api-key', # API key
- 'default', # Agent
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock user confirmations
- mock_confirm.side_effect = [
- 0, # Enable confirmation mode
- 0, # Enable memory condensation
- 0, # Save settings
- ]
-
- # Call the function
- await modify_llm_settings_advanced(app_config, settings_store)
-
- # Verify LLM config was updated
- app_config.set_llm_config.assert_called_once()
- args, kwargs = app_config.set_llm_config.call_args
- assert args[0].model == 'new-model'
- assert args[0].api_key.get_secret_value() == 'new-api-key'
- assert args[0].base_url == 'https://new-url'
-
- # Verify settings were saved
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.llm_model == 'new-model'
- assert settings.llm_api_key.get_secret_value() == 'new-api-key'
- assert settings.llm_base_url == 'https://new-url'
- assert settings.agent == 'default'
- assert settings.confirmation_mode is True
- assert settings.enable_default_condenser is True
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.Agent.list_agents')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.ConversationWindowCondenserConfig',
- MockConversationWindowCondenserConfig,
- )
- async def test_modify_llm_settings_advanced_user_cancels(
- self, mock_confirm, mock_session, mock_list_agents, app_config, settings_store
- ):
- # Setup mocks
- mock_list_agents.return_value = ['default', 'test-agent']
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(side_effect=UserCancelledError())
- mock_session.return_value = session_instance
-
- # Call the function
- await modify_llm_settings_advanced(app_config, settings_store)
-
- # Verify settings were not changed
- app_config.set_llm_config.assert_not_called()
- settings_store.store.assert_not_called()
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.Agent.list_agents')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch('openhands.cli.settings.print_formatted_text')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.ConversationWindowCondenserConfig',
- MockConversationWindowCondenserConfig,
- )
- async def test_modify_llm_settings_advanced_invalid_agent(
- self,
- mock_print,
- mock_confirm,
- mock_session,
- mock_list_agents,
- app_config,
- settings_store,
- ):
- # Setup mocks
- mock_list_agents.return_value = ['default', 'test-agent']
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'new-model', # Custom model
- 'https://new-url', # Base URL
- 'new-api-key', # API key
- 'invalid-agent', # Invalid agent
- 'default', # Valid agent on retry
- ]
- )
- mock_session.return_value = session_instance
-
- # Call the function
- await modify_llm_settings_advanced(app_config, settings_store)
-
- # Verify error message was shown
- assert (
- mock_print.call_count == 3
- ) # Called 3 times: empty line, error message, empty line
- error_message_call = mock_print.call_args_list[
- 1
- ] # The second call contains the error message
- args, kwargs = error_message_call
- assert isinstance(args[0], HTML)
- assert 'Invalid agent' in args[0].value
-
- # Verify settings were not changed
- app_config.set_llm_config.assert_not_called()
- settings_store.store.assert_not_called()
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.Agent.list_agents')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.ConversationWindowCondenserConfig',
- MockConversationWindowCondenserConfig,
- )
- async def test_modify_llm_settings_advanced_user_rejects_save(
- self, mock_confirm, mock_session, mock_list_agents, app_config, settings_store
- ):
- # Setup mocks
- mock_list_agents.return_value = ['default', 'test-agent']
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'new-model', # Custom model
- 'https://new-url', # Base URL
- 'new-api-key', # API key
- 'default', # Agent
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock user confirmations
- mock_confirm.side_effect = [
- 0, # Enable confirmation mode
- 0, # Enable memory condensation
- 1, # Reject saving settings
- ]
-
- # Call the function
- await modify_llm_settings_advanced(app_config, settings_store)
-
- # Verify settings were not changed
- app_config.set_llm_config.assert_not_called()
- settings_store.store.assert_not_called()
-
- @pytest.fixture
- def app_config_with_existing(self):
- config = MagicMock(spec=OpenHandsConfig)
- llm_config = MagicMock()
- llm_config.model = 'custom-existing-model'
- llm_config.api_key = SecretStr('existing-advanced-key')
- llm_config.base_url = 'https://existing-api.com'
- config.get_llm_config.return_value = llm_config
- config.set_llm_config = MagicMock()
- config.set_agent_config = MagicMock()
- config.default_agent = 'existing-agent'
- config.enable_default_condenser = False
-
- agent_config = MagicMock()
- config.get_agent_config.return_value = agent_config
-
- # Set up security as a separate mock
- security_mock = MagicMock()
- security_mock.confirmation_mode = False
- config.security = security_mock
-
- return config
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.Agent.list_agents')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.ConversationWindowCondenserConfig',
- MockConversationWindowCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.CondenserPipelineConfig', MockCondenserPipelineConfig
- )
- async def test_modify_llm_settings_advanced_keep_existing_values(
- self,
- mock_confirm,
- mock_session,
- mock_list_agents,
- app_config_with_existing,
- settings_store,
- ):
- """Test keeping all existing values in advanced settings by pressing Enter."""
- # Setup mocks
- mock_list_agents.return_value = ['default', 'existing-agent', 'test-agent']
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'custom-existing-model', # Keep existing model (simulating prefill behavior)
- 'https://existing-api.com', # Keep existing base URL (simulating prefill behavior)
- '', # Keep existing API key (press Enter)
- 'existing-agent', # Keep existing agent (simulating prefill behavior)
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock user confirmations
- mock_confirm.side_effect = [
- 1, # Disable confirmation mode (current is False)
- 1, # Disable memory condensation (current is False)
- 0, # Save settings
- ]
-
- await modify_llm_settings_advanced(app_config_with_existing, settings_store)
-
- # Verify all prompts were called with prefill=True and current values
- prompt_calls = session_instance.prompt_async.call_args_list
-
- # Check model prompt
- assert prompt_calls[0][1]['default'] == 'custom-existing-model'
-
- # Check base URL prompt
- assert prompt_calls[1][1]['default'] == 'https://existing-api.com'
-
- # Check API key prompt (should not prefill but show indicator)
- api_key_prompt = prompt_calls[2][0][0]
- assert 'exis***-key' in api_key_prompt
- assert 'ENTER to keep current' in api_key_prompt
- assert prompt_calls[2][1]['default'] == '' # Not prefilled for security
-
- # Check agent prompt
- assert prompt_calls[3][1]['default'] == 'existing-agent'
-
- # Verify initial selections for confirmation mode and condenser
- confirm_calls = mock_confirm.call_args_list
- assert confirm_calls[0][1]['initial_selection'] == 1 # Disable (current)
- assert confirm_calls[1][1]['initial_selection'] == 1 # Disable (current)
-
- # Verify settings were saved with existing values
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.llm_model == 'custom-existing-model'
- assert settings.llm_api_key.get_secret_value() == 'existing-advanced-key'
- assert settings.llm_base_url == 'https://existing-api.com'
- assert settings.agent == 'existing-agent'
- assert settings.confirmation_mode is False
- assert settings.enable_default_condenser is False
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.Agent.list_agents')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.ConversationWindowCondenserConfig',
- MockConversationWindowCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.CondenserPipelineConfig', MockCondenserPipelineConfig
- )
- async def test_modify_llm_settings_advanced_partial_change(
- self,
- mock_confirm,
- mock_session,
- mock_list_agents,
- app_config_with_existing,
- settings_store,
- ):
- """Test changing only some values in advanced settings while keeping others."""
- # Setup mocks
- mock_list_agents.return_value = ['default', 'existing-agent', 'test-agent']
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'new-custom-model', # Change model
- 'https://existing-api.com', # Keep existing base URL (simulating prefill behavior)
- 'new-api-key-123', # Change API key
- 'test-agent', # Change agent
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock user confirmations - change some settings
- mock_confirm.side_effect = [
- 0, # Enable confirmation mode (change from current False)
- 1, # Disable memory condensation (keep current False)
- 0, # Save settings
- ]
-
- await modify_llm_settings_advanced(app_config_with_existing, settings_store)
-
- # Verify settings were saved with mixed changes
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.llm_model == 'new-custom-model' # Changed
- assert settings.llm_api_key.get_secret_value() == 'new-api-key-123' # Changed
- assert settings.llm_base_url == 'https://existing-api.com' # Kept same
- assert settings.agent == 'test-agent' # Changed
- assert settings.confirmation_mode is True # Changed
- assert settings.enable_default_condenser is False # Kept same
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.Agent.list_agents')
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch(
- 'openhands.cli.settings.LLMSummarizingCondenserConfig',
- MockLLMSummarizingCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.ConversationWindowCondenserConfig',
- MockConversationWindowCondenserConfig,
- )
- @patch(
- 'openhands.cli.settings.CondenserPipelineConfig', MockCondenserPipelineConfig
- )
- async def test_modify_llm_settings_advanced_from_scratch(
- self, mock_confirm, mock_session, mock_list_agents, settings_store
- ):
- """Test setting up advanced configuration from scratch (no existing settings)."""
- # Create a fresh config with no existing settings
- config = MagicMock(spec=OpenHandsConfig)
- llm_config = MagicMock()
- llm_config.model = None # No existing model
- llm_config.api_key = None # No existing API key
- llm_config.base_url = None # No existing base URL
- config.get_llm_config.return_value = llm_config
- config.set_llm_config = MagicMock()
- config.set_agent_config = MagicMock()
- config.default_agent = None # No existing agent
- config.enable_default_condenser = True # Default value
-
- agent_config = MagicMock()
- config.get_agent_config.return_value = agent_config
-
- # Set up security as a separate mock
- security_mock = MagicMock()
- security_mock.confirmation_mode = True # Default value
- config.security = security_mock
-
- # Setup mocks
- mock_list_agents.return_value = ['default', 'test-agent', 'advanced-agent']
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(
- side_effect=[
- 'from-scratch-model', # New custom model
- 'https://new-api-endpoint.com', # New base URL
- 'brand-new-api-key', # New API key
- 'advanced-agent', # New agent
- ]
- )
- mock_session.return_value = session_instance
-
- # Mock user confirmations - set up from scratch
- mock_confirm.side_effect = [
- 1, # Disable confirmation mode (change from default True)
- 0, # Enable memory condensation (keep default True)
- 0, # Save settings
- ]
-
- await modify_llm_settings_advanced(config, settings_store)
-
- # Check that prompts don't show prefilled values since nothing exists
- prompt_calls = session_instance.prompt_async.call_args_list
-
- # Check model prompt - no prefill for empty model
- assert prompt_calls[0][1]['default'] == ''
-
- # Check base URL prompt - no prefill for empty base_url
- assert prompt_calls[1][1]['default'] == ''
-
- # Check API key prompt - should not show existing key indicator
- api_key_prompt = prompt_calls[2][0][0]
- assert '***' not in api_key_prompt
- assert 'ENTER to keep current' not in api_key_prompt
- assert prompt_calls[2][1]['default'] == '' # Not prefilled
-
- # Check agent prompt - no prefill for empty agent
- assert prompt_calls[3][1]['default'] == ''
-
- # Verify initial selections for confirmation mode and condenser
- confirm_calls = mock_confirm.call_args_list
- assert confirm_calls[0][1]['initial_selection'] == 0 # Enable (default)
- assert confirm_calls[1][1]['initial_selection'] == 0 # Enable (default)
-
- # Verify settings were saved with new values
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.llm_model == 'from-scratch-model'
- assert settings.llm_api_key.get_secret_value() == 'brand-new-api-key'
- assert settings.llm_base_url == 'https://new-api-endpoint.com'
- assert settings.agent == 'advanced-agent'
- assert settings.confirmation_mode is False # Changed from default
- assert settings.enable_default_condenser is True # Kept default
-
-
-class TestGetValidatedInput:
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.PromptSession')
- async def test_get_validated_input_with_prefill(self, mock_session):
- """Test get_validated_input with default_value prefilled."""
- from openhands.cli.settings import get_validated_input
-
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(return_value='modified-value')
-
- result = await get_validated_input(
- session_instance,
- 'Enter value: ',
- default_value='existing-value',
- )
-
- # Verify prompt was called with default parameter
- session_instance.prompt_async.assert_called_once_with(
- 'Enter value: ', default='existing-value'
- )
- assert result == 'modified-value'
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.PromptSession')
- async def test_get_validated_input_empty_returns_current(self, mock_session):
- """Test that pressing Enter with empty input returns enter_keeps_value."""
- from openhands.cli.settings import get_validated_input
-
- session_instance = MagicMock()
- # Simulate user pressing Enter (empty input)
- session_instance.prompt_async = AsyncMock(return_value=' ')
-
- result = await get_validated_input(
- session_instance,
- 'Enter value: ',
- default_value='',
- enter_keeps_value='existing-value',
- )
-
- # Verify prompt was called with empty default
- session_instance.prompt_async.assert_called_once_with(
- 'Enter value: ', default=''
- )
- # Empty input should return enter_keeps_value
- assert result == 'existing-value'
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.PromptSession')
- async def test_get_validated_input_with_validator(self, mock_session):
- """Test get_validated_input with validator and error message."""
- from openhands.cli.settings import get_validated_input
-
- session_instance = MagicMock()
- # First attempt fails validation, second succeeds
- session_instance.prompt_async = AsyncMock(
- side_effect=['invalid', 'valid-input']
- )
-
- # Mock print_formatted_text to verify error message
- with patch('openhands.cli.settings.print_formatted_text') as mock_print:
- result = await get_validated_input(
- session_instance,
- 'Enter value: ',
- validator=lambda x: x.startswith('valid'),
- error_message='Input must start with "valid"',
- )
-
- # Verify error message was shown
- assert mock_print.call_count == 3
- # The second call contains the error message
- error_message_call = mock_print.call_args_list[1]
- args, kwargs = error_message_call
- assert isinstance(args[0], HTML)
- assert 'Input must start with "valid"' in args[0].value
-
- assert result == 'valid-input'
-
-
-class TestModifySearchApiSettings:
- @pytest.fixture
- def app_config(self):
- config = MagicMock(spec=OpenHandsConfig)
- config.search_api_key = SecretStr('tvly-existing-key')
- return config
-
- @pytest.fixture
- def settings_store(self):
- store = MagicMock(spec=FileSettingsStore)
- store.load = AsyncMock(return_value=Settings())
- store.store = AsyncMock()
- return store
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch('openhands.cli.settings.print_formatted_text')
- async def test_modify_search_api_settings_set_new_key(
- self, mock_print, mock_confirm, mock_session, app_config, settings_store
- ):
- # Setup mocks
- session_instance = MagicMock()
- session_instance.prompt_async = AsyncMock(return_value='tvly-new-key')
- mock_session.return_value = session_instance
-
- # Mock user confirmations: Set/Update API Key, then Save
- mock_confirm.side_effect = [0, 0]
-
- # Call the function
- await modify_search_api_settings(app_config, settings_store)
-
- # Verify config was updated
- assert app_config.search_api_key.get_secret_value() == 'tvly-new-key'
-
- # Verify settings were saved
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.search_api_key.get_secret_value() == 'tvly-new-key'
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch('openhands.cli.settings.print_formatted_text')
- async def test_modify_search_api_settings_remove_key(
- self, mock_print, mock_confirm, mock_session, app_config, settings_store
- ):
- # Setup mocks
- session_instance = MagicMock()
- mock_session.return_value = session_instance
-
- # Mock user confirmations: Remove API Key, then Save
- mock_confirm.side_effect = [1, 0]
-
- # Call the function
- await modify_search_api_settings(app_config, settings_store)
-
- # Verify config was updated to None
- assert app_config.search_api_key is None
-
- # Verify settings were saved
- settings_store.store.assert_called_once()
- args, kwargs = settings_store.store.call_args
- settings = args[0]
- assert settings.search_api_key is None
-
- @pytest.mark.asyncio
- @patch('openhands.cli.settings.PromptSession')
- @patch('openhands.cli.settings.cli_confirm')
- @patch('openhands.cli.settings.print_formatted_text')
- async def test_modify_search_api_settings_keep_current(
- self, mock_print, mock_confirm, mock_session, app_config, settings_store
- ):
- # Setup mocks
- session_instance = MagicMock()
- mock_session.return_value = session_instance
-
- # Mock user confirmation: Keep current setting
- mock_confirm.return_value = 2
-
- # Call the function
- await modify_search_api_settings(app_config, settings_store)
-
- # Verify settings were not changed
- settings_store.store.assert_not_called()
diff --git a/tests/unit/cli/test_cli_setup_flow.py b/tests/unit/cli/test_cli_setup_flow.py
deleted file mode 100644
index c896467d3f..0000000000
--- a/tests/unit/cli/test_cli_setup_flow.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import asyncio
-import unittest
-from unittest.mock import AsyncMock, MagicMock, patch
-
-from openhands.cli.main import run_setup_flow
-from openhands.core.config import OpenHandsConfig
-from openhands.storage.settings.file_settings_store import FileSettingsStore
-
-
-class TestCLISetupFlow(unittest.TestCase):
- """Test the CLI setup flow."""
-
- @patch('openhands.cli.settings.modify_llm_settings_basic')
- @patch('openhands.cli.main.print_formatted_text')
- async def test_run_setup_flow(self, mock_print, mock_modify_settings):
- """Test that the setup flow calls the modify_llm_settings_basic function."""
- # Setup
- config = MagicMock(spec=OpenHandsConfig)
- settings_store = MagicMock(spec=FileSettingsStore)
- mock_modify_settings.return_value = None
-
- # Mock settings_store.load to return a settings object
- settings = MagicMock()
- settings_store.load = AsyncMock(return_value=settings)
-
- # Execute
- result = await run_setup_flow(config, settings_store)
-
- # Verify
- mock_modify_settings.assert_called_once_with(config, settings_store)
- # Verify that print_formatted_text was called at least twice (for welcome message and instructions)
- self.assertGreaterEqual(mock_print.call_count, 2)
- # Verify that the function returns True when settings are found
- self.assertTrue(result)
-
- @patch('openhands.cli.main.print_formatted_text')
- @patch('openhands.cli.main.run_setup_flow')
- @patch('openhands.cli.main.FileSettingsStore.get_instance')
- @patch('openhands.cli.main.setup_config_from_args')
- @patch('openhands.cli.main.parse_arguments')
- async def test_main_calls_setup_flow_when_no_settings(
- self,
- mock_parse_args,
- mock_setup_config,
- mock_get_instance,
- mock_run_setup_flow,
- mock_print,
- ):
- """Test that main calls run_setup_flow when no settings are found and exits."""
- # Setup
- mock_args = MagicMock()
- mock_config = MagicMock(spec=OpenHandsConfig)
- mock_settings_store = AsyncMock(spec=FileSettingsStore)
-
- # Settings load returns None (no settings)
- mock_settings_store.load = AsyncMock(return_value=None)
-
- mock_parse_args.return_value = mock_args
- mock_setup_config.return_value = mock_config
- mock_get_instance.return_value = mock_settings_store
-
- # Mock run_setup_flow to return True (settings configured successfully)
- mock_run_setup_flow.return_value = True
-
- # Import here to avoid circular imports during patching
- from openhands.cli.main import main
-
- # Execute
- loop = asyncio.get_event_loop()
- await main(loop)
-
- # Verify
- mock_run_setup_flow.assert_called_once_with(mock_config, mock_settings_store)
- # Verify that load was called once (before setup)
- self.assertEqual(mock_settings_store.load.call_count, 1)
- # Verify that print_formatted_text was called for success messages
- self.assertGreaterEqual(mock_print.call_count, 2)
-
-
-def run_async_test(coro):
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- try:
- return loop.run_until_complete(coro)
- finally:
- loop.close()
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/unit/cli/test_cli_suppress_warnings.py b/tests/unit/cli/test_cli_suppress_warnings.py
deleted file mode 100644
index 39ebc85922..0000000000
--- a/tests/unit/cli/test_cli_suppress_warnings.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""Test warning suppression functionality in CLI mode."""
-
-import warnings
-from io import StringIO
-from unittest.mock import patch
-
-from openhands.cli.suppress_warnings import suppress_cli_warnings
-
-
-class TestWarningSuppressionCLI:
- """Test cases for CLI warning suppression."""
-
- def test_suppress_pydantic_warnings(self):
- """Test that Pydantic serialization warnings are suppressed."""
- # Apply suppression
- suppress_cli_warnings()
-
- # Capture stderr to check if warnings are printed
- captured_output = StringIO()
- with patch('sys.stderr', captured_output):
- # Trigger Pydantic serialization warning
- warnings.warn(
- 'Pydantic serializer warnings: PydanticSerializationUnexpectedValue',
- UserWarning,
- stacklevel=2,
- )
-
- # Should be suppressed (no output to stderr)
- output = captured_output.getvalue()
- assert 'Pydantic serializer warnings' not in output
-
- def test_suppress_deprecated_method_warnings(self):
- """Test that deprecated method warnings are suppressed."""
- # Apply suppression
- suppress_cli_warnings()
-
- # Capture stderr to check if warnings are printed
- captured_output = StringIO()
- with patch('sys.stderr', captured_output):
- # Trigger deprecated method warning
- warnings.warn(
- 'Call to deprecated method get_events. (Use search_events instead)',
- DeprecationWarning,
- stacklevel=2,
- )
-
- # Should be suppressed (no output to stderr)
- output = captured_output.getvalue()
- assert 'deprecated method' not in output
-
- def test_suppress_expected_fields_warnings(self):
- """Test that expected fields warnings are suppressed."""
- # Apply suppression
- suppress_cli_warnings()
-
- # Capture stderr to check if warnings are printed
- captured_output = StringIO()
- with patch('sys.stderr', captured_output):
- # Trigger expected fields warning
- warnings.warn(
- 'Expected 9 fields but got 5: Expected `Message`',
- UserWarning,
- stacklevel=2,
- )
-
- # Should be suppressed (no output to stderr)
- output = captured_output.getvalue()
- assert 'Expected 9 fields' not in output
-
- def test_regular_warnings_not_suppressed(self):
- """Test that regular warnings are NOT suppressed."""
- # Apply suppression
- suppress_cli_warnings()
-
- # Capture stderr to check if warnings are printed
- captured_output = StringIO()
- with patch('sys.stderr', captured_output):
- # Trigger a regular warning that should NOT be suppressed
- warnings.warn(
- 'This is a regular warning that should appear',
- UserWarning,
- stacklevel=2,
- )
-
- # Should NOT be suppressed (should appear in stderr)
- output = captured_output.getvalue()
- assert 'regular warning' in output
-
- def test_module_import_applies_suppression(self):
- """Test that importing the module automatically applies suppression."""
- # Reset warnings filters
- warnings.resetwarnings()
-
- # Re-import the module to trigger suppression again
- import importlib
-
- import openhands.cli.suppress_warnings
-
- importlib.reload(openhands.cli.suppress_warnings)
-
- # Capture stderr to check if warnings are printed
- captured_output = StringIO()
- with patch('sys.stderr', captured_output):
- warnings.warn(
- 'Pydantic serializer warnings: test', UserWarning, stacklevel=2
- )
-
- # Should be suppressed (no output to stderr)
- output = captured_output.getvalue()
- assert 'Pydantic serializer warnings' not in output
-
- def test_warning_filters_are_applied(self):
- """Test that warning filters are properly applied."""
- # Reset warnings filters
- warnings.resetwarnings()
-
- # Apply suppression
- suppress_cli_warnings()
-
- # Check that filters are in place
- filters = warnings.filters
-
- # Should have filters for the specific warning patterns
- filter_messages = [f[1] for f in filters if f[1] is not None]
-
- # Check that our specific patterns are in the filters
- assert any(
- 'Pydantic serializer warnings' in str(msg) for msg in filter_messages
- )
- assert any('deprecated method' in str(msg) for msg in filter_messages)
diff --git a/tests/unit/cli/test_cli_thought_order.py b/tests/unit/cli/test_cli_thought_order.py
deleted file mode 100644
index 46d6506138..0000000000
--- a/tests/unit/cli/test_cli_thought_order.py
+++ /dev/null
@@ -1,246 +0,0 @@
-"""Tests for CLI thought display order fix.
-This ensures that agent thoughts are displayed before commands, not after.
-"""
-
-from unittest.mock import MagicMock, patch
-
-from openhands.cli.tui import display_event
-from openhands.core.config import OpenHandsConfig
-from openhands.events import EventSource
-from openhands.events.action import Action, ActionConfirmationStatus, CmdRunAction
-from openhands.events.action.message import MessageAction
-
-
-class TestThoughtDisplayOrder:
- """Test that thoughts are displayed in the correct order relative to commands."""
-
- @patch('openhands.cli.tui.display_thought_if_new')
- @patch('openhands.cli.tui.display_command')
- def test_cmd_run_action_thought_before_command(
- self, mock_display_command, mock_display_thought_if_new
- ):
- """Test that for CmdRunAction, thought is displayed before command."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a CmdRunAction with a thought awaiting confirmation
- cmd_action = CmdRunAction(
- command='npm install',
- thought='I need to install the dependencies first before running the tests.',
- )
- cmd_action.confirmation_state = ActionConfirmationStatus.AWAITING_CONFIRMATION
-
- display_event(cmd_action, config)
-
- # Verify that display_thought_if_new (for thought) was called before display_command
- mock_display_thought_if_new.assert_called_once_with(
- 'I need to install the dependencies first before running the tests.'
- )
- mock_display_command.assert_called_once_with(cmd_action)
-
- # Check the call order by examining the mock call history
- all_calls = []
- all_calls.extend(
- [
- ('display_thought_if_new', call)
- for call in mock_display_thought_if_new.call_args_list
- ]
- )
- all_calls.extend(
- [('display_command', call) for call in mock_display_command.call_args_list]
- )
-
- # Sort by the order they were called (this is a simplified check)
- # In practice, we know display_thought_if_new should be called first based on our code
- assert mock_display_thought_if_new.called
- assert mock_display_command.called
-
- @patch('openhands.cli.tui.display_thought_if_new')
- @patch('openhands.cli.tui.display_command')
- def test_cmd_run_action_no_thought(
- self, mock_display_command, mock_display_thought_if_new
- ):
- """Test that CmdRunAction without thought only displays command."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a CmdRunAction without a thought
- cmd_action = CmdRunAction(command='npm install')
- cmd_action.confirmation_state = ActionConfirmationStatus.AWAITING_CONFIRMATION
-
- display_event(cmd_action, config)
-
- # Verify that display_thought_if_new was not called (no thought)
- mock_display_thought_if_new.assert_not_called()
- mock_display_command.assert_called_once_with(cmd_action)
-
- @patch('openhands.cli.tui.display_thought_if_new')
- @patch('openhands.cli.tui.display_command')
- def test_cmd_run_action_empty_thought(
- self, mock_display_command, mock_display_thought_if_new
- ):
- """Test that CmdRunAction with empty thought only displays command."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a CmdRunAction with empty thought
- cmd_action = CmdRunAction(command='npm install', thought='')
- cmd_action.confirmation_state = ActionConfirmationStatus.AWAITING_CONFIRMATION
-
- display_event(cmd_action, config)
-
- # Verify that display_thought_if_new was not called (empty thought)
- mock_display_thought_if_new.assert_not_called()
- mock_display_command.assert_called_once_with(cmd_action)
-
- @patch('openhands.cli.tui.display_thought_if_new')
- @patch('openhands.cli.tui.display_command')
- @patch('openhands.cli.tui.initialize_streaming_output')
- def test_cmd_run_action_confirmed_no_display(
- self, mock_init_streaming, mock_display_command, mock_display_thought_if_new
- ):
- """Test that confirmed CmdRunAction doesn't display command again but initializes streaming."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a confirmed CmdRunAction with thought
- cmd_action = CmdRunAction(
- command='npm install',
- thought='I need to install the dependencies first before running the tests.',
- )
- cmd_action.confirmation_state = ActionConfirmationStatus.CONFIRMED
-
- display_event(cmd_action, config)
-
- # Verify that thought is still displayed
- mock_display_thought_if_new.assert_called_once_with(
- 'I need to install the dependencies first before running the tests.'
- )
- # But command should not be displayed again (already shown when awaiting confirmation)
- mock_display_command.assert_not_called()
- # Streaming should be initialized
- mock_init_streaming.assert_called_once()
-
- @patch('openhands.cli.tui.display_thought_if_new')
- def test_other_action_thought_display(self, mock_display_thought_if_new):
- """Test that other Action types still display thoughts normally."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a generic Action with thought
- action = Action()
- action.thought = 'This is a thought for a generic action.'
-
- display_event(action, config)
-
- # Verify that thought is displayed
- mock_display_thought_if_new.assert_called_once_with(
- 'This is a thought for a generic action.'
- )
-
- @patch('openhands.cli.tui.display_message')
- def test_other_action_final_thought_display(self, mock_display_message):
- """Test that other Action types display final thoughts as agent messages."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a generic Action with final thought
- action = Action()
- action.final_thought = 'This is a final thought.'
-
- display_event(action, config)
-
- # Verify that final thought is displayed as an agent message
- mock_display_message.assert_called_once_with(
- 'This is a final thought.', is_agent_message=True
- )
-
- @patch('openhands.cli.tui.display_thought_if_new')
- def test_message_action_from_agent(self, mock_display_thought_if_new):
- """Test that MessageAction from agent is displayed."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a MessageAction from agent
- message_action = MessageAction(content='Hello from agent')
- message_action._source = EventSource.AGENT
-
- display_event(message_action, config)
-
- # Verify that agent message is displayed with agent styling
- mock_display_thought_if_new.assert_called_once_with(
- 'Hello from agent', is_agent_message=True
- )
-
- @patch('openhands.cli.tui.display_thought_if_new')
- def test_message_action_from_user_not_displayed(self, mock_display_thought_if_new):
- """Test that MessageAction from user is not displayed."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a MessageAction from user
- message_action = MessageAction(content='Hello from user')
- message_action._source = EventSource.USER
-
- display_event(message_action, config)
-
- # Verify that message is not displayed (only agent messages are shown)
- mock_display_thought_if_new.assert_not_called()
-
- @patch('openhands.cli.tui.display_thought_if_new')
- @patch('openhands.cli.tui.display_command')
- def test_cmd_run_action_with_both_thoughts(
- self, mock_display_command, mock_display_thought_if_new
- ):
- """Test CmdRunAction with both thought and final_thought."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Create a CmdRunAction with both thoughts
- cmd_action = CmdRunAction(command='npm install', thought='Initial thought')
- cmd_action.final_thought = 'Final thought'
- cmd_action.confirmation_state = ActionConfirmationStatus.AWAITING_CONFIRMATION
-
- display_event(cmd_action, config)
-
- # For CmdRunAction, only the regular thought should be displayed
- # (final_thought is handled by the general Action case, but CmdRunAction is handled first)
- mock_display_thought_if_new.assert_called_once_with('Initial thought')
- mock_display_command.assert_called_once_with(cmd_action)
-
-
-class TestThoughtDisplayIntegration:
- """Integration tests for the thought display order fix."""
-
- def test_realistic_scenario_order(self):
- """Test a realistic scenario to ensure proper order."""
- config = MagicMock(spec=OpenHandsConfig)
-
- # Track the order of calls
- call_order = []
-
- def track_display_message(message, is_agent_message=False):
- call_order.append(f'THOUGHT: {message}')
-
- def track_display_command(event):
- call_order.append(f'COMMAND: {event.command}')
-
- with (
- patch(
- 'openhands.cli.tui.display_message', side_effect=track_display_message
- ),
- patch(
- 'openhands.cli.tui.display_command', side_effect=track_display_command
- ),
- ):
- # Create the scenario from the issue
- cmd_action = CmdRunAction(
- command='npm install',
- thought='I need to install the dependencies first before running the tests.',
- )
- cmd_action.confirmation_state = (
- ActionConfirmationStatus.AWAITING_CONFIRMATION
- )
-
- display_event(cmd_action, config)
-
- # Verify the correct order
- expected_order = [
- 'THOUGHT: I need to install the dependencies first before running the tests.',
- 'COMMAND: npm install',
- ]
-
- assert call_order == expected_order, (
- f'Expected {expected_order}, but got {call_order}'
- )
diff --git a/tests/unit/cli/test_cli_tui.py b/tests/unit/cli/test_cli_tui.py
deleted file mode 100644
index 86ab1e33ca..0000000000
--- a/tests/unit/cli/test_cli_tui.py
+++ /dev/null
@@ -1,513 +0,0 @@
-from unittest.mock import MagicMock, Mock, patch
-
-import pytest
-
-from openhands.cli.tui import (
- CustomDiffLexer,
- UsageMetrics,
- UserCancelledError,
- _render_basic_markdown,
- display_banner,
- display_command,
- display_event,
- display_mcp_action,
- display_mcp_errors,
- display_mcp_observation,
- display_message,
- display_runtime_initialization_message,
- display_shutdown_message,
- display_status,
- display_usage_metrics,
- display_welcome_message,
- get_session_duration,
- read_confirmation_input,
-)
-from openhands.core.config import OpenHandsConfig
-from openhands.events import EventSource
-from openhands.events.action import (
- Action,
- ActionConfirmationStatus,
- CmdRunAction,
- MCPAction,
- MessageAction,
-)
-from openhands.events.observation import (
- CmdOutputObservation,
- FileEditObservation,
- FileReadObservation,
- MCPObservation,
-)
-from openhands.llm.metrics import Metrics
-from openhands.mcp.error_collector import MCPError
-
-
-class TestDisplayFunctions:
- @patch('openhands.cli.tui.print_formatted_text')
- def test_display_runtime_initialization_message_local(self, mock_print):
- display_runtime_initialization_message('local')
- assert mock_print.call_count == 3
- # Check the second call has the local runtime message
- args, kwargs = mock_print.call_args_list[1]
- assert 'Starting local runtime' in str(args[0])
-
- @patch('openhands.cli.tui.print_formatted_text')
- def test_display_runtime_initialization_message_docker(self, mock_print):
- display_runtime_initialization_message('docker')
- assert mock_print.call_count == 3
- # Check the second call has the docker runtime message
- args, kwargs = mock_print.call_args_list[1]
- assert 'Starting Docker runtime' in str(args[0])
-
- @patch('openhands.cli.tui.print_formatted_text')
- def test_display_banner(self, mock_print):
- session_id = 'test-session-id'
-
- display_banner(session_id)
-
- # Verify banner calls
- assert mock_print.call_count >= 3
- # Check the last call has the session ID
- args, kwargs = mock_print.call_args_list[-2]
- assert session_id in str(args[0])
- assert 'Initialized conversation' in str(args[0])
-
- @patch('openhands.cli.tui.print_formatted_text')
- def test_display_welcome_message(self, mock_print):
- display_welcome_message()
- assert mock_print.call_count == 2
- # Check the first call contains the welcome message
- args, kwargs = mock_print.call_args_list[0]
- assert "Let's start building" in str(args[0])
-
- @patch('openhands.cli.tui.print_formatted_text')
- def test_display_welcome_message_with_message(self, mock_print):
- message = 'Test message'
- display_welcome_message(message)
- assert mock_print.call_count == 2
- # Check the first call contains the welcome message
- args, kwargs = mock_print.call_args_list[0]
- message_text = str(args[0])
- assert "Let's start building" in message_text
- # Check the second call contains the custom message
- args, kwargs = mock_print.call_args_list[1]
- message_text = str(args[0])
- assert 'Test message' in message_text
- assert 'Type /help for help' in message_text
-
- @patch('openhands.cli.tui.print_formatted_text')
- def test_display_welcome_message_without_message(self, mock_print):
- display_welcome_message()
- assert mock_print.call_count == 2
- # Check the first call contains the welcome message
- args, kwargs = mock_print.call_args_list[0]
- message_text = str(args[0])
- assert "Let's start building" in message_text
- # Check the second call contains the default message
- args, kwargs = mock_print.call_args_list[1]
- message_text = str(args[0])
- assert 'What do you want to build?' in message_text
- assert 'Type /help for help' in message_text
-
- def test_display_event_message_action(self):
- config = MagicMock(spec=OpenHandsConfig)
- message = MessageAction(content='Test message')
- message._source = EventSource.AGENT
-
- # Directly test the function without mocking
- display_event(message, config)
-
- @patch('openhands.cli.tui.display_command')
- def test_display_event_cmd_action(self, mock_display_command):
- config = MagicMock(spec=OpenHandsConfig)
- # Test that commands awaiting confirmation are displayed
- cmd_action = CmdRunAction(command='echo test')
- cmd_action.confirmation_state = ActionConfirmationStatus.AWAITING_CONFIRMATION
-
- display_event(cmd_action, config)
-
- mock_display_command.assert_called_once_with(cmd_action)
-
- @patch('openhands.cli.tui.display_command')
- @patch('openhands.cli.tui.initialize_streaming_output')
- def test_display_event_cmd_action_confirmed(
- self, mock_init_streaming, mock_display_command
- ):
- config = MagicMock(spec=OpenHandsConfig)
- # Test that confirmed commands don't display the command but do initialize streaming
- cmd_action = CmdRunAction(command='echo test')
- cmd_action.confirmation_state = ActionConfirmationStatus.CONFIRMED
-
- display_event(cmd_action, config)
-
- # Command should not be displayed (since it was already shown when awaiting confirmation)
- mock_display_command.assert_not_called()
- # But streaming should be initialized
- mock_init_streaming.assert_called_once()
-
- @patch('openhands.cli.tui.display_command_output')
- def test_display_event_cmd_output(self, mock_display_output):
- config = MagicMock(spec=OpenHandsConfig)
- cmd_output = CmdOutputObservation(content='Test output', command='echo test')
-
- display_event(cmd_output, config)
-
- mock_display_output.assert_called_once_with('Test output')
-
- @patch('openhands.cli.tui.display_file_edit')
- def test_display_event_file_edit_observation(self, mock_display_file_edit):
- config = MagicMock(spec=OpenHandsConfig)
- file_edit_obs = FileEditObservation(path='test.py', content="print('hello')")
-
- display_event(file_edit_obs, config)
-
- mock_display_file_edit.assert_called_once_with(file_edit_obs)
-
- @patch('openhands.cli.tui.display_file_read')
- def test_display_event_file_read(self, mock_display_file_read):
- config = MagicMock(spec=OpenHandsConfig)
- file_read = FileReadObservation(path='test.py', content="print('hello')")
-
- display_event(file_read, config)
-
- mock_display_file_read.assert_called_once_with(file_read)
-
- def test_display_event_thought(self):
- config = MagicMock(spec=OpenHandsConfig)
- action = Action()
- action.thought = 'Thinking about this...'
-
- # Directly test the function without mocking
- display_event(action, config)
-
- @patch('openhands.cli.tui.display_mcp_action')
- def test_display_event_mcp_action(self, mock_display_mcp_action):
- config = MagicMock(spec=OpenHandsConfig)
- mcp_action = MCPAction(name='test_tool', arguments={'param': 'value'})
-
- display_event(mcp_action, config)
-
- mock_display_mcp_action.assert_called_once_with(mcp_action)
-
- @patch('openhands.cli.tui.display_mcp_observation')
- def test_display_event_mcp_observation(self, mock_display_mcp_observation):
- config = MagicMock(spec=OpenHandsConfig)
- mcp_observation = MCPObservation(
- content='Tool result', name='test_tool', arguments={'param': 'value'}
- )
-
- display_event(mcp_observation, config)
-
- mock_display_mcp_observation.assert_called_once_with(mcp_observation)
-
- @patch('openhands.cli.tui.print_container')
- def test_display_mcp_action(self, mock_print_container):
- mcp_action = MCPAction(name='test_tool', arguments={'param': 'value'})
-
- display_mcp_action(mcp_action)
-
- mock_print_container.assert_called_once()
- container = mock_print_container.call_args[0][0]
- assert 'test_tool' in container.body.text
- assert 'param' in container.body.text
-
- @patch('openhands.cli.tui.print_container')
- def test_display_mcp_action_no_args(self, mock_print_container):
- mcp_action = MCPAction(name='test_tool')
-
- display_mcp_action(mcp_action)
-
- mock_print_container.assert_called_once()
- container = mock_print_container.call_args[0][0]
- assert 'test_tool' in container.body.text
- assert 'Arguments' not in container.body.text
-
- @patch('openhands.cli.tui.print_container')
- def test_display_mcp_observation(self, mock_print_container):
- mcp_observation = MCPObservation(
- content='Tool result', name='test_tool', arguments={'param': 'value'}
- )
-
- display_mcp_observation(mcp_observation)
-
- mock_print_container.assert_called_once()
- container = mock_print_container.call_args[0][0]
- assert 'test_tool' in container.body.text
- assert 'Tool result' in container.body.text
-
- @patch('openhands.cli.tui.print_container')
- def test_display_mcp_observation_no_content(self, mock_print_container):
- mcp_observation = MCPObservation(content='', name='test_tool')
-
- display_mcp_observation(mcp_observation)
-
- mock_print_container.assert_called_once()
- container = mock_print_container.call_args[0][0]
- assert 'No output' in container.body.text
-
- @patch('openhands.cli.tui.print_formatted_text')
- def test_display_message(self, mock_print):
- message = 'Test message'
- display_message(message)
-
- mock_print.assert_called()
- args, kwargs = mock_print.call_args
- assert message in str(args[0])
-
- @patch('openhands.cli.tui.print_container')
- def test_display_command_awaiting_confirmation(self, mock_print_container):
- cmd_action = CmdRunAction(command='echo test')
- cmd_action.confirmation_state = ActionConfirmationStatus.AWAITING_CONFIRMATION
-
- display_command(cmd_action)
-
- mock_print_container.assert_called_once()
- container = mock_print_container.call_args[0][0]
- assert 'echo test' in container.body.text
-
-
-class TestInteractiveCommandFunctions:
- @patch('openhands.cli.tui.print_container')
- def test_display_usage_metrics(self, mock_print_container):
- metrics = UsageMetrics()
- metrics.total_cost = 1.25
- metrics.total_input_tokens = 1000
- metrics.total_output_tokens = 2000
-
- display_usage_metrics(metrics)
-
- mock_print_container.assert_called_once()
-
- def test_get_session_duration(self):
- import time
-
- current_time = time.time()
- one_hour_ago = current_time - 3600
-
- # Test for a 1-hour session
- duration = get_session_duration(one_hour_ago)
- assert '1h' in duration
- assert '0m' in duration
- assert '0s' in duration
-
- @patch('openhands.cli.tui.print_formatted_text')
- @patch('openhands.cli.tui.get_session_duration')
- def test_display_shutdown_message(self, mock_get_duration, mock_print):
- mock_get_duration.return_value = '1 hour 5 minutes'
-
- metrics = UsageMetrics()
- metrics.total_cost = 1.25
- session_id = 'test-session-id'
-
- display_shutdown_message(metrics, session_id)
-
- assert mock_print.call_count >= 3 # At least 3 print calls
- assert mock_get_duration.call_count == 1
-
- @patch('openhands.cli.tui.display_usage_metrics')
- def test_display_status(self, mock_display_metrics):
- metrics = UsageMetrics()
- session_id = 'test-session-id'
-
- display_status(metrics, session_id)
-
- mock_display_metrics.assert_called_once_with(metrics)
-
-
-class TestCustomDiffLexer:
- def test_custom_diff_lexer_plus_line(self):
- lexer = CustomDiffLexer()
- document = Mock()
- document.lines = ['+added line']
-
- line_style = lexer.lex_document(document)(0)
-
- assert line_style[0][0] == 'ansigreen' # Green for added lines
- assert line_style[0][1] == '+added line'
-
- def test_custom_diff_lexer_minus_line(self):
- lexer = CustomDiffLexer()
- document = Mock()
- document.lines = ['-removed line']
-
- line_style = lexer.lex_document(document)(0)
-
- assert line_style[0][0] == 'ansired' # Red for removed lines
- assert line_style[0][1] == '-removed line'
-
- def test_custom_diff_lexer_metadata_line(self):
- lexer = CustomDiffLexer()
- document = Mock()
- document.lines = ['[Existing file]']
-
- line_style = lexer.lex_document(document)(0)
-
- assert line_style[0][0] == 'bold' # Bold for metadata lines
- assert line_style[0][1] == '[Existing file]'
-
- def test_custom_diff_lexer_normal_line(self):
- lexer = CustomDiffLexer()
- document = Mock()
- document.lines = ['normal line']
-
- line_style = lexer.lex_document(document)(0)
-
- assert line_style[0][0] == '' # Default style for other lines
- assert line_style[0][1] == 'normal line'
-
-
-class TestUsageMetrics:
- def test_usage_metrics_initialization(self):
- metrics = UsageMetrics()
-
- # Only test the attributes that are actually initialized
- assert isinstance(metrics.metrics, Metrics)
- assert metrics.session_init_time > 0 # Should have a valid timestamp
-
-
-class TestUserCancelledError:
- def test_user_cancelled_error(self):
- error = UserCancelledError()
- assert isinstance(error, Exception)
-
-
-class TestReadConfirmationInput:
- @pytest.mark.asyncio
- @patch('openhands.cli.tui.cli_confirm')
- async def test_read_confirmation_input_yes(self, mock_confirm):
- mock_confirm.return_value = 0 # user picked first menu item
-
- cfg = MagicMock() # <- no spec for simplicity
- cfg.cli = MagicMock(vi_mode=False)
-
- result = await read_confirmation_input(config=cfg, security_risk='LOW')
- assert result == 'yes'
-
- @pytest.mark.asyncio
- @patch('openhands.cli.tui.cli_confirm')
- async def test_read_confirmation_input_no(self, mock_confirm):
- mock_confirm.return_value = 1 # user picked second menu item
-
- cfg = MagicMock() # <- no spec for simplicity
- cfg.cli = MagicMock(vi_mode=False)
-
- result = await read_confirmation_input(config=cfg, security_risk='MEDIUM')
- assert result == 'no'
-
- @pytest.mark.asyncio
- @patch('openhands.cli.tui.cli_confirm')
- async def test_read_confirmation_input_smart(self, mock_confirm):
- mock_confirm.return_value = 2 # user picked third menu item
-
-
-class TestMarkdownRendering:
- def test_empty_string(self):
- assert _render_basic_markdown('') == ''
-
- def test_plain_text(self):
- assert _render_basic_markdown('hello world') == 'hello world'
-
- def test_bold(self):
- assert _render_basic_markdown('**bold**') == 'bold'
-
- def test_underline(self):
- assert _render_basic_markdown('__under__') == 'under'
-
- def test_combined(self):
- assert (
- _render_basic_markdown('mix **bold** and __under__ here')
- == 'mix bold and under here'
- )
-
- def test_html_is_escaped(self):
- assert _render_basic_markdown('') == (
- '<script>alert(1)</script>'
- )
-
- def test_bold_with_special_chars(self):
- assert _render_basic_markdown('**a < b & c > d**') == (
- 'a < b & c > d'
- )
-
-
-"""Tests for CLI TUI MCP functionality."""
-
-
-class TestMCPTUIDisplay:
- """Test MCP TUI display functions."""
-
- @patch('openhands.cli.tui.print_container')
- def test_display_mcp_action_with_arguments(self, mock_print_container):
- """Test displaying MCP action with arguments."""
- mcp_action = MCPAction(
- name='test_tool', arguments={'param1': 'value1', 'param2': 42}
- )
-
- display_mcp_action(mcp_action)
-
- mock_print_container.assert_called_once()
- container = mock_print_container.call_args[0][0]
- assert 'test_tool' in container.body.text
- assert 'param1' in container.body.text
- assert 'value1' in container.body.text
-
- @patch('openhands.cli.tui.print_container')
- def test_display_mcp_observation_with_content(self, mock_print_container):
- """Test displaying MCP observation with content."""
- mcp_observation = MCPObservation(
- content='Tool execution successful',
- name='test_tool',
- arguments={'param': 'value'},
- )
-
- display_mcp_observation(mcp_observation)
-
- mock_print_container.assert_called_once()
- container = mock_print_container.call_args[0][0]
- assert 'test_tool' in container.body.text
- assert 'Tool execution successful' in container.body.text
-
- @patch('openhands.cli.tui.print_formatted_text')
- @patch('openhands.cli.tui.mcp_error_collector')
- def test_display_mcp_errors_no_errors(self, mock_collector, mock_print):
- """Test displaying MCP errors when none exist."""
- mock_collector.get_errors.return_value = []
-
- display_mcp_errors()
-
- mock_print.assert_called_once()
- call_args = mock_print.call_args[0][0]
- assert 'No MCP errors detected' in str(call_args)
-
- @patch('openhands.cli.tui.print_container')
- @patch('openhands.cli.tui.print_formatted_text')
- @patch('openhands.cli.tui.mcp_error_collector')
- def test_display_mcp_errors_with_errors(
- self, mock_collector, mock_print, mock_print_container
- ):
- """Test displaying MCP errors when some exist."""
- # Create mock errors
- error1 = MCPError(
- timestamp=1234567890.0,
- server_name='test-server-1',
- server_type='stdio',
- error_message='Connection failed',
- exception_details='Socket timeout',
- )
- error2 = MCPError(
- timestamp=1234567891.0,
- server_name='test-server-2',
- server_type='sse',
- error_message='Server unreachable',
- )
-
- mock_collector.get_errors.return_value = [error1, error2]
-
- display_mcp_errors()
-
- # Should print error count header
- assert mock_print.call_count >= 1
- header_call = mock_print.call_args_list[0][0][0]
- assert '2 MCP error(s) detected' in str(header_call)
-
- # Should print containers for each error
- assert mock_print_container.call_count == 2
diff --git a/tests/unit/cli/test_cli_utils.py b/tests/unit/cli/test_cli_utils.py
deleted file mode 100644
index b34f351fb7..0000000000
--- a/tests/unit/cli/test_cli_utils.py
+++ /dev/null
@@ -1,473 +0,0 @@
-from pathlib import Path
-from unittest.mock import MagicMock, PropertyMock, mock_open, patch
-
-import toml
-
-from openhands.cli.tui import UsageMetrics
-from openhands.cli.utils import (
- add_local_config_trusted_dir,
- extract_model_and_provider,
- get_local_config_trusted_dirs,
- is_number,
- organize_models_and_providers,
- read_file,
- split_is_actually_version,
- update_usage_metrics,
- write_to_file,
-)
-from openhands.events.event import Event
-from openhands.llm.metrics import Metrics, TokenUsage
-
-
-class TestGetLocalConfigTrustedDirs:
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- def test_config_file_does_not_exist(self, mock_config_path):
- mock_config_path.exists.return_value = False
- result = get_local_config_trusted_dirs()
- assert result == []
- mock_config_path.exists.assert_called_once()
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch('builtins.open', new_callable=mock_open, read_data='invalid toml')
- @patch(
- 'openhands.cli.utils.toml.load',
- side_effect=toml.TomlDecodeError('error', 'doc', 0),
- )
- def test_config_file_invalid_toml(
- self, mock_toml_load, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = True
- result = get_local_config_trusted_dirs()
- assert result == []
- mock_config_path.exists.assert_called_once()
- mock_open_file.assert_called_once_with(mock_config_path, 'r')
- mock_toml_load.assert_called_once()
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch(
- 'builtins.open',
- new_callable=mock_open,
- read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/path/one']}}),
- )
- @patch('openhands.cli.utils.toml.load')
- def test_config_file_valid(self, mock_toml_load, mock_open_file, mock_config_path):
- mock_config_path.exists.return_value = True
- mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/path/one']}}
- result = get_local_config_trusted_dirs()
- assert result == ['/path/one']
- mock_config_path.exists.assert_called_once()
- mock_open_file.assert_called_once_with(mock_config_path, 'r')
- mock_toml_load.assert_called_once()
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch(
- 'builtins.open',
- new_callable=mock_open,
- read_data=toml.dumps({'other_section': {}}),
- )
- @patch('openhands.cli.utils.toml.load')
- def test_config_file_missing_sandbox(
- self, mock_toml_load, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = True
- mock_toml_load.return_value = {'other_section': {}}
- result = get_local_config_trusted_dirs()
- assert result == []
- mock_config_path.exists.assert_called_once()
- mock_open_file.assert_called_once_with(mock_config_path, 'r')
- mock_toml_load.assert_called_once()
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch(
- 'builtins.open',
- new_callable=mock_open,
- read_data=toml.dumps({'sandbox': {'other_key': []}}),
- )
- @patch('openhands.cli.utils.toml.load')
- def test_config_file_missing_trusted_dirs(
- self, mock_toml_load, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = True
- mock_toml_load.return_value = {'sandbox': {'other_key': []}}
- result = get_local_config_trusted_dirs()
- assert result == []
- mock_config_path.exists.assert_called_once()
- mock_open_file.assert_called_once_with(mock_config_path, 'r')
- mock_toml_load.assert_called_once()
-
-
-class TestAddLocalConfigTrustedDir:
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch('builtins.open', new_callable=mock_open)
- @patch('openhands.cli.utils.toml.dump')
- @patch('openhands.cli.utils.toml.load')
- def test_add_to_non_existent_file(
- self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = False
- mock_parent = MagicMock(spec=Path)
- mock_config_path.parent = mock_parent
-
- add_local_config_trusted_dir('/new/path')
-
- mock_config_path.exists.assert_called_once()
- mock_parent.mkdir.assert_called_once_with(parents=True, exist_ok=True)
- mock_open_file.assert_called_once_with(mock_config_path, 'w')
- expected_config = {'sandbox': {'trusted_dirs': ['/new/path']}}
- mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
- mock_toml_load.assert_not_called()
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch(
- 'builtins.open',
- new_callable=mock_open,
- read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/old/path']}}),
- )
- @patch('openhands.cli.utils.toml.dump')
- @patch('openhands.cli.utils.toml.load')
- def test_add_to_existing_file(
- self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = True
- mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/old/path']}}
-
- add_local_config_trusted_dir('/new/path')
-
- mock_config_path.exists.assert_called_once()
- assert mock_open_file.call_count == 2 # Once for read, once for write
- mock_open_file.assert_any_call(mock_config_path, 'r')
- mock_open_file.assert_any_call(mock_config_path, 'w')
- mock_toml_load.assert_called_once()
- expected_config = {'sandbox': {'trusted_dirs': ['/old/path', '/new/path']}}
- mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch(
- 'builtins.open',
- new_callable=mock_open,
- read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/old/path']}}),
- )
- @patch('openhands.cli.utils.toml.dump')
- @patch('openhands.cli.utils.toml.load')
- def test_add_existing_dir(
- self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = True
- mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/old/path']}}
-
- add_local_config_trusted_dir('/old/path')
-
- mock_config_path.exists.assert_called_once()
- mock_toml_load.assert_called_once()
- expected_config = {
- 'sandbox': {'trusted_dirs': ['/old/path']}
- } # Should not change
- mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch('builtins.open', new_callable=mock_open, read_data='invalid toml')
- @patch('openhands.cli.utils.toml.dump')
- @patch(
- 'openhands.cli.utils.toml.load',
- side_effect=toml.TomlDecodeError('error', 'doc', 0),
- )
- def test_add_to_invalid_toml(
- self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = True
-
- add_local_config_trusted_dir('/new/path')
-
- mock_config_path.exists.assert_called_once()
- mock_toml_load.assert_called_once()
- expected_config = {
- 'sandbox': {'trusted_dirs': ['/new/path']}
- } # Should reset to default + new path
- mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch(
- 'builtins.open',
- new_callable=mock_open,
- read_data=toml.dumps({'other_section': {}}),
- )
- @patch('openhands.cli.utils.toml.dump')
- @patch('openhands.cli.utils.toml.load')
- def test_add_to_missing_sandbox(
- self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = True
- mock_toml_load.return_value = {'other_section': {}}
-
- add_local_config_trusted_dir('/new/path')
-
- mock_config_path.exists.assert_called_once()
- mock_toml_load.assert_called_once()
- expected_config = {
- 'other_section': {},
- 'sandbox': {'trusted_dirs': ['/new/path']},
- }
- mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
-
- @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH')
- @patch(
- 'builtins.open',
- new_callable=mock_open,
- read_data=toml.dumps({'sandbox': {'other_key': []}}),
- )
- @patch('openhands.cli.utils.toml.dump')
- @patch('openhands.cli.utils.toml.load')
- def test_add_to_missing_trusted_dirs(
- self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path
- ):
- mock_config_path.exists.return_value = True
- mock_toml_load.return_value = {'sandbox': {'other_key': []}}
-
- add_local_config_trusted_dir('/new/path')
-
- mock_config_path.exists.assert_called_once()
- mock_toml_load.assert_called_once()
- expected_config = {'sandbox': {'other_key': [], 'trusted_dirs': ['/new/path']}}
- mock_toml_dump.assert_called_once_with(expected_config, mock_open_file())
-
-
-class TestUpdateUsageMetrics:
- def test_update_usage_metrics_no_llm_metrics(self):
- event = Event()
- usage_metrics = UsageMetrics()
-
- # Store original metrics object for comparison
- original_metrics = usage_metrics.metrics
-
- update_usage_metrics(event, usage_metrics)
-
- # Metrics should remain unchanged
- assert usage_metrics.metrics is original_metrics # Same object reference
- assert usage_metrics.metrics.accumulated_cost == 0.0 # Default value
-
- def test_update_usage_metrics_with_cost(self):
- event = Event()
- # Create a mock Metrics object
- metrics = MagicMock(spec=Metrics)
- # Mock the accumulated_cost property
- type(metrics).accumulated_cost = PropertyMock(return_value=1.25)
- event.llm_metrics = metrics
-
- usage_metrics = UsageMetrics()
-
- update_usage_metrics(event, usage_metrics)
-
- # Test that the metrics object was updated to the one from the event
- assert usage_metrics.metrics is metrics # Should be the same object reference
- # Test that we can access the accumulated_cost through the metrics property
- assert usage_metrics.metrics.accumulated_cost == 1.25
-
- def test_update_usage_metrics_with_tokens(self):
- event = Event()
-
- # Create mock token usage
- token_usage = MagicMock(spec=TokenUsage)
- token_usage.prompt_tokens = 100
- token_usage.completion_tokens = 50
- token_usage.cache_read_tokens = 20
- token_usage.cache_write_tokens = 30
-
- # Create mock metrics
- metrics = MagicMock(spec=Metrics)
- # Set the mock properties
- type(metrics).accumulated_cost = PropertyMock(return_value=1.5)
- type(metrics).accumulated_token_usage = PropertyMock(return_value=token_usage)
-
- event.llm_metrics = metrics
-
- usage_metrics = UsageMetrics()
-
- update_usage_metrics(event, usage_metrics)
-
- # Test that the metrics object was updated to the one from the event
- assert usage_metrics.metrics is metrics # Should be the same object reference
-
- # Test we can access metrics values through the metrics property
- assert usage_metrics.metrics.accumulated_cost == 1.5
- assert usage_metrics.metrics.accumulated_token_usage is token_usage
- assert usage_metrics.metrics.accumulated_token_usage.prompt_tokens == 100
- assert usage_metrics.metrics.accumulated_token_usage.completion_tokens == 50
- assert usage_metrics.metrics.accumulated_token_usage.cache_read_tokens == 20
- assert usage_metrics.metrics.accumulated_token_usage.cache_write_tokens == 30
-
- def test_update_usage_metrics_with_invalid_types(self):
- event = Event()
-
- # Create mock token usage with invalid types
- token_usage = MagicMock(spec=TokenUsage)
- token_usage.prompt_tokens = 'not an int'
- token_usage.completion_tokens = 'not an int'
- token_usage.cache_read_tokens = 'not an int'
- token_usage.cache_write_tokens = 'not an int'
-
- # Create mock metrics
- metrics = MagicMock(spec=Metrics)
- # Set the mock properties
- type(metrics).accumulated_cost = PropertyMock(return_value='not a float')
- type(metrics).accumulated_token_usage = PropertyMock(return_value=token_usage)
-
- event.llm_metrics = metrics
-
- usage_metrics = UsageMetrics()
-
- update_usage_metrics(event, usage_metrics)
-
- # Test that the metrics object was still updated to the one from the event
- # Even though the values are invalid types, the metrics object reference should be updated
- assert usage_metrics.metrics is metrics # Should be the same object reference
-
- # We can verify that we can access the properties through the metrics object
- # The invalid types are preserved since our update_usage_metrics function
- # simply assigns the metrics object without validation
- assert usage_metrics.metrics.accumulated_cost == 'not a float'
- assert usage_metrics.metrics.accumulated_token_usage is token_usage
-
-
-class TestModelAndProviderFunctions:
- def test_extract_model_and_provider_slash_format(self):
- model = 'openai/gpt-4o'
- result = extract_model_and_provider(model)
-
- assert result['provider'] == 'openai'
- assert result['model'] == 'gpt-4o'
- assert result['separator'] == '/'
-
- def test_extract_model_and_provider_dot_format(self):
- model = 'anthropic.claude-3-7'
- result = extract_model_and_provider(model)
-
- assert result['provider'] == 'anthropic'
- assert result['model'] == 'claude-3-7'
- assert result['separator'] == '.'
-
- def test_extract_model_and_provider_openai_implicit(self):
- model = 'gpt-4o'
- result = extract_model_and_provider(model)
-
- assert result['provider'] == 'openai'
- assert result['model'] == 'gpt-4o'
- assert result['separator'] == '/'
-
- def test_extract_model_and_provider_anthropic_implicit(self):
- model = 'claude-sonnet-4-20250514'
- result = extract_model_and_provider(model)
-
- assert result['provider'] == 'anthropic'
- assert result['model'] == 'claude-sonnet-4-20250514'
- assert result['separator'] == '/'
-
- def test_extract_model_and_provider_mistral_implicit(self):
- model = 'devstral-small-2505'
- result = extract_model_and_provider(model)
-
- assert result['provider'] == 'mistral'
- assert result['model'] == 'devstral-small-2505'
- assert result['separator'] == '/'
-
- def test_extract_model_and_provider_o4_mini(self):
- model = 'o4-mini'
- result = extract_model_and_provider(model)
-
- assert result['provider'] == 'openai'
- assert result['model'] == 'o4-mini'
- assert result['separator'] == '/'
-
- def test_extract_model_and_provider_versioned(self):
- model = 'deepseek.deepseek-coder-1.3b'
- result = extract_model_and_provider(model)
-
- assert result['provider'] == 'deepseek'
- assert result['model'] == 'deepseek-coder-1.3b'
- assert result['separator'] == '.'
-
- def test_extract_model_and_provider_unknown(self):
- model = 'unknown-model'
- result = extract_model_and_provider(model)
-
- assert result['provider'] == ''
- assert result['model'] == 'unknown-model'
- assert result['separator'] == ''
-
- def test_organize_models_and_providers(self):
- models = [
- 'openai/gpt-4o',
- 'anthropic/claude-sonnet-4-20250514',
- 'o3',
- 'o4-mini',
- 'devstral-small-2505',
- 'mistral/devstral-small-2505',
- 'anthropic.claude-3-5', # Should be ignored as it uses dot separator for anthropic
- 'unknown-model',
- ]
-
- result = organize_models_and_providers(models)
-
- assert 'openai' in result
- assert 'anthropic' in result
- assert 'mistral' in result
- assert 'other' in result
-
- assert len(result['openai']['models']) == 3
- assert 'gpt-4o' in result['openai']['models']
- assert 'o3' in result['openai']['models']
- assert 'o4-mini' in result['openai']['models']
-
- assert len(result['anthropic']['models']) == 1
- assert 'claude-sonnet-4-20250514' in result['anthropic']['models']
-
- assert len(result['mistral']['models']) == 2
- assert 'devstral-small-2505' in result['mistral']['models']
-
- assert len(result['other']['models']) == 1
- assert 'unknown-model' in result['other']['models']
-
-
-class TestUtilityFunctions:
- def test_is_number_with_digit(self):
- assert is_number('1') is True
- assert is_number('9') is True
-
- def test_is_number_with_letter(self):
- assert is_number('a') is False
- assert is_number('Z') is False
-
- def test_is_number_with_special_char(self):
- assert is_number('.') is False
- assert is_number('-') is False
-
- def test_split_is_actually_version_true(self):
- split = ['model', '1.0']
- assert split_is_actually_version(split) is True
-
- def test_split_is_actually_version_false(self):
- split = ['model', 'version']
- assert split_is_actually_version(split) is False
-
- def test_split_is_actually_version_single_item(self):
- split = ['model']
- assert split_is_actually_version(split) is False
-
-
-class TestFileOperations:
- def test_read_file(self):
- mock_content = 'test file content'
- with patch('builtins.open', mock_open(read_data=mock_content)):
- result = read_file('test.txt')
-
- assert result == mock_content
-
- def test_write_to_file(self):
- mock_content = 'test file content'
- mock_file = mock_open()
-
- with patch('builtins.open', mock_file):
- write_to_file('test.txt', mock_content)
-
- mock_file.assert_called_once_with('test.txt', 'w')
- handle = mock_file()
- handle.write.assert_called_once_with(mock_content)
diff --git a/tests/unit/cli/test_cli_vi_mode.py b/tests/unit/cli/test_cli_vi_mode.py
deleted file mode 100644
index fbf2b7c150..0000000000
--- a/tests/unit/cli/test_cli_vi_mode.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import os
-from unittest.mock import ANY, MagicMock, patch
-
-from openhands.core.config import CLIConfig, OpenHandsConfig
-
-
-class TestCliViMode:
- """Test the VI mode feature."""
-
- @patch('openhands.cli.tui.PromptSession')
- def test_create_prompt_session_vi_mode_enabled(self, mock_prompt_session):
- """Test that vi_mode can be enabled."""
- from openhands.cli.tui import create_prompt_session
-
- config = OpenHandsConfig(cli=CLIConfig(vi_mode=True))
- create_prompt_session(config)
- mock_prompt_session.assert_called_with(
- style=ANY,
- vi_mode=True,
- )
-
- @patch('openhands.cli.tui.PromptSession')
- def test_create_prompt_session_vi_mode_disabled(self, mock_prompt_session):
- """Test that vi_mode is disabled by default."""
- from openhands.cli.tui import create_prompt_session
-
- config = OpenHandsConfig(cli=CLIConfig(vi_mode=False))
- create_prompt_session(config)
- mock_prompt_session.assert_called_with(
- style=ANY,
- vi_mode=False,
- )
-
- @patch('openhands.cli.tui.Application')
- def test_cli_confirm_vi_keybindings_are_added(self, mock_app_class):
- """Test that vi keybindings are added to the KeyBindings object."""
- from openhands.cli.tui import cli_confirm
-
- config = OpenHandsConfig(cli=CLIConfig(vi_mode=True))
- with patch('openhands.cli.tui.KeyBindings', MagicMock()) as mock_key_bindings:
- cli_confirm(
- config, 'Test question', choices=['Choice 1', 'Choice 2', 'Choice 3']
- )
- # here we are checking if the key bindings are being created
- assert mock_key_bindings.call_count == 1
-
- # then we check that the key bindings are being added
- mock_kb_instance = mock_key_bindings.return_value
- assert mock_kb_instance.add.call_count > 0
-
- @patch('openhands.cli.tui.Application')
- def test_cli_confirm_vi_keybindings_are_not_added(self, mock_app_class):
- """Test that vi keybindings are not added when vi_mode is False."""
- from openhands.cli.tui import cli_confirm
-
- config = OpenHandsConfig(cli=CLIConfig(vi_mode=False))
- with patch('openhands.cli.tui.KeyBindings', MagicMock()) as mock_key_bindings:
- cli_confirm(
- config, 'Test question', choices=['Choice 1', 'Choice 2', 'Choice 3']
- )
- # here we are checking if the key bindings are being created
- assert mock_key_bindings.call_count == 1
-
- # then we check that the key bindings are being added
- mock_kb_instance = mock_key_bindings.return_value
-
- # and here we check that the vi key bindings are not being added
- for call in mock_kb_instance.add.call_args_list:
- assert call[0][0] not in ('j', 'k')
-
- @patch.dict(os.environ, {}, clear=True)
- def test_vi_mode_disabled_by_default(self):
- """Test that vi_mode is disabled by default when no env var is set."""
- from openhands.core.config.utils import load_from_env
-
- config = OpenHandsConfig()
- load_from_env(config, os.environ)
- assert config.cli.vi_mode is False, 'vi_mode should be False by default'
-
- @patch.dict(os.environ, {'CLI_VI_MODE': 'True'})
- def test_vi_mode_enabled_from_env(self):
- """Test that vi_mode can be enabled from an environment variable."""
- from openhands.core.config.utils import load_from_env
-
- config = OpenHandsConfig()
- load_from_env(config, os.environ)
- assert config.cli.vi_mode is True, (
- 'vi_mode should be True when CLI_VI_MODE is set'
- )
diff --git a/tests/unit/cli/test_cli_workspace.py b/tests/unit/cli/test_cli_workspace.py
deleted file mode 100644
index 1a0deed394..0000000000
--- a/tests/unit/cli/test_cli_workspace.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""Test CLIRuntime class."""
-
-import os
-import tempfile
-
-import pytest
-
-from openhands.core.config import OpenHandsConfig
-from openhands.events import EventStream
-
-# Mock LLMRegistry
-from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
-from openhands.storage import get_file_store
-
-
-# Create a mock LLMRegistry class
-class MockLLMRegistry:
- def __init__(self, config):
- self.config = config
-
-
-@pytest.fixture
-def temp_dir():
- """Create a temporary directory for testing."""
- with tempfile.TemporaryDirectory() as temp_dir:
- yield temp_dir
-
-
-@pytest.fixture
-def cli_runtime(temp_dir):
- """Create a CLIRuntime instance for testing."""
- file_store = get_file_store('local', temp_dir)
- event_stream = EventStream('test', file_store)
- config = OpenHandsConfig()
- config.workspace_base = temp_dir
- llm_registry = MockLLMRegistry(config)
- runtime = CLIRuntime(config, event_stream, llm_registry)
- runtime._runtime_initialized = True # Skip initialization
- return runtime
-
-
-def test_sanitize_filename_valid_path(cli_runtime):
- """Test _sanitize_filename with a valid path."""
- test_path = os.path.join(cli_runtime._workspace_path, 'test.txt')
- sanitized_path = cli_runtime._sanitize_filename(test_path)
- assert sanitized_path == os.path.realpath(test_path)
-
-
-def test_sanitize_filename_relative_path(cli_runtime):
- """Test _sanitize_filename with a relative path."""
- test_path = 'test.txt'
- expected_path = os.path.join(cli_runtime._workspace_path, test_path)
- sanitized_path = cli_runtime._sanitize_filename(test_path)
- assert sanitized_path == os.path.realpath(expected_path)
-
-
-def test_sanitize_filename_outside_workspace(cli_runtime):
- """Test _sanitize_filename with a path outside the workspace."""
- test_path = '/tmp/test.txt' # Path outside workspace
- with pytest.raises(PermissionError) as exc_info:
- cli_runtime._sanitize_filename(test_path)
- assert 'Invalid path:' in str(exc_info.value)
- assert 'You can only work with files in' in str(exc_info.value)
-
-
-def test_sanitize_filename_path_traversal(cli_runtime):
- """Test _sanitize_filename with path traversal attempt."""
- test_path = os.path.join(cli_runtime._workspace_path, '..', 'test.txt')
- with pytest.raises(PermissionError) as exc_info:
- cli_runtime._sanitize_filename(test_path)
- assert 'Invalid path traversal:' in str(exc_info.value)
- assert 'Path resolves outside the workspace' in str(exc_info.value)
-
-
-def test_sanitize_filename_absolute_path_with_dots(cli_runtime):
- """Test _sanitize_filename with absolute path containing dots."""
- test_path = os.path.join(cli_runtime._workspace_path, 'subdir', '..', 'test.txt')
- # Create the parent directory
- os.makedirs(os.path.join(cli_runtime._workspace_path, 'subdir'), exist_ok=True)
- sanitized_path = cli_runtime._sanitize_filename(test_path)
- assert sanitized_path == os.path.join(cli_runtime._workspace_path, 'test.txt')
-
-
-def test_sanitize_filename_nested_path(cli_runtime):
- """Test _sanitize_filename with a nested path."""
- nested_dir = os.path.join(cli_runtime._workspace_path, 'dir1', 'dir2')
- os.makedirs(nested_dir, exist_ok=True)
- test_path = os.path.join(nested_dir, 'test.txt')
- sanitized_path = cli_runtime._sanitize_filename(test_path)
- assert sanitized_path == os.path.realpath(test_path)
diff --git a/tests/unit/cli/test_vscode_extension.py b/tests/unit/cli/test_vscode_extension.py
deleted file mode 100644
index db80e444b1..0000000000
--- a/tests/unit/cli/test_vscode_extension.py
+++ /dev/null
@@ -1,858 +0,0 @@
-import os
-import pathlib
-import subprocess
-from unittest import mock
-
-import pytest
-
-from openhands.cli import vscode_extension
-
-
-@pytest.fixture
-def mock_env_and_dependencies():
- """A fixture to mock all external dependencies and manage the environment."""
- with (
- mock.patch.dict(os.environ, {}, clear=True),
- mock.patch('pathlib.Path.home') as mock_home,
- mock.patch('pathlib.Path.exists') as mock_exists,
- mock.patch('pathlib.Path.touch') as mock_touch,
- mock.patch('pathlib.Path.mkdir') as mock_mkdir,
- mock.patch('subprocess.run') as mock_subprocess,
- mock.patch('importlib.resources.as_file') as mock_as_file,
- mock.patch(
- 'openhands.cli.vscode_extension.download_latest_vsix_from_github'
- ) as mock_download,
- mock.patch('builtins.print') as mock_print,
- mock.patch('openhands.cli.vscode_extension.logger.debug') as mock_logger,
- ):
- # Setup a temporary directory for home
- temp_dir = pathlib.Path.cwd() / 'temp_test_home'
- temp_dir.mkdir(exist_ok=True)
- mock_home.return_value = temp_dir
-
- try:
- yield {
- 'home': mock_home,
- 'exists': mock_exists,
- 'touch': mock_touch,
- 'mkdir': mock_mkdir,
- 'subprocess': mock_subprocess,
- 'as_file': mock_as_file,
- 'download': mock_download,
- 'print': mock_print,
- 'logger': mock_logger,
- }
- finally:
- # Teardown the temporary directory, ignoring errors if files don't exist
- openhands_dir = temp_dir / '.openhands'
- if openhands_dir.exists():
- for f in openhands_dir.glob('*'):
- if f.is_file():
- f.unlink()
- try:
- openhands_dir.rmdir()
- except FileNotFoundError:
- pass
- try:
- temp_dir.rmdir()
- except (FileNotFoundError, OSError):
- pass
-
-
-def test_not_in_vscode_environment(mock_env_and_dependencies):
- """Should not attempt any installation if not in a VSCode-like environment."""
- os.environ['TERM_PROGRAM'] = 'not_vscode'
- vscode_extension.attempt_vscode_extension_install()
- mock_env_and_dependencies['download'].assert_not_called()
- mock_env_and_dependencies['subprocess'].assert_not_called()
-
-
-def test_already_attempted_flag_prevents_execution(mock_env_and_dependencies):
- """Should do nothing if the installation flag file already exists."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = True # Simulate flag file exists
- vscode_extension.attempt_vscode_extension_install()
- mock_env_and_dependencies['download'].assert_not_called()
- mock_env_and_dependencies['subprocess'].assert_not_called()
-
-
-def test_extension_already_installed_detected(mock_env_and_dependencies):
- """Should detect already installed extension and create flag."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
-
- # Mock subprocess call for --list-extensions (returns extension as installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0,
- args=[],
- stdout='openhands.openhands-vscode\nother.extension',
- stderr='',
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should only call --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['subprocess'].assert_called_with(
- ['code', '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: OpenHands VS Code extension is already installed.'
- )
- mock_env_and_dependencies['touch'].assert_called_once()
- mock_env_and_dependencies['download'].assert_not_called()
-
-
-def test_extension_detection_in_middle_of_list(mock_env_and_dependencies):
- """Should detect extension even when it's not the first in the list."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
-
- # Extension is in the middle of the list
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0,
- args=[],
- stdout='first.extension\nopenhands.openhands-vscode\nlast.extension',
- stderr='',
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: OpenHands VS Code extension is already installed.'
- )
- mock_env_and_dependencies['touch'].assert_called_once()
-
-
-def test_extension_detection_partial_match_ignored(mock_env_and_dependencies):
- """Should not match partial extension IDs."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
-
- # Partial match should not trigger detection
- mock_env_and_dependencies['subprocess'].side_effect = [
- subprocess.CompletedProcess(
- returncode=0,
- args=[],
- stdout='other.openhands-vscode-fork\nsome.extension',
- stderr='',
- ),
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # Bundled install succeeds
- ]
-
- # Mock bundled VSIX to succeed
- mock_vsix_path = mock.MagicMock()
- mock_vsix_path.exists.return_value = True
- mock_vsix_path.__str__.return_value = '/fake/path/to/bundled.vsix'
- mock_env_and_dependencies[
- 'as_file'
- ].return_value.__enter__.return_value = mock_vsix_path
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should proceed with installation since exact match not found
- assert mock_env_and_dependencies['subprocess'].call_count == 2
- mock_env_and_dependencies['as_file'].assert_called_once()
- # GitHub download should not be attempted since bundled install succeeds
- mock_env_and_dependencies['download'].assert_not_called()
-
-
-def test_list_extensions_fails_continues_installation(mock_env_and_dependencies):
- """Should continue with installation if --list-extensions fails."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
-
- # --list-extensions fails, but bundled install succeeds
- mock_env_and_dependencies['subprocess'].side_effect = [
- subprocess.CompletedProcess(
- returncode=1, args=[], stdout='', stderr='Command failed'
- ),
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # Bundled install succeeds
- ]
-
- # Mock bundled VSIX to succeed
- mock_vsix_path = mock.MagicMock()
- mock_vsix_path.exists.return_value = True
- mock_vsix_path.__str__.return_value = '/fake/path/to/bundled.vsix'
- mock_env_and_dependencies[
- 'as_file'
- ].return_value.__enter__.return_value = mock_vsix_path
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should proceed with installation
- assert mock_env_and_dependencies['subprocess'].call_count == 2
- mock_env_and_dependencies['as_file'].assert_called_once()
- # GitHub download should not be attempted since bundled install succeeds
- mock_env_and_dependencies['download'].assert_not_called()
-
-
-def test_list_extensions_exception_continues_installation(mock_env_and_dependencies):
- """Should continue with installation if --list-extensions throws exception."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
-
- # --list-extensions throws exception, but bundled install succeeds
- mock_env_and_dependencies['subprocess'].side_effect = [
- FileNotFoundError('code command not found'),
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # Bundled install succeeds
- ]
-
- # Mock bundled VSIX to succeed
- mock_vsix_path = mock.MagicMock()
- mock_vsix_path.exists.return_value = True
- mock_vsix_path.__str__.return_value = '/fake/path/to/bundled.vsix'
- mock_env_and_dependencies[
- 'as_file'
- ].return_value.__enter__.return_value = mock_vsix_path
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should proceed with installation
- assert mock_env_and_dependencies['subprocess'].call_count == 2
- mock_env_and_dependencies['as_file'].assert_called_once()
- # GitHub download should not be attempted since bundled install succeeds
- mock_env_and_dependencies['download'].assert_not_called()
-
-
-def test_mark_installation_successful_os_error(mock_env_and_dependencies):
- """Should log error but continue if flag file creation fails."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
-
- # Mock bundled VSIX to succeed
- mock_vsix_path = mock.MagicMock()
- mock_vsix_path.exists.return_value = True
- mock_vsix_path.__str__.return_value = '/fake/path/to/bundled.vsix'
- mock_env_and_dependencies[
- 'as_file'
- ].return_value.__enter__.return_value = mock_vsix_path
-
- mock_env_and_dependencies['subprocess'].side_effect = [
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # --list-extensions (empty)
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # Bundled install succeeds
- ]
- mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should still complete installation
- mock_env_and_dependencies['as_file'].assert_called_once()
- # GitHub download should not be attempted since bundled install succeeds
- mock_env_and_dependencies['download'].assert_not_called()
- mock_env_and_dependencies['touch'].assert_called_once()
- # Should log the error
- mock_env_and_dependencies['logger'].assert_any_call(
- 'Could not create VS Code extension success flag file: Permission denied'
- )
-
-
-def test_installation_failure_no_flag_created(mock_env_and_dependencies):
- """Should NOT create flag when all installation methods fail (allow retry)."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0,
- args=[],
- stdout='',
- stderr='', # --list-extensions (empty)
- )
- mock_env_and_dependencies['download'].return_value = None # GitHub fails
- mock_env_and_dependencies[
- 'as_file'
- ].side_effect = FileNotFoundError # Bundled fails
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should NOT create flag file - this is the key behavior change
- mock_env_and_dependencies['touch'].assert_not_called()
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Will retry installation next time you run OpenHands in VS Code.'
- )
-
-
-def test_install_succeeds_from_bundled(mock_env_and_dependencies):
- """Should successfully install from bundled VSIX on the first try."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
-
- mock_vsix_path = mock.MagicMock()
- mock_vsix_path.exists.return_value = True
- mock_vsix_path.__str__.return_value = '/fake/path/to/bundled.vsix'
- mock_env_and_dependencies[
- 'as_file'
- ].return_value.__enter__.return_value = mock_vsix_path
-
- # Mock subprocess calls: first --list-extensions (returns empty), then install
- mock_env_and_dependencies['subprocess'].side_effect = [
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # --list-extensions
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # --install-extension
- ]
-
- vscode_extension.attempt_vscode_extension_install()
-
- mock_env_and_dependencies['as_file'].assert_called_once()
- # Should have two subprocess calls: list-extensions and install-extension
- assert mock_env_and_dependencies['subprocess'].call_count == 2
- mock_env_and_dependencies['subprocess'].assert_any_call(
- ['code', '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['subprocess'].assert_any_call(
- ['code', '--install-extension', '/fake/path/to/bundled.vsix', '--force'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Bundled VS Code extension installed successfully.'
- )
- mock_env_and_dependencies['touch'].assert_called_once()
- # GitHub download should not be attempted
- mock_env_and_dependencies['download'].assert_not_called()
-
-
-def test_bundled_fails_falls_back_to_github(mock_env_and_dependencies):
- """Should fall back to GitHub if bundled VSIX installation fails."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = '/fake/path/to/github.vsix'
-
- # Mock bundled VSIX to fail
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess calls: first --list-extensions (returns empty), then install
- mock_env_and_dependencies['subprocess'].side_effect = [
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # --list-extensions
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # --install-extension
- ]
-
- with (
- mock.patch('os.remove') as mock_os_remove,
- mock.patch('os.path.exists', return_value=True),
- ):
- vscode_extension.attempt_vscode_extension_install()
-
- mock_env_and_dependencies['as_file'].assert_called_once()
- mock_env_and_dependencies['download'].assert_called_once()
- # Should have two subprocess calls: list-extensions and install-extension
- assert mock_env_and_dependencies['subprocess'].call_count == 2
- mock_env_and_dependencies['subprocess'].assert_any_call(
- ['code', '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['subprocess'].assert_any_call(
- ['code', '--install-extension', '/fake/path/to/github.vsix', '--force'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: OpenHands VS Code extension installed successfully from GitHub.'
- )
- mock_os_remove.assert_called_once_with('/fake/path/to/github.vsix')
- mock_env_and_dependencies['touch'].assert_called_once()
-
-
-def test_all_methods_fail(mock_env_and_dependencies):
- """Should show a final failure message if all installation methods fail."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- mock_env_and_dependencies['download'].assert_called_once()
- mock_env_and_dependencies['as_file'].assert_called_once()
- # Only one subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['subprocess'].assert_called_with(
- ['code', '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Will retry installation next time you run OpenHands in VS Code.'
- )
- # Should NOT create flag file on failure - that's the point of our new approach
- mock_env_and_dependencies['touch'].assert_not_called()
-
-
-def test_windsurf_detection_and_install(mock_env_and_dependencies):
- """Should correctly detect Windsurf but not attempt marketplace installation."""
- os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Only one subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['subprocess'].assert_called_with(
- ['surf', '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Will retry installation next time you run OpenHands in Windsurf.'
- )
- # Should NOT create flag file on failure
- mock_env_and_dependencies['touch'].assert_not_called()
-
-
-def test_os_error_on_mkdir(mock_env_and_dependencies):
- """Should log a debug message if creating the flag directory fails."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['mkdir'].side_effect = OSError('Permission denied')
-
- vscode_extension.attempt_vscode_extension_install()
-
- mock_env_and_dependencies['logger'].assert_called_once_with(
- 'Could not create or check VS Code extension flag directory: Permission denied'
- )
- mock_env_and_dependencies['download'].assert_not_called()
-
-
-def test_os_error_on_touch(mock_env_and_dependencies):
- """Should log a debug message if creating the flag file fails."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
- mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should NOT create flag file on failure - this is the new behavior
- mock_env_and_dependencies['touch'].assert_not_called()
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Will retry installation next time you run OpenHands in VS Code.'
- )
-
-
-def test_flag_file_exists_windsurf(mock_env_and_dependencies):
- """Should not attempt install if flag file already exists (Windsurf)."""
- os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
- mock_env_and_dependencies['exists'].return_value = True
- vscode_extension.attempt_vscode_extension_install()
- mock_env_and_dependencies['download'].assert_not_called()
- mock_env_and_dependencies['subprocess'].assert_not_called()
-
-
-def test_successful_install_attempt_vscode(mock_env_and_dependencies):
- """Test that VS Code is detected but marketplace installation is not attempted."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['subprocess'].assert_called_with(
- ['code', '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_successful_install_attempt_windsurf(mock_env_and_dependencies):
- """Test that Windsurf is detected but marketplace installation is not attempted."""
- os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['subprocess'].assert_called_with(
- ['surf', '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_install_attempt_code_command_fails(mock_env_and_dependencies):
- """Test that VS Code is detected but marketplace installation is not attempted."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_install_attempt_code_not_found(mock_env_and_dependencies):
- """Test that VS Code is detected but marketplace installation is not attempted."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_flag_dir_creation_os_error_windsurf(mock_env_and_dependencies):
- """Test OSError during flag directory creation (Windsurf)."""
- os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
- mock_env_and_dependencies['mkdir'].side_effect = OSError('Permission denied')
- vscode_extension.attempt_vscode_extension_install()
- mock_env_and_dependencies['logger'].assert_called_once_with(
- 'Could not create or check Windsurf extension flag directory: Permission denied'
- )
- mock_env_and_dependencies['download'].assert_not_called()
-
-
-def test_flag_file_touch_os_error_vscode(mock_env_and_dependencies):
- """Test OSError during flag file touch (VS Code)."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
- mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should NOT create flag file on failure - this is the new behavior
- mock_env_and_dependencies['touch'].assert_not_called()
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Will retry installation next time you run OpenHands in VS Code.'
- )
-
-
-def test_flag_file_touch_os_error_windsurf(mock_env_and_dependencies):
- """Test OSError during flag file touch (Windsurf)."""
- os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
- mock_env_and_dependencies['touch'].side_effect = OSError('Permission denied')
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Should NOT create flag file on failure - this is the new behavior
- mock_env_and_dependencies['touch'].assert_not_called()
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Will retry installation next time you run OpenHands in Windsurf.'
- )
-
-
-def test_bundled_vsix_installation_failure_fallback_to_marketplace(
- mock_env_and_dependencies,
-):
- """Test bundled VSIX failure shows appropriate message."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_vsix_path = mock.MagicMock()
- mock_vsix_path.exists.return_value = True
- mock_vsix_path.__str__.return_value = '/mock/path/openhands-vscode-0.0.1.vsix'
- mock_env_and_dependencies[
- 'as_file'
- ].return_value.__enter__.return_value = mock_vsix_path
-
- # Mock subprocess calls: first --list-extensions (empty), then bundled install (fails)
- mock_env_and_dependencies['subprocess'].side_effect = [
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # --list-extensions
- subprocess.CompletedProcess(
- args=[
- 'code',
- '--install-extension',
- '/mock/path/openhands-vscode-0.0.1.vsix',
- '--force',
- ],
- returncode=1,
- stdout='Installation failed',
- stderr='Error installing extension',
- ),
- ]
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Two subprocess calls: --list-extensions and bundled VSIX install
- assert mock_env_and_dependencies['subprocess'].call_count == 2
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_bundled_vsix_not_found_fallback_to_marketplace(mock_env_and_dependencies):
- """Test bundled VSIX not found shows appropriate message."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_vsix_path = mock.MagicMock()
- mock_vsix_path.exists.return_value = False
- mock_env_and_dependencies[
- 'as_file'
- ].return_value.__enter__.return_value = mock_vsix_path
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_importlib_resources_exception_fallback_to_marketplace(
- mock_env_and_dependencies,
-):
- """Test importlib.resources exception shows appropriate message."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError(
- 'Resource not found'
- )
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_comprehensive_windsurf_detection_path_based(mock_env_and_dependencies):
- """Test Windsurf detection via PATH environment variable but no marketplace installation."""
- os.environ['PATH'] = (
- '/usr/local/bin:/Applications/Windsurf.app/Contents/Resources/app/bin:/usr/bin'
- )
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['subprocess'].assert_called_with(
- ['surf', '--list-extensions'],
- capture_output=True,
- text=True,
- check=False,
- )
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_comprehensive_windsurf_detection_env_value_based(mock_env_and_dependencies):
- """Test Windsurf detection via environment variable values but no marketplace installation."""
- os.environ['SOME_APP_PATH'] = '/Applications/Windsurf.app/Contents/MacOS/Windsurf'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_comprehensive_windsurf_detection_multiple_indicators(
- mock_env_and_dependencies,
-):
- """Test Windsurf detection with multiple environment indicators."""
- os.environ['__CFBundleIdentifier'] = 'com.exafunction.windsurf'
- os.environ['PATH'] = (
- '/usr/local/bin:/Applications/Windsurf.app/Contents/Resources/app/bin:/usr/bin'
- )
- os.environ['WINDSURF_CONFIG'] = '/Users/test/.windsurf/config'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_env_and_dependencies['as_file'].side_effect = FileNotFoundError
-
- # Mock subprocess call for --list-extensions (returns empty, extension not installed)
- mock_env_and_dependencies['subprocess'].return_value = subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- )
-
- vscode_extension.attempt_vscode_extension_install()
-
- # One subprocess call for --list-extensions, no installation attempts
- assert mock_env_and_dependencies['subprocess'].call_count == 1
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
-
-
-def test_no_editor_detection_skips_installation(mock_env_and_dependencies):
- """Test that no installation is attempted when no supported editor is detected."""
- os.environ['TERM_PROGRAM'] = 'iTerm.app'
- os.environ['PATH'] = '/usr/local/bin:/usr/bin:/bin'
- vscode_extension.attempt_vscode_extension_install()
- mock_env_and_dependencies['exists'].assert_not_called()
- mock_env_and_dependencies['touch'].assert_not_called()
- mock_env_and_dependencies['subprocess'].assert_not_called()
- mock_env_and_dependencies['print'].assert_not_called()
-
-
-def test_both_bundled_and_marketplace_fail(mock_env_and_dependencies):
- """Test when bundled VSIX installation fails."""
- os.environ['TERM_PROGRAM'] = 'vscode'
- mock_env_and_dependencies['exists'].return_value = False
- mock_env_and_dependencies['download'].return_value = None
- mock_vsix_path = mock.MagicMock()
- mock_vsix_path.exists.return_value = True
- mock_vsix_path.__str__.return_value = '/mock/path/openhands-vscode-0.0.1.vsix'
- mock_env_and_dependencies[
- 'as_file'
- ].return_value.__enter__.return_value = mock_vsix_path
-
- # Mock subprocess calls: first --list-extensions (empty), then bundled install (fails)
- mock_env_and_dependencies['subprocess'].side_effect = [
- subprocess.CompletedProcess(
- returncode=0, args=[], stdout='', stderr=''
- ), # --list-extensions
- subprocess.CompletedProcess(
- args=[
- 'code',
- '--install-extension',
- '/mock/path/openhands-vscode-0.0.1.vsix',
- '--force',
- ],
- returncode=1,
- stdout='Bundled installation failed',
- stderr='Error installing bundled extension',
- ),
- ]
-
- vscode_extension.attempt_vscode_extension_install()
-
- # Two subprocess calls: --list-extensions and bundled VSIX install
- assert mock_env_and_dependencies['subprocess'].call_count == 2
- mock_env_and_dependencies['print'].assert_any_call(
- 'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
- )
diff --git a/tests/unit/core/config/test_config_precedence.py b/tests/unit/core/config/test_config_precedence.py
index c56ee7a240..56c77e1758 100644
--- a/tests/unit/core/config/test_config_precedence.py
+++ b/tests/unit/core/config/test_config_precedence.py
@@ -153,165 +153,6 @@ def test_get_llm_config_arg_precedence(mock_expanduser, temp_config_files):
assert llm_config is None
-@patch('openhands.core.config.utils.os.path.expanduser')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.FileSettingsStore.load')
-def test_cli_main_settings_precedence(
- mock_load, mock_get_instance, mock_expanduser, temp_config_files
-):
- """Test that the CLI main.py correctly applies settings precedence."""
- from openhands.cli.main import setup_config_from_args
-
- mock_expanduser.side_effect = lambda path: path.replace(
- '~', temp_config_files['home_dir']
- )
-
- # Create mock settings
- mock_settings = MagicMock()
- mock_settings.llm_model = 'settings-store-model'
- mock_settings.llm_api_key = 'settings-store-api-key'
- mock_settings.llm_base_url = None
- mock_settings.agent = 'CodeActAgent'
- mock_settings.confirmation_mode = False
- mock_settings.enable_default_condenser = True
-
- # Setup mocks
- mock_load.return_value = mock_settings
- mock_get_instance.return_value = MagicMock()
-
- # Create mock args with config file pointing to current directory config
- mock_args = MagicMock()
- mock_args.config_file = temp_config_files['current_dir_toml']
- mock_args.llm_config = None # No CLI parameter
- mock_args.agent_cls = None
- mock_args.max_iterations = None
- mock_args.max_budget_per_task = None
- mock_args.selected_repo = None
-
- # Load config using the actual CLI code path
- with patch('os.path.exists', return_value=True):
- config = setup_config_from_args(mock_args)
-
- # Verify that config.toml values take precedence over settings.json
- assert config.get_llm_config().model == 'current-dir-model'
- assert config.get_llm_config().api_key.get_secret_value() == 'current-dir-api-key'
-
-
-@patch('openhands.core.config.utils.os.path.expanduser')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.FileSettingsStore.load')
-def test_cli_with_l_parameter_precedence(
- mock_load, mock_get_instance, mock_expanduser, temp_config_files
-):
- """Test that CLI -l parameter has highest precedence in CLI mode."""
- from openhands.cli.main import setup_config_from_args
-
- mock_expanduser.side_effect = lambda path: path.replace(
- '~', temp_config_files['home_dir']
- )
-
- # Create mock settings
- mock_settings = MagicMock()
- mock_settings.llm_model = 'settings-store-model'
- mock_settings.llm_api_key = 'settings-store-api-key'
- mock_settings.llm_base_url = None
- mock_settings.agent = 'CodeActAgent'
- mock_settings.confirmation_mode = False
- mock_settings.enable_default_condenser = True
-
- # Setup mocks
- mock_load.return_value = mock_settings
- mock_get_instance.return_value = MagicMock()
-
- # Create mock args with -l parameter
- mock_args = MagicMock()
- mock_args.config_file = temp_config_files['current_dir_toml']
- mock_args.llm_config = 'current-dir-llm' # Specify LLM via CLI
- mock_args.agent_cls = None
- mock_args.max_iterations = None
- mock_args.max_budget_per_task = None
- mock_args.selected_repo = None
-
- # Load config using the actual CLI code path
- with patch('os.path.exists', return_value=True):
- config = setup_config_from_args(mock_args)
-
- # Verify that -l parameter takes precedence over everything
- assert config.get_llm_config().model == 'current-dir-specific-model'
- assert (
- config.get_llm_config().api_key.get_secret_value()
- == 'current-dir-specific-api-key'
- )
-
-
-@patch('openhands.core.config.utils.os.path.expanduser')
-@patch('openhands.cli.main.FileSettingsStore.get_instance')
-@patch('openhands.cli.main.FileSettingsStore.load')
-def test_cli_settings_json_not_override_config_toml(
- mock_load, mock_get_instance, mock_expanduser, temp_config_files
-):
- """Test that settings.json doesn't override config.toml in CLI mode."""
- import importlib
- import sys
- from unittest.mock import patch
-
- # First, ensure we can import the CLI main module
- if 'openhands.cli.main' in sys.modules:
- importlib.reload(sys.modules['openhands.cli.main'])
-
- # Now import the specific function we want to test
- from openhands.cli.main import setup_config_from_args
-
- mock_expanduser.side_effect = lambda path: path.replace(
- '~', temp_config_files['home_dir']
- )
-
- # Create mock settings with different values than config.toml
- mock_settings = MagicMock()
- mock_settings.llm_model = 'settings-json-model'
- mock_settings.llm_api_key = 'settings-json-api-key'
- mock_settings.llm_base_url = None
- mock_settings.agent = 'CodeActAgent'
- mock_settings.confirmation_mode = False
- mock_settings.enable_default_condenser = True
-
- # Setup mocks
- mock_load.return_value = mock_settings
- mock_get_instance.return_value = MagicMock()
-
- # Create mock args with config file pointing to current directory config
- mock_args = MagicMock()
- mock_args.config_file = temp_config_files['current_dir_toml']
- mock_args.llm_config = None # No CLI parameter
- mock_args.agent_cls = None
- mock_args.max_iterations = None
- mock_args.max_budget_per_task = None
- mock_args.selected_repo = None
-
- # Load config using the actual CLI code path
- with patch('os.path.exists', return_value=True):
- setup_config_from_args(mock_args)
-
- # Create a test LLM config to simulate the fix in CLI main.py
- test_config = OpenHandsConfig()
- test_llm_config = test_config.get_llm_config()
- test_llm_config.model = 'config-toml-model'
- test_llm_config.api_key = 'config-toml-api-key'
-
- # Simulate the CLI main.py logic that we fixed
- if not mock_args.llm_config and (test_llm_config.model or test_llm_config.api_key):
- # Should NOT apply settings from settings.json
- pass
- else:
- # This branch should not be taken in our test
- test_llm_config.model = mock_settings.llm_model
- test_llm_config.api_key = mock_settings.llm_api_key
-
- # Verify that settings.json did not override config.toml
- assert test_llm_config.model == 'config-toml-model'
- assert test_llm_config.api_key == 'config-toml-api-key'
-
-
def test_default_values_applied_when_none():
"""Test that default values are applied when config values are None."""
# Create mock args with None values for agent_cls and max_iterations
diff --git a/tests/unit/core/schema/test_exit_reason.py b/tests/unit/core/schema/test_exit_reason.py
index 8c862c4c91..fb4ab3708d 100644
--- a/tests/unit/core/schema/test_exit_reason.py
+++ b/tests/unit/core/schema/test_exit_reason.py
@@ -1,10 +1,3 @@
-import time
-from unittest.mock import MagicMock
-
-import pytest
-
-from openhands.cli.commands import handle_commands
-from openhands.core.schema import AgentState
from openhands.core.schema.exit_reason import ExitReason
@@ -23,36 +16,3 @@ def test_exit_reason_enum_names():
def test_exit_reason_str_representation():
assert str(ExitReason.INTENTIONAL) == 'ExitReason.INTENTIONAL'
assert repr(ExitReason.ERROR) == ""
-
-
-@pytest.mark.asyncio
-async def test_handle_exit_command_returns_intentional(monkeypatch):
- monkeypatch.setattr('openhands.cli.commands.cli_confirm', lambda *a, **k: 0)
-
- mock_usage_metrics = MagicMock()
- mock_usage_metrics.session_init_time = time.time() - 3600
- mock_usage_metrics.metrics.accumulated_cost = 0.123456
-
- # Mock all token counts used in display formatting
- mock_usage_metrics.metrics.accumulated_token_usage.prompt_tokens = 1234
- mock_usage_metrics.metrics.accumulated_token_usage.cache_read_tokens = 5678
- mock_usage_metrics.metrics.accumulated_token_usage.cache_write_tokens = 9012
- mock_usage_metrics.metrics.accumulated_token_usage.completion_tokens = 3456
-
- (
- close_repl,
- reload_microagents,
- new_session_requested,
- exit_reason,
- ) = await handle_commands(
- '/exit',
- MagicMock(),
- mock_usage_metrics,
- 'test-session',
- MagicMock(),
- '/tmp/test',
- MagicMock(),
- AgentState.RUNNING,
- )
-
- assert exit_reason == ExitReason.INTENTIONAL
diff --git a/tests/unit/runtime/test_runtime_import_robustness.py b/tests/unit/runtime/test_runtime_import_robustness.py
index 00907056e1..6e9f87b597 100644
--- a/tests/unit/runtime/test_runtime_import_robustness.py
+++ b/tests/unit/runtime/test_runtime_import_robustness.py
@@ -11,24 +11,6 @@ import sys
import pytest
-def test_cli_import_with_broken_third_party_runtime():
- """Test that CLI can be imported even with broken third-party runtime dependencies."""
- # Clear any cached modules to ensure fresh import
- modules_to_clear = [
- k for k in sys.modules.keys() if 'openhands' in k or 'third_party' in k
- ]
- for module in modules_to_clear:
- del sys.modules[module]
-
- # This should not raise an exception even if third-party runtimes have broken dependencies
- try:
- import openhands.cli.main # noqa: F401
-
- assert True
- except Exception as e:
- pytest.fail(f'CLI import failed: {e}')
-
-
def test_runtime_import_robustness():
"""Test that runtime import system is robust against broken dependencies."""
# Clear any cached runtime modules