diff --git a/.github/scripts/update_pr_description.sh b/.github/scripts/update_pr_description.sh index 4457b74955..fd8b640c74 100755 --- a/.github/scripts/update_pr_description.sh +++ b/.github/scripts/update_pr_description.sh @@ -17,9 +17,6 @@ DOCKER_RUN_COMMAND="docker run -it --rm \ --name openhands-app-${SHORT_SHA} \ docker.openhands.dev/openhands/openhands:${SHORT_SHA}" -# Define the uvx command -UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/OpenHands/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands" - # Get the current PR body PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq .body) @@ -37,11 +34,6 @@ GUI with Docker: \`\`\` ${DOCKER_RUN_COMMAND} \`\`\` - -CLI with uvx: -\`\`\` -${UVX_RUN_COMMAND} -\`\`\` EOF ) else @@ -57,11 +49,6 @@ GUI with Docker: \`\`\` ${DOCKER_RUN_COMMAND} \`\`\` - -CLI with uvx: -\`\`\` -${UVX_RUN_COMMAND} -\`\`\` EOF ) fi diff --git a/.github/workflows/cli-build-binary-and-optionally-release.yml b/.github/workflows/cli-build-binary-and-optionally-release.yml deleted file mode 100644 index 0aefcd3820..0000000000 --- a/.github/workflows/cli-build-binary-and-optionally-release.yml +++ /dev/null @@ -1,122 +0,0 @@ -# Workflow that builds and tests the CLI binary executable -name: CLI - Build binary and optionally release - -# Run on pushes to main branch and CLI tags, and on pull requests when CLI files change -on: - push: - branches: - - main - tags: - - "*-cli" - pull_request: - paths: - - "openhands-cli/**" - -permissions: - contents: write # needed to create releases or upload assets - -# Cancel previous runs if a new commit is pushed -concurrency: - group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }} - cancel-in-progress: true - -jobs: - build-binary: - name: Build binary executable - strategy: - matrix: - include: - # Build on Ubuntu 22.04 for maximum GLIBC compatibility (GLIBC 2.31) - - os: ubuntu-22.04 - platform: linux - artifact_name: openhands-cli-linux - # Build on macOS for macOS users - - os: macos-15 - platform: macos - artifact_name: openhands-cli-macos - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.12 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - version: "latest" - - - name: Install dependencies - working-directory: openhands-cli - run: | - uv sync - - - name: Build binary executable - working-directory: openhands-cli - run: | - ./build.sh --install-pyinstaller | tee output.log - echo "Full output:" - cat output.log - - if grep -q "❌" output.log; then - echo "❌ Found failure marker in output" - exit 1 - fi - - echo "✅ Build & test finished without ❌ markers" - - - name: Verify binary files exist - run: | - if ! ls openhands-cli/dist/openhands* 1> /dev/null 2>&1; then - echo "❌ No binaries found to upload!" - exit 1 - fi - echo "✅ Found binaries to upload." - - - name: Upload binary artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact_name }} - path: openhands-cli/dist/openhands* - retention-days: 30 - - create-github-release: - name: Create GitHub Release - runs-on: ubuntu-latest - needs: build-binary - if: startsWith(github.ref, 'refs/tags/') - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Prepare release assets - run: | - mkdir -p release-assets - # Copy binaries with appropriate names for release - if [ -f artifacts/openhands-cli-linux/openhands ]; then - cp artifacts/openhands-cli-linux/openhands release-assets/openhands-linux - fi - if [ -f artifacts/openhands-cli-macos/openhands ]; then - cp artifacts/openhands-cli-macos/openhands release-assets/openhands-macos - fi - ls -la release-assets/ - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: release-assets/* - draft: true - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 89cb645f5f..4c882bda07 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -72,21 +72,3 @@ jobs: - name: Run pre-commit hooks working-directory: ./enterprise run: pre-commit run --all-files --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml - - lint-cli-python: - name: Lint CLI python - runs-on: blacksmith-4vcpu-ubuntu-2204 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up python - uses: useblacksmith/setup-python@v6 - with: - python-version: 3.12 - cache: "pip" - - name: Install pre-commit - run: pip install pre-commit==4.2.0 - - name: Run pre-commit hooks - working-directory: ./openhands-cli - run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml diff --git a/.github/workflows/py-tests.yml b/.github/workflows/py-tests.yml index 4506f1ea75..5c4c35f6bc 100644 --- a/.github/workflows/py-tests.yml +++ b/.github/workflows/py-tests.yml @@ -101,56 +101,11 @@ jobs: path: ".coverage.enterprise.${{ matrix.python_version }}" include-hidden-files: true - # Run CLI unit tests - test-cli-python: - name: CLI Unit Tests - runs-on: blacksmith-4vcpu-ubuntu-2404 - strategy: - matrix: - python-version: ["3.12"] - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: useblacksmith/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - version: "latest" - - - name: Install dependencies - working-directory: ./openhands-cli - run: | - uv sync --group dev - - - name: Run CLI unit tests - working-directory: ./openhands-cli - env: - # write coverage to repo root so the merge step finds it - COVERAGE_FILE: "${{ github.workspace }}/.coverage.openhands-cli.${{ matrix.python-version }}" - run: | - uv run pytest --forked -n auto -s \ - -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark \ - tests --cov=openhands_cli --cov-branch - - - name: Store coverage file - uses: actions/upload-artifact@v4 - with: - name: coverage-openhands-cli - path: ".coverage.openhands-cli.${{ matrix.python-version }}" - include-hidden-files: true - coverage-comment: name: Coverage Comment if: github.event_name == 'pull_request' runs-on: ubuntu-latest - needs: [test-on-linux, test-enterprise, test-cli-python] + needs: [test-on-linux, test-enterprise] permissions: pull-requests: write @@ -164,9 +119,6 @@ jobs: pattern: coverage-* merge-multiple: true - - name: Create symlink for CLI source files - run: ln -sf openhands-cli/openhands_cli openhands_cli - - name: Coverage comment id: coverage_comment uses: py-cov-action/python-coverage-comment-action@v3 diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 89a64aa58a..f4df10567f 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -10,7 +10,6 @@ on: type: choice options: - app server - - cli default: app server push: tags: @@ -39,36 +38,3 @@ jobs: run: ./build.sh - name: publish run: poetry publish -u __token__ -p ${{ secrets.PYPI_TOKEN }} - - release-cli: - name: Publish CLI to PyPI - runs-on: ubuntu-latest - # Run when manually dispatched for "cli" OR for tag pushes that contain '-cli' - if: | - (github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'cli') - || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-cli')) - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.12 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - version: "latest" - - - name: Build CLI package - working-directory: openhands-cli - run: | - # Clean dist directory to avoid conflicts with binary builds - rm -rf dist/ - uv build - - - name: Publish CLI to PyPI - working-directory: openhands-cli - run: | - uv publish --token ${{ secrets.PYPI_TOKEN_OPENHANDS }} diff --git a/dev_config/python/.pre-commit-config.yaml b/dev_config/python/.pre-commit-config.yaml index fe3f137cea..2063e60562 100644 --- a/dev_config/python/.pre-commit-config.yaml +++ b/dev_config/python/.pre-commit-config.yaml @@ -3,9 +3,9 @@ repos: rev: v5.0.0 hooks: - id: trailing-whitespace - exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/) + exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/) - id: end-of-file-fixer - exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/) + exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/) - id: check-yaml args: ["--allow-multiple-documents"] - id: debug-statements @@ -28,12 +28,12 @@ repos: entry: ruff check --config dev_config/python/ruff.toml types_or: [python, pyi, jupyter] args: [--fix, --unsafe-fixes] - exclude: ^(third_party/|enterprise/|openhands-cli/) + exclude: ^(third_party/|enterprise/) # Run the formatter. - id: ruff-format entry: ruff format --config dev_config/python/ruff.toml types_or: [python, pyi, jupyter] - exclude: ^(third_party/|enterprise/|openhands-cli/) + exclude: ^(third_party/|enterprise/) - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.15.0 diff --git a/openhands-cli/.gitignore b/openhands-cli/.gitignore deleted file mode 100644 index a83411f80e..0000000000 --- a/openhands-cli/.gitignore +++ /dev/null @@ -1,56 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual Environment -.env -.venv -env/ -venv/ -ENV/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# Testing -.pytest_cache/ -.coverage -htmlcov/ -.tox/ -.nox/ -.coverage.* -coverage.xml -*.cover -.hypothesis/ - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -# Note: We keep our custom spec file in version control -# *.spec - -# Generated artifacts -build - diff --git a/openhands-cli/Makefile b/openhands-cli/Makefile deleted file mode 100644 index 0736a2852f..0000000000 --- a/openhands-cli/Makefile +++ /dev/null @@ -1,46 +0,0 @@ -.PHONY: help install install-dev test format clean run - -# Default target -help: - @echo "OpenHands CLI - Available commands:" - @echo " install - Install the package" - @echo " install-dev - Install with development dependencies" - @echo " test - Run tests" - @echo " format - Format code with ruff" - @echo " clean - Clean build artifacts" - @echo " run - Run the CLI" - -# Install the package -install: - uv sync - -# Install with development dependencies -install-dev: - uv sync --group dev - -# Run tests -test: - uv run pytest - -# Format code -format: - uv run ruff format openhands_cli/ - -# Clean build artifacts -clean: - rm -rf .venv/ - find . -type d -name "__pycache__" -exec rm -rf {} + - find . -type f -name "*.pyc" -delete - -# Run the CLI -run: - uv run openhands - -# Install UV if not present -install-uv: - @if ! command -v uv &> /dev/null; then \ - echo "Installing UV..."; \ - curl -LsSf https://astral.sh/uv/install.sh | sh; \ - else \ - echo "UV is already installed"; \ - fi diff --git a/openhands-cli/README.md b/openhands-cli/README.md deleted file mode 100644 index 07f49dcfc9..0000000000 --- a/openhands-cli/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# OpenHands V1 CLI - -A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [OpenHands software-agent-sdk](https://github.com/OpenHands/software-agent-sdk)). - ---- - -## Quickstart - -- Prerequisites: Python 3.12+, curl -- Install uv (package manager): - ```bash - curl -LsSf https://astral.sh/uv/install.sh | sh - # Restart your shell so "uv" is on PATH, or follow the installer hint - ``` - -### Run the CLI locally -```bash -make install - -# Start the CLI -make run -# or -uv run openhands -``` - -### Build a standalone executable -```bash -# Build (installs PyInstaller if needed) -./build.sh --install-pyinstaller - -# The binary will be in dist/ -./dist/openhands # macOS/Linux -# dist/openhands.exe # Windows -``` diff --git a/openhands-cli/build.py b/openhands-cli/build.py deleted file mode 100755 index 1b574294b1..0000000000 --- a/openhands-cli/build.py +++ /dev/null @@ -1,292 +0,0 @@ -#!/usr/bin/env python3 -""" -Build script for OpenHands CLI using PyInstaller. - -This script packages the OpenHands CLI into a standalone executable binary -using PyInstaller with the custom spec file. -""" - -import argparse -import os -import select -import shutil -import subprocess -import sys -import time -from pathlib import Path - -from openhands_cli.utils import get_default_cli_agent, get_llm_metadata -from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR - -from openhands.sdk import LLM - -# ================================================= -# SECTION: Build Binary -# ================================================= - - -def clean_build_directories() -> None: - """Clean up previous build artifacts.""" - print('🧹 Cleaning up previous build artifacts...') - - build_dirs = ['build', 'dist', '__pycache__'] - for dir_name in build_dirs: - if os.path.exists(dir_name): - print(f' Removing {dir_name}/') - shutil.rmtree(dir_name) - - # Clean up .pyc files - for root, _dirs, files in os.walk('.'): - for file in files: - if file.endswith('.pyc'): - os.remove(os.path.join(root, file)) - - print('✅ Cleanup complete!') - - -def check_pyinstaller() -> bool: - """Check if PyInstaller is available.""" - try: - subprocess.run( - ['uv', 'run', 'pyinstaller', '--version'], check=True, capture_output=True - ) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - print( - '❌ PyInstaller is not available. Use --install-pyinstaller flag or install manually with:' - ) - print(' uv add --dev pyinstaller') - return False - - -def build_executable( - spec_file: str = 'openhands.spec', - clean: bool = True, -) -> bool: - """Build the executable using PyInstaller.""" - if clean: - clean_build_directories() - - # Check if PyInstaller is available (installation is handled by build.sh) - if not check_pyinstaller(): - return False - - print(f'🔨 Building executable using {spec_file}...') - - try: - # Run PyInstaller with uv - cmd = ['uv', 'run', 'pyinstaller', spec_file, '--clean'] - - print(f'Running: {" ".join(cmd)}') - subprocess.run(cmd, check=True, capture_output=True, text=True) - - print('✅ Build completed successfully!') - - # Check if the executable was created - dist_dir = Path('dist') - if dist_dir.exists(): - executables = list(dist_dir.glob('*')) - if executables: - print('📁 Executable(s) created in dist/:') - for exe in executables: - size = exe.stat().st_size / (1024 * 1024) # Size in MB - print(f' - {exe.name} ({size:.1f} MB)') - else: - print('⚠️ No executables found in dist/ directory') - - return True - - except subprocess.CalledProcessError as e: - print(f'❌ Build failed: {e}') - if e.stdout: - print('STDOUT:', e.stdout) - if e.stderr: - print('STDERR:', e.stderr) - return False - - -# ================================================= -# SECTION: Test and profile binary -# ================================================= - -WELCOME_MARKERS = ['welcome', 'openhands cli', 'type /help', 'available commands', '>'] - - -def _is_welcome(line: str) -> bool: - s = line.strip().lower() - return any(marker in s for marker in WELCOME_MARKERS) - - -def test_executable(dummy_agent) -> bool: - """Test the built executable, measuring boot time and total test time.""" - print('🧪 Testing the built executable...') - - spec_path = os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH) - - specs_path = Path(os.path.expanduser(spec_path)) - if specs_path.exists(): - print(f'⚠️ Using existing settings at {specs_path}') - else: - print(f'💾 Creating dummy settings at {specs_path}') - specs_path.parent.mkdir(parents=True, exist_ok=True) - specs_path.write_text(dummy_agent.model_dump_json()) - - exe_path = Path('dist/openhands') - if not exe_path.exists(): - exe_path = Path('dist/openhands.exe') - if not exe_path.exists(): - print('❌ Executable not found!') - return False - - try: - if os.name != 'nt': - os.chmod(exe_path, 0o755) - - boot_start = time.time() - proc = subprocess.Popen( - [str(exe_path)], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - env={**os.environ}, - ) - - # --- Wait for welcome --- - deadline = boot_start + 60 - saw_welcome = False - captured = [] - - while time.time() < deadline: - if proc.poll() is not None: - break - rlist, _, _ = select.select([proc.stdout], [], [], 0.2) - if not rlist: - continue - line = proc.stdout.readline() - if not line: - continue - captured.append(line) - if _is_welcome(line): - saw_welcome = True - break - - if not saw_welcome: - print('❌ Did not detect welcome prompt') - try: - proc.kill() - except Exception: - pass - return False - - boot_end = time.time() - print(f'⏱️ Boot to welcome: {boot_end - boot_start:.2f} seconds') - - # --- Run /help then /exit --- - if proc.stdin is None: - print('❌ stdin unavailable') - proc.kill() - return False - - proc.stdin.write('/help\n/exit\n') - proc.stdin.flush() - out, _ = proc.communicate(timeout=60) - - total_end = time.time() - full_output = ''.join(captured) + (out or '') - - print(f'⏱️ End-to-end test time: {total_end - boot_start:.2f} seconds') - - if 'available commands' in full_output.lower(): - print('✅ Executable starts, welcome detected, and /help works') - return True - else: - print('❌ /help output not found') - print('Output preview:', full_output[-500:]) - return False - - except subprocess.TimeoutExpired: - print('❌ Executable test timed out') - try: - proc.kill() - except Exception: - pass - return False - except Exception as e: - print(f'❌ Error testing executable: {e}') - try: - proc.kill() - except Exception: - pass - return False - - -# ================================================= -# SECTION: Main -# ================================================= - - -def main() -> int: - """Main function.""" - parser = argparse.ArgumentParser(description='Build OpenHands CLI executable') - parser.add_argument( - '--spec', default='openhands.spec', help='PyInstaller spec file to use' - ) - parser.add_argument( - '--no-clean', action='store_true', help='Skip cleaning build directories' - ) - parser.add_argument( - '--no-test', action='store_true', help='Skip testing the built executable' - ) - parser.add_argument( - '--install-pyinstaller', - action='store_true', - help='Install PyInstaller using uv before building', - ) - - parser.add_argument( - '--no-build', action='store_true', help='Skip testing the built executable' - ) - - args = parser.parse_args() - - print('🚀 OpenHands CLI Build Script') - print('=' * 40) - - # Check if spec file exists - if not os.path.exists(args.spec): - print(f"❌ Spec file '{args.spec}' not found!") - return 1 - - # Build the executable - if not args.no_build and not build_executable(args.spec, clean=not args.no_clean): - return 1 - - # Test the executable - if not args.no_test: - dummy_agent = get_default_cli_agent( - llm=LLM( - model='dummy-model', - api_key='dummy-key', - litellm_extra_body={"metadata": get_llm_metadata(model_name='dummy-model', llm_type='openhands')}, - ) - ) - if not test_executable(dummy_agent): - print('❌ Executable test failed, build process failed') - return 1 - - print('\n🎉 Build process completed!') - print("📁 Check the 'dist/' directory for your executable") - - return 0 - - -if __name__ == '__main__': - try: - sys.exit(main()) - except Exception as e: - print(e) - print('❌ Executable test failed') - sys.exit(1) - diff --git a/openhands-cli/build.sh b/openhands-cli/build.sh deleted file mode 100755 index 102a1bcb06..0000000000 --- a/openhands-cli/build.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -# -# Shell script wrapper for building OpenHands CLI executable. -# -# This script provides a simple interface to build the OpenHands CLI -# using PyInstaller with uv package management. -# - -set -e # Exit on any error - -echo "🚀 OpenHands CLI Build Script" -echo "==============================" - -# Check if uv is available -if ! command -v uv &> /dev/null; then - echo "❌ uv is required but not found! Please install uv first." - exit 1 -fi - -# Parse arguments to check for --install-pyinstaller -INSTALL_PYINSTALLER=false -PYTHON_ARGS=() - -for arg in "$@"; do - case $arg in - --install-pyinstaller) - INSTALL_PYINSTALLER=true - PYTHON_ARGS+=("$arg") - ;; - *) - PYTHON_ARGS+=("$arg") - ;; - esac -done - -# Install PyInstaller if requested -if [ "$INSTALL_PYINSTALLER" = true ]; then - echo "📦 Installing PyInstaller with uv..." - if uv add --dev pyinstaller; then - echo "✅ PyInstaller installed successfully with uv!" - else - echo "❌ Failed to install PyInstaller" - exit 1 - fi -fi - -# Run the Python build script using uv -uv run python build.py "${PYTHON_ARGS[@]}" diff --git a/openhands-cli/hooks/rthook_profile_imports.py b/openhands-cli/hooks/rthook_profile_imports.py deleted file mode 100644 index 2175b51146..0000000000 --- a/openhands-cli/hooks/rthook_profile_imports.py +++ /dev/null @@ -1,68 +0,0 @@ -import atexit -import os -import sys -import time -from collections import defaultdict - -ENABLE = os.getenv('IMPORT_PROFILING', '0') not in ('', '0', 'false', 'False') -OUT = 'dist/import_profiler.csv' -THRESHOLD_MS = float(os.getenv('IMPORT_PROFILING_THRESHOLD_MS', '0')) - -if ENABLE: - timings = defaultdict(float) # module -> total seconds (first load only) - counts = defaultdict(int) # module -> number of first-loads (should be 1) - max_dur = defaultdict(float) # module -> max single load seconds - - try: - import importlib._bootstrap as _bootstrap # type: ignore[attr-defined] - except Exception: - _bootstrap = None - - start_time = time.perf_counter() - - if _bootstrap is not None: - _orig_find_and_load = _bootstrap._find_and_load - - def _timed_find_and_load(name, import_): - preloaded = name in sys.modules # cache hit? - t0 = time.perf_counter() - try: - return _orig_find_and_load(name, import_) - finally: - if not preloaded: - dt = time.perf_counter() - t0 - timings[name] += dt - counts[name] += 1 - if dt > max_dur[name]: - max_dur[name] = dt - - _bootstrap._find_and_load = _timed_find_and_load - - @atexit.register - def _dump_import_profile(): - def ms(s): - return f'{s * 1000:.3f}' - - items = [ - (name, counts[name], timings[name], max_dur[name]) - for name in timings - if timings[name] * 1000 >= THRESHOLD_MS - ] - items.sort(key=lambda x: x[2], reverse=True) - try: - with open(OUT, 'w', encoding='utf-8') as f: - f.write('module,count,total_ms,max_ms\n') - for name, cnt, tot_s, max_s in items: - f.write(f'{name},{cnt},{ms(tot_s)},{ms(max_s)}\n') - # brief summary - if items: - w = max(len(n) for n, *_ in items[:25]) - sys.stderr.write('\n=== Import Time Profile (first-load only) ===\n') - sys.stderr.write(f'{"module".ljust(w)} count total_ms max_ms\n') - for name, cnt, tot_s, max_s in items[:25]: - sys.stderr.write( - f'{name.ljust(w)} {str(cnt).rjust(5)} {ms(tot_s).rjust(8)} {ms(max_s).rjust(7)}\n' - ) - sys.stderr.write(f'\nImport profile written to: {OUT}\n') - except Exception as e: - sys.stderr.write(f'[import-profiler] failed to write profile: {e}\n') diff --git a/openhands-cli/openhands.spec b/openhands-cli/openhands.spec deleted file mode 100644 index 909d1480a8..0000000000 --- a/openhands-cli/openhands.spec +++ /dev/null @@ -1,110 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -""" -PyInstaller spec file for OpenHands CLI. - -This spec file configures PyInstaller to create a standalone executable -for the OpenHands CLI application. -""" - -from pathlib import Path -import os -import sys -from PyInstaller.utils.hooks import ( - collect_submodules, - collect_data_files, - copy_metadata -) - - - -# Get the project root directory (current working directory when running PyInstaller) -project_root = Path.cwd() - -a = Analysis( - ['openhands_cli/simple_main.py'], - pathex=[str(project_root)], - binaries=[], - datas=[ - # Include any data files that might be needed - # Add more data files here if needed in the future - *collect_data_files('tiktoken'), - *collect_data_files('tiktoken_ext'), - *collect_data_files('litellm'), - *collect_data_files('fastmcp'), - *collect_data_files('mcp'), - # Include all data files from openhands.sdk (templates, configs, etc.) - *collect_data_files('openhands.sdk'), - # Include package metadata for importlib.metadata - *copy_metadata('fastmcp'), - ], - hiddenimports=[ - # Explicitly include modules that might not be detected automatically - *collect_submodules('openhands_cli'), - *collect_submodules('prompt_toolkit'), - # Include OpenHands SDK submodules explicitly to avoid resolution issues - *collect_submodules('openhands.sdk'), - *collect_submodules('openhands.tools'), - *collect_submodules('tiktoken'), - *collect_submodules('tiktoken_ext'), - *collect_submodules('litellm'), - *collect_submodules('fastmcp'), - # Include mcp but exclude CLI parts that require typer - 'mcp.types', - 'mcp.client', - 'mcp.server', - 'mcp.shared', - 'openhands.tools.terminal', - 'openhands.tools.str_replace_editor', - 'openhands.tools.task_tracker', - ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - # runtime_hooks=[str(project_root / "hooks" / "rthook_profile_imports.py")], - excludes=[ - # Exclude unnecessary modules to reduce binary size - 'tkinter', - 'matplotlib', - 'numpy', - 'scipy', - 'pandas', - 'IPython', - 'jupyter', - 'notebook', - # Exclude mcp CLI parts that cause issues - 'mcp.cli', - 'prompt_toolkit.contrib.ssh', - 'fastmcp.cli', - 'boto3', - 'botocore', - 'posthog', - 'browser-use', - 'openhands.tools.browser_use' - ], - noarchive=False, - # IMPORTANT: do not use optimize=2 (-OO) because it strips docstrings used by PLY/bashlex grammar - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - [], - name='openhands', - debug=False, - bootloader_ignore_signals=False, - strip=True, # Strip debug symbols to reduce size - upx=True, # Use UPX compression if available - upx_exclude=[], - runtime_tmpdir=None, - console=True, # CLI application needs console - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - icon=None, # Add icon path here if you have one -) diff --git a/openhands-cli/openhands_cli/__init__.py b/openhands-cli/openhands_cli/__init__.py deleted file mode 100644 index a354bd0e46..0000000000 --- a/openhands-cli/openhands_cli/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""OpenHands package.""" - -from importlib.metadata import version, PackageNotFoundError - -try: - __version__ = version("openhands") -except PackageNotFoundError: - __version__ = "0.0.0" diff --git a/openhands-cli/openhands_cli/agent_chat.py b/openhands-cli/openhands_cli/agent_chat.py deleted file mode 100644 index e71efb7a6f..0000000000 --- a/openhands-cli/openhands_cli/agent_chat.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python3 -""" -Agent chat functionality for OpenHands CLI. -Provides a conversation interface with an AI agent using OpenHands patterns. -""" - -import sys -from datetime import datetime -import uuid - -from openhands.sdk import ( - Message, - TextContent, -) -from openhands.sdk.conversation.state import ConversationExecutionStatus -from prompt_toolkit import print_formatted_text -from prompt_toolkit.formatted_text import HTML - -from openhands_cli.runner import ConversationRunner -from openhands_cli.setup import ( - MissingAgentSpec, - setup_conversation, - verify_agent_exists_or_setup_agent -) -from openhands_cli.tui.settings.mcp_screen import MCPScreen -from openhands_cli.tui.settings.settings_screen import SettingsScreen -from openhands_cli.tui.status import display_status -from openhands_cli.tui.tui import ( - display_help, - display_welcome, -) -from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation -from openhands_cli.user_actions.utils import get_session_prompter - - -def _restore_tty() -> None: - """ - Ensure terminal modes are reset in case prompt_toolkit cleanup didn't run. - - Turn off application cursor keys (DECCKM): ESC[?1l - - Turn off bracketed paste: ESC[?2004l - """ - try: - sys.stdout.write('\x1b[?1l\x1b[?2004l') - sys.stdout.flush() - except Exception: - pass - - -def _print_exit_hint(conversation_id: str) -> None: - """Print a resume hint with the current conversation ID.""" - print_formatted_text( - HTML(f'Conversation ID: {conversation_id}') - ) - print_formatted_text( - HTML( - f'Hint: run openhands --resume {conversation_id} ' - 'to resume this conversation.' - ) - ) - - - -def run_cli_entry(resume_conversation_id: str | None = None) -> None: - """Run the agent chat session using the agent SDK. - - - Raises: - AgentSetupError: If agent setup fails - KeyboardInterrupt: If user interrupts the session - EOFError: If EOF is encountered - """ - - conversation_id = uuid.uuid4() - if resume_conversation_id: - try: - conversation_id = uuid.UUID(resume_conversation_id) - except ValueError as e: - print_formatted_text( - HTML( - f"Warning: '{resume_conversation_id}' is not a valid UUID." - ) - ) - return - - try: - initialized_agent = verify_agent_exists_or_setup_agent() - except MissingAgentSpec: - print_formatted_text(HTML('\nSetup is required to use OpenHands CLI.')) - print_formatted_text(HTML('\nGoodbye! 👋')) - return - - - display_welcome(conversation_id, bool(resume_conversation_id)) - - # Track session start time for uptime calculation - session_start_time = datetime.now() - - # Create conversation runner to handle state machine logic - runner = None - session = get_session_prompter() - - # Main chat loop - while True: - try: - # Get user input - user_input = session.prompt( - HTML('> '), - multiline=False, - ) - - if not user_input.strip(): - continue - - # Handle commands - command = user_input.strip().lower() - - message = Message( - role='user', - content=[TextContent(text=user_input)], - ) - - if command == '/exit': - exit_confirmation = exit_session_confirmation() - if exit_confirmation == UserConfirmation.ACCEPT: - print_formatted_text(HTML('\nGoodbye! 👋')) - _print_exit_hint(conversation_id) - break - - elif command == '/settings': - settings_screen = SettingsScreen(runner.conversation if runner else None) - settings_screen.display_settings() - continue - - elif command == '/mcp': - mcp_screen = MCPScreen() - mcp_screen.display_mcp_info(initialized_agent) - continue - - elif command == '/clear': - display_welcome(conversation_id) - continue - - elif command == '/new': - try: - # Start a fresh conversation (no resume ID = new conversation) - conversation_id = uuid.uuid4() - runner = None - conversation = None - display_welcome(conversation_id, resume=False) - print_formatted_text( - HTML('✓ Started fresh conversation') - ) - continue - except Exception as e: - print_formatted_text( - HTML(f'Error starting fresh conversation: {e}') - ) - continue - - elif command == '/help': - display_help() - continue - - elif command == '/status': - display_status(conversation, session_start_time=session_start_time) - continue - - elif command == '/confirm': - runner.toggle_confirmation_mode() - new_status = ( - 'enabled' if runner.is_confirmation_mode_active else 'disabled' - ) - print_formatted_text( - HTML(f'Confirmation mode {new_status}') - ) - continue - - elif command == '/resume': - if not runner: - print_formatted_text( - HTML('No active conversation running...') - ) - continue - - conversation = runner.conversation - if not ( - conversation.state.execution_status == ConversationExecutionStatus.PAUSED - or conversation.state.execution_status - == ConversationExecutionStatus.WAITING_FOR_CONFIRMATION - ): - print_formatted_text( - HTML('No paused conversation to resume...') - ) - continue - - # Resume without new message - message = None - - if not runner or not conversation: - conversation = setup_conversation(conversation_id) - runner = ConversationRunner(conversation) - runner.process_message(message) - - print() # Add spacing - - except KeyboardInterrupt: - exit_confirmation = exit_session_confirmation() - if exit_confirmation == UserConfirmation.ACCEPT: - print_formatted_text(HTML('\nGoodbye! 👋')) - _print_exit_hint(conversation_id) - break - - # Clean up terminal state - _restore_tty() diff --git a/openhands-cli/openhands_cli/argparsers/main_parser.py b/openhands-cli/openhands_cli/argparsers/main_parser.py deleted file mode 100644 index 6f28d1e637..0000000000 --- a/openhands-cli/openhands_cli/argparsers/main_parser.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Main argument parser for OpenHands CLI.""" - -import argparse - - -def create_main_parser() -> argparse.ArgumentParser: - """Create the main argument parser with CLI as default and serve as subcommand. - - Returns: - The configured argument parser - """ - parser = argparse.ArgumentParser( - description='OpenHands CLI - Terminal User Interface for OpenHands AI Agent', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -By default, OpenHands runs in CLI mode (terminal interface). -Use 'serve' subcommand to launch the GUI server instead. - -Examples: - openhands # Start CLI mode - openhands --resume conversation-id # Resume a conversation in CLI mode - openhands serve # Launch GUI server - openhands serve --gpu # Launch GUI server with GPU support -""" - ) - - # CLI arguments at top level (default mode) - parser.add_argument( - '--resume', - type=str, - help='Conversation ID to resume' - ) - - # Only serve as subcommand - subparsers = parser.add_subparsers( - dest='command', - help='Additional commands' - ) - - # Add serve subcommand - serve_parser = subparsers.add_parser( - 'serve', - help='Launch the OpenHands GUI server using Docker (web interface)' - ) - serve_parser.add_argument( - '--mount-cwd', - action='store_true', - help='Mount the current working directory in the Docker container' - ) - serve_parser.add_argument( - '--gpu', - action='store_true', - help='Enable GPU support in the Docker container' - ) - - return parser \ No newline at end of file diff --git a/openhands-cli/openhands_cli/argparsers/serve_parser.py b/openhands-cli/openhands_cli/argparsers/serve_parser.py deleted file mode 100644 index dea9912548..0000000000 --- a/openhands-cli/openhands_cli/argparsers/serve_parser.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Argument parser for serve subcommand.""" - -import argparse - - -def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: - """Add serve subcommand parser. - - Args: - subparsers: The subparsers object to add the serve parser to - - Returns: - The serve argument parser - """ - serve_parser = subparsers.add_parser( - 'serve', - help='Launch the OpenHands GUI server using Docker (web interface)' - ) - serve_parser.add_argument( - '--mount-cwd', - help='Mount the current working directory into the GUI server container', - action='store_true', - default=False, - ) - serve_parser.add_argument( - '--gpu', - help='Enable GPU support by mounting all GPUs into the Docker container via nvidia-docker', - action='store_true', - default=False, - ) - return serve_parser \ No newline at end of file diff --git a/openhands-cli/openhands_cli/gui_launcher.py b/openhands-cli/openhands_cli/gui_launcher.py deleted file mode 100644 index b872496123..0000000000 --- a/openhands-cli/openhands_cli/gui_launcher.py +++ /dev/null @@ -1,220 +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_cli.locations import PERSISTENCE_DIR - - -def _format_docker_command_for_logging(cmd: list[str]) -> str: - """Format a Docker command for logging with grey color. - - Args: - cmd (list[str]): The Docker command as a list of strings - - Returns: - str: The formatted command string in grey HTML color - """ - cmd_str = ' '.join(cmd) - return f'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.""" - path = Path(PERSISTENCE_DIR) - path.mkdir(exist_ok=True, parents=True) - return path - - -def get_openhands_version() -> str: - """Get the OpenHands version for Docker images. - - Returns: - str: The version string to use for Docker images - """ - # For now, use 'latest' as the default version - # In the future, this could be read from a version file or environment variable - return os.environ.get('OPENHANDS_VERSION', 'latest') - - -def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None: - """Launch the OpenHands GUI server using Docker. - - Args: - mount_cwd: If True, mount the current working directory into the container. - gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker. - """ - print_formatted_text( - HTML('🚀 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 = get_openhands_version() - runtime_image = f'docker.openhands.dev/openhands/runtime:{version}-nikolaik' - app_image = f'docker.openhands.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/openhands_cli/listeners/__init__.py b/openhands-cli/openhands_cli/listeners/__init__.py deleted file mode 100644 index 76725db747..0000000000 --- a/openhands-cli/openhands_cli/listeners/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from openhands_cli.listeners.pause_listener import PauseListener - -__all__ = ['PauseListener'] diff --git a/openhands-cli/openhands_cli/listeners/pause_listener.py b/openhands-cli/openhands_cli/listeners/pause_listener.py deleted file mode 100644 index bb18b9c7aa..0000000000 --- a/openhands-cli/openhands_cli/listeners/pause_listener.py +++ /dev/null @@ -1,83 +0,0 @@ -import threading -from collections.abc import Callable, Iterator -from contextlib import contextmanager - -from prompt_toolkit import HTML, print_formatted_text -from prompt_toolkit.input import Input, create_input -from prompt_toolkit.keys import Keys - -from openhands.sdk import BaseConversation - - -class PauseListener(threading.Thread): - """Background key listener that triggers pause on Ctrl-P. - - Starts and stops around agent run() loops to avoid interfering with user prompts. - """ - - def __init__( - self, - on_pause: Callable, - input_source: Input | None = None, # used to pipe inputs for unit tests - ): - super().__init__(daemon=True) - self.on_pause = on_pause - self._stop_event = threading.Event() - self._pause_event = threading.Event() - self._input = input_source or create_input() - - def _detect_pause_key_presses(self) -> bool: - pause_detected = False - - for key_press in self._input.read_keys(): - pause_detected = pause_detected or key_press.key == Keys.ControlP - pause_detected = pause_detected or key_press.key == Keys.ControlC - pause_detected = pause_detected or key_press.key == Keys.ControlD - - return pause_detected - - def _execute_pause(self) -> None: - self._pause_event.set() # Mark pause event occurred - print_formatted_text(HTML('')) - print_formatted_text( - HTML('Pausing agent once step is completed...') - ) - try: - self.on_pause() - except Exception: - pass - - def run(self) -> None: - try: - with self._input.raw_mode(): - # User hasn't paused and pause listener hasn't been shut down - while not (self.is_paused() or self.is_stopped()): - if self._detect_pause_key_presses(): - self._execute_pause() - finally: - try: - self._input.close() - except Exception: - pass - - def stop(self) -> None: - self._stop_event.set() - - def is_stopped(self) -> bool: - return self._stop_event.is_set() - - def is_paused(self) -> bool: - return self._pause_event.is_set() - - -@contextmanager -def pause_listener( - conversation: BaseConversation, input_source: Input | None = None -) -> Iterator[PauseListener]: - """Ensure PauseListener always starts/stops cleanly.""" - listener = PauseListener(on_pause=conversation.pause, input_source=input_source) - listener.start() - try: - yield listener - finally: - listener.stop() diff --git a/openhands-cli/openhands_cli/locations.py b/openhands-cli/openhands_cli/locations.py deleted file mode 100644 index fe01a30a28..0000000000 --- a/openhands-cli/openhands_cli/locations.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -# Configuration directory for storing agent settings and CLI configuration -PERSISTENCE_DIR = os.path.expanduser('~/.openhands') -CONVERSATIONS_DIR = os.path.join(PERSISTENCE_DIR, 'conversations') - -# Working directory for agent operations (current directory where CLI is run) -WORK_DIR = os.getcwd() - -AGENT_SETTINGS_PATH = 'agent_settings.json' - -# MCP configuration file (relative to PERSISTENCE_DIR) -MCP_CONFIG_FILE = 'mcp.json' diff --git a/openhands-cli/openhands_cli/pt_style.py b/openhands-cli/openhands_cli/pt_style.py deleted file mode 100644 index 3b4ade6c9a..0000000000 --- a/openhands-cli/openhands_cli/pt_style.py +++ /dev/null @@ -1,30 +0,0 @@ -from prompt_toolkit.styles import Style, merge_styles -from prompt_toolkit.styles.base import BaseStyle -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() -> BaseStyle: - 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 - 'placeholder': '#888888 italic', - } - ) - return merge_styles([base, custom]) diff --git a/openhands-cli/openhands_cli/runner.py b/openhands-cli/openhands_cli/runner.py deleted file mode 100644 index 0ef15acdff..0000000000 --- a/openhands-cli/openhands_cli/runner.py +++ /dev/null @@ -1,188 +0,0 @@ -from prompt_toolkit import HTML, print_formatted_text - -from openhands.sdk import BaseConversation, Message -from openhands.sdk.conversation.state import ( - ConversationExecutionStatus, - ConversationState, -) -from openhands.sdk.security.confirmation_policy import ( - AlwaysConfirm, - ConfirmationPolicyBase, - ConfirmRisky, - NeverConfirm, -) -from openhands_cli.listeners.pause_listener import PauseListener, pause_listener -from openhands_cli.user_actions import ask_user_confirmation -from openhands_cli.user_actions.types import UserConfirmation -from openhands_cli.setup import setup_conversation - - -class ConversationRunner: - """Handles the conversation state machine logic cleanly.""" - - def __init__(self, conversation: BaseConversation): - self.conversation = conversation - - @property - def is_confirmation_mode_active(self): - return self.conversation.is_confirmation_mode_active - - def toggle_confirmation_mode(self): - new_confirmation_mode_state = not self.is_confirmation_mode_active - - self.conversation = setup_conversation( - self.conversation.id, - include_security_analyzer=new_confirmation_mode_state - ) - - if new_confirmation_mode_state: - # Enable confirmation mode: set AlwaysConfirm policy - self.set_confirmation_policy(AlwaysConfirm()) - else: - # Disable confirmation mode: set NeverConfirm policy and remove security analyzer - self.set_confirmation_policy(NeverConfirm()) - - def set_confirmation_policy( - self, confirmation_policy: ConfirmationPolicyBase - ) -> None: - self.conversation.set_confirmation_policy(confirmation_policy) - - - def _start_listener(self) -> None: - self.listener = PauseListener(on_pause=self.conversation.pause) - self.listener.start() - - def _print_run_status(self) -> None: - print_formatted_text('') - if ( - self.conversation.state.execution_status - == ConversationExecutionStatus.PAUSED - ): - print_formatted_text( - HTML( - 'Resuming paused conversation... (Press Ctrl-P to pause)' - ) - ) - - else: - print_formatted_text( - HTML( - 'Agent running... (Press Ctrl-P to pause)' - ) - ) - print_formatted_text('') - - def process_message(self, message: Message | None) -> None: - """Process a user message through the conversation. - - Args: - message: The user message to process - """ - - self._print_run_status() - - # Send message to conversation - if message: - self.conversation.send_message(message) - - if self.is_confirmation_mode_active: - self._run_with_confirmation() - else: - self._run_without_confirmation() - - def _run_without_confirmation(self) -> None: - with pause_listener(self.conversation): - self.conversation.run() - - def _run_with_confirmation(self) -> None: - # If agent was paused, resume with confirmation request - if ( - self.conversation.state.execution_status - == ConversationExecutionStatus.WAITING_FOR_CONFIRMATION - ): - user_confirmation = self._handle_confirmation_request() - if user_confirmation == UserConfirmation.DEFER: - return - - while True: - with pause_listener(self.conversation) as listener: - self.conversation.run() - - if listener.is_paused(): - break - - # In confirmation mode, agent either finishes or waits for user confirmation - if ( - self.conversation.state.execution_status - == ConversationExecutionStatus.FINISHED - ): - break - - elif ( - self.conversation.state.execution_status - == ConversationExecutionStatus.WAITING_FOR_CONFIRMATION - ): - user_confirmation = self._handle_confirmation_request() - if user_confirmation == UserConfirmation.DEFER: - return - - else: - raise Exception('Infinite loop') - - - def _handle_confirmation_request(self) -> UserConfirmation: - """Handle confirmation request from user. - - Returns: - UserConfirmation indicating the user's choice - """ - - pending_actions = ConversationState.get_unmatched_actions( - self.conversation.state.events - ) - if not pending_actions: - return UserConfirmation.ACCEPT - - result = ask_user_confirmation( - pending_actions, - isinstance(self.conversation.state.confirmation_policy, ConfirmRisky), - ) - decision = result.decision - policy_change = result.policy_change - - if decision == UserConfirmation.REJECT: - self.conversation.reject_pending_actions( - result.reason or 'User rejected the actions' - ) - return decision - - if decision == UserConfirmation.DEFER: - self.conversation.pause() - return decision - - if isinstance(policy_change, NeverConfirm): - print_formatted_text( - HTML( - 'Confirmation mode disabled. Agent will proceed without asking.' - ) - ) - - # Remove security analyzer when policy is never confirm - self.toggle_confirmation_mode() - return decision - - if isinstance(policy_change, ConfirmRisky): - print_formatted_text( - HTML( - 'Security-based confirmation enabled. ' - 'LOW/MEDIUM risk actions will auto-confirm, HIGH risk actions will ask for confirmation.' - ) - ) - - # Keep security analyzer, change existing policy - self.set_confirmation_policy(policy_change) - return decision - - # Accept action without changing existing policies - assert decision == UserConfirmation.ACCEPT - return decision diff --git a/openhands-cli/openhands_cli/setup.py b/openhands-cli/openhands_cli/setup.py deleted file mode 100644 index 91b7ab9464..0000000000 --- a/openhands-cli/openhands_cli/setup.py +++ /dev/null @@ -1,101 +0,0 @@ -import uuid - -from openhands.sdk.conversation import visualizer -from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer -from prompt_toolkit import HTML, print_formatted_text - -from openhands.sdk import Agent, BaseConversation, Conversation, Workspace -from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR -from openhands_cli.tui.settings.store import AgentStore -from openhands.sdk.security.confirmation_policy import ( - AlwaysConfirm, -) -from openhands_cli.tui.settings.settings_screen import SettingsScreen -from openhands_cli.tui.visualizer import CLIVisualizer - -# register tools -from openhands.tools.terminal import TerminalTool -from openhands.tools.file_editor import FileEditorTool -from openhands.tools.task_tracker import TaskTrackerTool - - -class MissingAgentSpec(Exception): - """Raised when agent specification is not found or invalid.""" - - pass - - - -def load_agent_specs( - conversation_id: str | None = None, -) -> Agent: - agent_store = AgentStore() - agent = agent_store.load(session_id=conversation_id) - if not agent: - raise MissingAgentSpec( - 'Agent specification not found. Please configure your agent settings.' - ) - return agent - - -def verify_agent_exists_or_setup_agent() -> Agent: - """Verify agent specs exists by attempting to load it. - - """ - settings_screen = SettingsScreen() - try: - agent = load_agent_specs() - return agent - except MissingAgentSpec: - # For first-time users, show the full settings flow with choice between basic/advanced - settings_screen.configure_settings(first_time=True) - - - # Try once again after settings setup attempt - return load_agent_specs() - - -def setup_conversation( - conversation_id: uuid, - include_security_analyzer: bool = True -) -> BaseConversation: - """ - Setup the conversation with agent. - - Args: - conversation_id: conversation ID to use. If not provided, a random UUID will be generated. - - Raises: - MissingAgentSpec: If agent specification is not found or invalid. - """ - - print_formatted_text( - HTML(f'Initializing agent...') - ) - - agent = load_agent_specs(str(conversation_id)) - - - - # Create conversation - agent context is now set in AgentStore.load() - conversation: BaseConversation = Conversation( - agent=agent, - workspace=Workspace(working_dir=WORK_DIR), - # Conversation will add / to this path - persistence_dir=CONVERSATIONS_DIR, - conversation_id=conversation_id, - visualizer=CLIVisualizer - ) - - # Security analyzer is set though conversation API now - if not include_security_analyzer: - conversation.set_security_analyzer(None) - else: - conversation.set_security_analyzer(LLMSecurityAnalyzer()) - conversation.set_confirmation_policy(AlwaysConfirm()) - - print_formatted_text( - HTML(f'✓ Agent initialized with model: {agent.llm.model}') - ) - return conversation - diff --git a/openhands-cli/openhands_cli/simple_main.py b/openhands-cli/openhands_cli/simple_main.py deleted file mode 100644 index 343d37a4d3..0000000000 --- a/openhands-cli/openhands_cli/simple_main.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple main entry point for OpenHands CLI. -This is a simplified version that demonstrates the TUI functionality. -""" - -import logging -import os -import sys -import warnings - -debug_env = os.getenv('DEBUG', 'false').lower() -if debug_env != '1' and debug_env != 'true': - logging.disable(logging.WARNING) - warnings.filterwarnings('ignore') - -from prompt_toolkit import print_formatted_text -from prompt_toolkit.formatted_text import HTML - -from openhands_cli.argparsers.main_parser import create_main_parser - - -def main() -> None: - """Main entry point for the OpenHands CLI. - - Raises: - ImportError: If agent chat dependencies are missing - Exception: On other error conditions - """ - parser = create_main_parser() - args = parser.parse_args() - - try: - 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) - else: - # Default CLI behavior - no subcommand needed - # Import agent_chat only when needed - from openhands_cli.agent_chat import run_cli_entry - - # Start agent chat - run_cli_entry(resume_conversation_id=args.resume) - except KeyboardInterrupt: - print_formatted_text(HTML('\nGoodbye! 👋')) - except EOFError: - print_formatted_text(HTML('\nGoodbye! 👋')) - except Exception as e: - print_formatted_text(HTML(f'Error: {e}')) - import traceback - - traceback.print_exc() - raise - - -if __name__ == '__main__': - main() diff --git a/openhands-cli/openhands_cli/tui/__init__.py b/openhands-cli/openhands_cli/tui/__init__.py deleted file mode 100644 index 00205468cb..0000000000 --- a/openhands-cli/openhands_cli/tui/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from openhands_cli.tui.tui import DEFAULT_STYLE - -__all__ = [ - 'DEFAULT_STYLE', -] diff --git a/openhands-cli/openhands_cli/tui/settings/mcp_screen.py b/openhands-cli/openhands_cli/tui/settings/mcp_screen.py deleted file mode 100644 index 8284f353b5..0000000000 --- a/openhands-cli/openhands_cli/tui/settings/mcp_screen.py +++ /dev/null @@ -1,217 +0,0 @@ -import json -from pathlib import Path -from typing import Any - -from fastmcp.mcp_config import MCPConfig -from openhands_cli.locations import MCP_CONFIG_FILE, PERSISTENCE_DIR -from prompt_toolkit import HTML, print_formatted_text - -from openhands.sdk import Agent - - -class MCPScreen: - """ - MCP Screen - - 1. Display information about setting up MCP - 2. See existing servers that are setup - 3. Debug additional servers passed via mcp.json - 4. Identify servers waiting to sync on session restart - """ - - # ---------- server spec handlers ---------- - - def _check_server_specs_are_equal( - self, first_server_spec, second_server_spec - ) -> bool: - first_stringified_server_spec = json.dumps(first_server_spec, sort_keys=True) - second_stringified_server_spec = json.dumps(second_server_spec, sort_keys=True) - return first_stringified_server_spec == second_stringified_server_spec - - def _check_mcp_config_status(self) -> dict: - """Check the status of the MCP configuration file and return information about it.""" - config_path = Path(PERSISTENCE_DIR) / MCP_CONFIG_FILE - - if not config_path.exists(): - return { - 'exists': False, - 'valid': False, - 'servers': {}, - 'message': f'MCP configuration file not found at ~/.openhands/{MCP_CONFIG_FILE}', - } - - try: - mcp_config = MCPConfig.from_file(config_path) - servers = mcp_config.to_dict().get('mcpServers', {}) - return { - 'exists': True, - 'valid': True, - 'servers': servers, - 'message': f'Valid MCP configuration found with {len(servers)} server(s)', - } - except Exception as e: - return { - 'exists': True, - 'valid': False, - 'servers': {}, - 'message': f'Invalid MCP configuration file: {str(e)}', - } - - # ---------- TUI helpers ---------- - - def _get_mcp_server_diff( - self, - current: dict[str, Any], - incoming: dict[str, Any], - ) -> None: - """ - Display a diff-style view: - - - Always show the MCP servers the agent is *currently* configured with - - If there are incoming servers (from ~/.openhands/mcp.json), - clearly show which ones are NEW (not in current) and which ones are CHANGED - (same name but different config). Unchanged servers are not repeated. - """ - - print_formatted_text(HTML('Current Agent MCP Servers:')) - if current: - for name, cfg in current.items(): - self._render_server_summary(name, cfg, indent=2) - else: - print_formatted_text( - HTML(' None configured on the current agent.') - ) - print_formatted_text('') - - # If no incoming, we're done - if not incoming: - print_formatted_text( - HTML('No incoming servers detected for next restart.') - ) - print_formatted_text('') - return - - # Compare names and configs - current_names = set(current.keys()) - incoming_names = set(incoming.keys()) - new_servers = sorted(incoming_names - current_names) - - overriden_servers = [] - for name in sorted(incoming_names & current_names): - if not self._check_server_specs_are_equal(current[name], incoming[name]): - overriden_servers.append(name) - - # Display incoming section header - print_formatted_text( - HTML( - 'Incoming Servers on Restart (from ~/.openhands/mcp.json):' - ) - ) - - if not new_servers and not overriden_servers: - print_formatted_text( - HTML( - ' All configured servers match the current agent configuration.' - ) - ) - print_formatted_text('') - return - - if new_servers: - print_formatted_text(HTML(' New servers (will be added):')) - for name in new_servers: - self._render_server_summary(name, incoming[name], indent=4) - - if overriden_servers: - print_formatted_text( - HTML(' Updated servers (configuration will change):') - ) - for name in overriden_servers: - print_formatted_text(HTML(f' • {name}')) - print_formatted_text(HTML(' Current:')) - self._render_server_summary(None, current[name], indent=8) - print_formatted_text(HTML(' Incoming:')) - self._render_server_summary(None, incoming[name], indent=8) - - print_formatted_text('') - - def _render_server_summary( - self, server_name: str | None, server_spec: dict[str, Any], indent: int = 2 - ) -> None: - pad = ' ' * indent - - if server_name: - print_formatted_text(HTML(f'{pad}• {server_name}')) - - if isinstance(server_spec, dict): - if 'command' in server_spec: - cmd = server_spec.get('command', '') - args = server_spec.get('args', []) - args_str = ' '.join(args) if args else '' - print_formatted_text(HTML(f'{pad} Type: Command-based')) - if cmd or args_str: - print_formatted_text( - HTML(f'{pad} Command: {cmd} {args_str}') - ) - elif 'url' in server_spec: - url = server_spec.get('url', '') - auth = server_spec.get('auth', 'none') - print_formatted_text(HTML(f'{pad} Type: URL-based')) - if url: - print_formatted_text(HTML(f'{pad} URL: {url}')) - print_formatted_text(HTML(f'{pad} Auth: {auth}')) - - def _display_information_header(self) -> None: - print_formatted_text( - HTML('MCP (Model Context Protocol) Configuration') - ) - print_formatted_text('') - print_formatted_text(HTML('To get started:')) - print_formatted_text( - HTML( - ' 1. Create the configuration file: ~/.openhands/mcp.json' - ) - ) - print_formatted_text( - HTML( - ' 2. Add your MCP server configurations ' - 'https://gofastmcp.com/clients/client#configuration-format' - ) - ) - print_formatted_text( - HTML(' 3. Restart your OpenHands session to load the new configuration') - ) - print_formatted_text('') - - # ---------- status + display entrypoint ---------- - - def display_mcp_info(self, existing_agent: Agent) -> None: - """Display comprehensive MCP configuration information.""" - - self._display_information_header() - - # Always determine current & incoming first - status = self._check_mcp_config_status() - incoming_servers = status.get('servers', {}) if status.get('valid') else {} - current_servers = existing_agent.mcp_config.get('mcpServers', {}) - - # Show file status - if not status['exists']: - print_formatted_text( - HTML('Status: Configuration file not found') - ) - - elif not status['valid']: - print_formatted_text(HTML(f'Status: {status["message"]}')) - print_formatted_text('') - print_formatted_text( - HTML('Please check your configuration file format.') - ) - else: - print_formatted_text(HTML(f'Status: {status["message"]}')) - - print_formatted_text('') - - # Always show the agent's current servers - # Then show incoming (deduped and changes highlighted) - self._get_mcp_server_diff(current_servers, incoming_servers) diff --git a/openhands-cli/openhands_cli/tui/settings/settings_screen.py b/openhands-cli/openhands_cli/tui/settings/settings_screen.py deleted file mode 100644 index 0db491fc3e..0000000000 --- a/openhands-cli/openhands_cli/tui/settings/settings_screen.py +++ /dev/null @@ -1,212 +0,0 @@ -import os - -from openhands.sdk import LLM, BaseConversation, LLMSummarizingCondenser, LocalFileStore -from prompt_toolkit import HTML, print_formatted_text -from prompt_toolkit.shortcuts import print_container -from prompt_toolkit.widgets import Frame, TextArea - -from openhands_cli.utils import get_default_cli_agent, get_llm_metadata -from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR -from openhands_cli.pt_style import COLOR_GREY -from openhands_cli.tui.settings.store import AgentStore -from openhands_cli.tui.utils import StepCounter -from openhands_cli.user_actions.settings_action import ( - SettingsType, - choose_llm_model, - choose_llm_provider, - choose_memory_condensation, - prompt_api_key, - prompt_base_url, - prompt_custom_model, - save_settings_confirmation, - settings_type_confirmation, -) - - -class SettingsScreen: - def __init__(self, conversation: BaseConversation | None = None): - self.file_store = LocalFileStore(PERSISTENCE_DIR) - self.agent_store = AgentStore() - self.conversation = conversation - - def display_settings(self) -> None: - agent_spec = self.agent_store.load() - if not agent_spec: - return - - llm = agent_spec.llm - advanced_llm_settings = True if llm.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 = llm.model.split('/')[0] if '/' in llm.model else 'Unknown' - - labels_and_values.extend( - [ - (' LLM Provider', str(provider)), - (' LLM Model', str(llm.model)), - ] - ) - else: - labels_and_values.extend( - [ - (' Custom Model', llm.model), - (' Base URL', llm.base_url), - ] - ) - labels_and_values.extend( - [ - (' API Key', '********' if llm.api_key else 'Not Set'), - ] - ) - - if self.conversation: - labels_and_values.extend([ - ( - ' Confirmation Mode', - 'Enabled' - if self.conversation.is_confirmation_mode_active - else 'Disabled', - ) - ]) - - labels_and_values.extend([ - ( - ' Memory Condensation', - 'Enabled' if agent_spec.condenser else 'Disabled', - ), - ( - ' Configuration File', - os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH), - ), - ] - ) - - # 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) - - self.configure_settings() - - def configure_settings(self, first_time=False): - try: - settings_type = settings_type_confirmation(first_time=first_time) - except KeyboardInterrupt: - return - - if settings_type == SettingsType.BASIC: - self.handle_basic_settings() - elif settings_type == SettingsType.ADVANCED: - self.handle_advanced_settings() - - def handle_basic_settings(self): - step_counter = StepCounter(3) - try: - provider = choose_llm_provider(step_counter, escapable=True) - llm_model = choose_llm_model(step_counter, provider, escapable=True) - api_key = prompt_api_key( - step_counter, - provider, - self.conversation.state.agent.llm.api_key - if self.conversation - else None, - escapable=True, - ) - save_settings_confirmation() - except KeyboardInterrupt: - print_formatted_text(HTML('\nCancelled settings change.')) - return - - # Store the collected settings for persistence - self._save_llm_settings(f'{provider}/{llm_model}', api_key) - - def handle_advanced_settings(self, escapable=True): - """Handle advanced settings configuration with clean step-by-step flow.""" - step_counter = StepCounter(4) - try: - custom_model = prompt_custom_model(step_counter) - base_url = prompt_base_url(step_counter) - api_key = prompt_api_key( - step_counter, - custom_model.split('/')[0] if len(custom_model.split('/')) > 1 else '', - self.conversation.state.agent.llm.api_key if self.conversation else None, - escapable=escapable, - ) - memory_condensation = choose_memory_condensation(step_counter) - - # Confirm save - save_settings_confirmation() - except KeyboardInterrupt: - print_formatted_text(HTML('\nCancelled settings change.')) - return - - # Store the collected settings for persistence - self._save_advanced_settings( - custom_model, base_url, api_key, memory_condensation - ) - - def _save_llm_settings(self, model, api_key, base_url: str | None = None) -> None: - llm = LLM( - model=model, - api_key=api_key, - base_url=base_url, - usage_id='agent', - litellm_extra_body={"metadata": get_llm_metadata(model_name=model, llm_type='agent')}, - ) - - agent = self.agent_store.load() - if not agent: - agent = get_default_cli_agent(llm=llm) - - # Must update all LLMs - agent = agent.model_copy(update={'llm': llm}) - condenser = LLMSummarizingCondenser( - llm=llm.model_copy( - update={"usage_id": "condenser"} - ) - ) - agent = agent.model_copy(update={'condenser': condenser}) - self.agent_store.save(agent) - - def _save_advanced_settings( - self, custom_model: str, base_url: str, api_key: str, memory_condensation: bool - ): - self._save_llm_settings(custom_model, api_key, base_url=base_url) - - agent_spec = self.agent_store.load() - if not agent_spec: - return - - if not memory_condensation: - agent_spec.model_copy(update={'condenser': None}) - - self.agent_store.save(agent_spec) diff --git a/openhands-cli/openhands_cli/tui/settings/store.py b/openhands-cli/openhands_cli/tui/settings/store.py deleted file mode 100644 index 018a7484e0..0000000000 --- a/openhands-cli/openhands_cli/tui/settings/store.py +++ /dev/null @@ -1,104 +0,0 @@ -# openhands_cli/settings/store.py -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from fastmcp.mcp_config import MCPConfig -from openhands_cli.locations import ( - AGENT_SETTINGS_PATH, - MCP_CONFIG_FILE, - PERSISTENCE_DIR, - WORK_DIR, -) -from openhands_cli.utils import get_llm_metadata -from prompt_toolkit import HTML, print_formatted_text - -from openhands.sdk import Agent, AgentContext, LocalFileStore -from openhands.sdk.context.condenser import LLMSummarizingCondenser -from openhands.tools.preset.default import get_default_tools - - -class AgentStore: - """Single source of truth for persisting/retrieving AgentSpec.""" - - def __init__(self) -> None: - self.file_store = LocalFileStore(root=PERSISTENCE_DIR) - - def load_mcp_configuration(self) -> dict[str, Any]: - try: - mcp_config_path = Path(self.file_store.root) / MCP_CONFIG_FILE - mcp_config = MCPConfig.from_file(mcp_config_path) - return mcp_config.to_dict()['mcpServers'] - except Exception: - return {} - - def load(self, session_id: str | None = None) -> Agent | None: - try: - str_spec = self.file_store.read(AGENT_SETTINGS_PATH) - agent = Agent.model_validate_json(str_spec) - - - # Temporary to remove security analyzer from agent specs - # Security analyzer is set via conversation API now - # Doing this so that deprecation warning is thrown only the first time running CLI - if agent.security_analyzer: - agent = agent.model_copy( - update={"security_analyzer": None} - ) - self.save(agent) - - # Update tools with most recent working directory - updated_tools = get_default_tools(enable_browser=False) - - agent_context = AgentContext( - system_message_suffix=f'You current working directory is: {WORK_DIR}', - ) - - mcp_config: dict = self.load_mcp_configuration() - - # Update LLM metadata with current information - agent_llm_metadata = get_llm_metadata( - model_name=agent.llm.model, llm_type='agent', session_id=session_id - ) - updated_llm = agent.llm.model_copy(update={'litellm_extra_body': {'metadata': agent_llm_metadata}}) - - condenser_updates = {} - if agent.condenser and isinstance(agent.condenser, LLMSummarizingCondenser): - condenser_updates['llm'] = agent.condenser.llm.model_copy( - update={ - 'litellm_extra_body': { - 'metadata': get_llm_metadata( - model_name=agent.condenser.llm.model, - llm_type='condenser', - session_id=session_id, - ) - } - } - ) - - # Update tools and context - agent = agent.model_copy( - update={ - 'llm': updated_llm, - 'tools': updated_tools, - 'mcp_config': {'mcpServers': mcp_config} if mcp_config else {}, - 'agent_context': agent_context, - 'condenser': agent.condenser.model_copy(update=condenser_updates) - if agent.condenser - else None, - } - ) - - return agent - except FileNotFoundError: - return None - except Exception: - print_formatted_text( - HTML('\nAgent configuration file is corrupted!') - ) - return None - - def save(self, agent: Agent) -> None: - serialized_spec = agent.model_dump_json(context={'expose_secrets': True}) - self.file_store.write(AGENT_SETTINGS_PATH, serialized_spec) diff --git a/openhands-cli/openhands_cli/tui/status.py b/openhands-cli/openhands_cli/tui/status.py deleted file mode 100644 index 91d0ef0142..0000000000 --- a/openhands-cli/openhands_cli/tui/status.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Status display components for OpenHands CLI TUI.""" - -from datetime import datetime - -from openhands.sdk import BaseConversation -from prompt_toolkit import print_formatted_text -from prompt_toolkit.formatted_text import HTML -from prompt_toolkit.shortcuts import print_container -from prompt_toolkit.widgets import Frame, TextArea - - -def display_status( - conversation: BaseConversation, - session_start_time: datetime, -) -> None: - """Display detailed conversation status including metrics and uptime. - - Args: - conversation: The conversation to display status for - session_start_time: The session start time for uptime calculation - """ - # Get conversation stats - stats = conversation.conversation_stats.get_combined_metrics() - - # Calculate uptime from session start time - now = datetime.now() - diff = now - session_start_time - - # Format as hours, minutes, seconds - total_seconds = int(diff.total_seconds()) - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - seconds = total_seconds % 60 - uptime_str = f"{hours}h {minutes}m {seconds}s" - - # Display conversation ID and uptime - print_formatted_text(HTML(f'Conversation ID: {conversation.id}')) - print_formatted_text(HTML(f'Uptime: {uptime_str}')) - print_formatted_text('') - - # Calculate token metrics - token_usage = stats.accumulated_token_usage - total_input_tokens = token_usage.prompt_tokens if token_usage else 0 - total_output_tokens = token_usage.completion_tokens if token_usage else 0 - cache_hits = token_usage.cache_read_tokens if token_usage else 0 - cache_writes = token_usage.cache_write_tokens if token_usage else 0 - total_tokens = total_input_tokens + total_output_tokens - total_cost = stats.accumulated_cost - - # Use prompt_toolkit containers for formatted display - _display_usage_metrics_container( - total_cost, - total_input_tokens, - total_output_tokens, - cache_hits, - cache_writes, - total_tokens - ) - - -def _display_usage_metrics_container( - total_cost: float, - total_input_tokens: int, - total_output_tokens: int, - cache_hits: int, - cache_writes: int, - total_tokens: int -) -> None: - """Display usage metrics using prompt_toolkit containers.""" - # Format values with proper formatting - cost_str = f'${total_cost:.6f}' - input_tokens_str = f'{total_input_tokens:,}' - cache_read_str = f'{cache_hits:,}' - cache_write_str = f'{cache_writes:,}' - output_tokens_str = f'{total_output_tokens:,}' - total_tokens_str = f'{total_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, - wrap_lines=True, - ), - title='Usage Metrics', - ) - - print_container(container) diff --git a/openhands-cli/openhands_cli/tui/tui.py b/openhands-cli/openhands_cli/tui/tui.py deleted file mode 100644 index b966d877db..0000000000 --- a/openhands-cli/openhands_cli/tui/tui.py +++ /dev/null @@ -1,100 +0,0 @@ -from collections.abc import Generator -from uuid import UUID - -from prompt_toolkit import print_formatted_text -from prompt_toolkit.completion import CompleteEvent, Completer, Completion -from prompt_toolkit.document import Document -from prompt_toolkit.formatted_text import HTML -from prompt_toolkit.shortcuts import clear - -from openhands_cli import __version__ -from openhands_cli.pt_style import get_cli_style - -DEFAULT_STYLE = get_cli_style() - -# Available commands with descriptions -COMMANDS = { - '/exit': 'Exit the application', - '/help': 'Display available commands', - '/clear': 'Clear the screen', - '/new': 'Start a fresh conversation', - '/status': 'Display conversation details', - '/confirm': 'Toggle confirmation mode on/off', - '/resume': 'Resume a paused conversation', - '/settings': 'Display and modify current settings', - '/mcp': 'View MCP (Model Context Protocol) server configuration', -} - - -class CommandCompleter(Completer): - """Custom completer for commands with interactive dropdown.""" - - def get_completions( - self, document: Document, complete_event: CompleteEvent - ) -> Generator[Completion, None, None]: - text = document.text_before_cursor.lstrip() - if text.startswith('/'): - for command, description in COMMANDS.items(): - if command.startswith(text): - yield Completion( - command, - start_position=-len(text), - display_meta=description, - style='bg:ansidarkgray fg:gold', - ) - - -def display_banner(conversation_id: str, resume: bool = False) -> None: - print_formatted_text( - HTML(r""" - ___ _ _ _ - / _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___ - | | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __| - | |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \ - \___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/ - |_| - """), - style=DEFAULT_STYLE, - ) - - print_formatted_text('') - if not resume: - print_formatted_text( - HTML(f'Initialized conversation {conversation_id}') - ) - else: - print_formatted_text( - HTML(f'Resumed conversation {conversation_id}') - ) - print_formatted_text('') - - -def display_help() -> None: - """Display help information about available commands.""" - print_formatted_text('') - print_formatted_text(HTML('🤖 OpenHands CLI Help')) - print_formatted_text(HTML('Available commands:')) - print_formatted_text('') - - for command, description in COMMANDS.items(): - print_formatted_text(HTML(f' {command} - {description}')) - - print_formatted_text('') - print_formatted_text(HTML('Tips:')) - print_formatted_text(' • Type / and press Tab to see command suggestions') - print_formatted_text(' • Use arrow keys to navigate through suggestions') - print_formatted_text(' • Press Enter to select a command') - print_formatted_text('') - - -def display_welcome(conversation_id: UUID, resume: bool = False) -> None: - """Display welcome message.""" - clear() - display_banner(str(conversation_id), resume) - print_formatted_text(HTML("Let's start building!")) - print_formatted_text( - HTML( - 'What do you want to build? Type /help for help' - ) - ) - print() diff --git a/openhands-cli/openhands_cli/tui/utils.py b/openhands-cli/openhands_cli/tui/utils.py deleted file mode 100644 index fbf6200223..0000000000 --- a/openhands-cli/openhands_cli/tui/utils.py +++ /dev/null @@ -1,14 +0,0 @@ -class StepCounter: - """Automatically manages step numbering for settings flows.""" - - def __init__(self, total_steps: int): - self.current_step = 0 - self.total_steps = total_steps - - def next_step(self, prompt: str) -> str: - """Get the next step prompt with automatic numbering.""" - self.current_step += 1 - return f'(Step {self.current_step}/{self.total_steps}) {prompt}' - - def existing_step(self, prompt: str) -> str: - return f'(Step {self.current_step}/{self.total_steps}) {prompt}' diff --git a/openhands-cli/openhands_cli/tui/visualizer.py b/openhands-cli/openhands_cli/tui/visualizer.py deleted file mode 100644 index efcdb338bd..0000000000 --- a/openhands-cli/openhands_cli/tui/visualizer.py +++ /dev/null @@ -1,312 +0,0 @@ -import re - -from rich.console import Console -from rich.panel import Panel -from rich.text import Text - -from openhands.sdk.conversation.visualizer.base import ( - ConversationVisualizerBase, -) -from openhands.sdk.event import ( - ActionEvent, - AgentErrorEvent, - MessageEvent, - ObservationEvent, - PauseEvent, - SystemPromptEvent, - UserRejectObservation, -) -from openhands.sdk.event.base import Event -from openhands.sdk.event.condenser import Condensation - - -# These are external inputs -_OBSERVATION_COLOR = "yellow" -_MESSAGE_USER_COLOR = "gold3" -_PAUSE_COLOR = "bright_yellow" -# These are internal system stuff -_SYSTEM_COLOR = "magenta" -_THOUGHT_COLOR = "bright_black" -_ERROR_COLOR = "red" -# These are agent actions -_ACTION_COLOR = "blue" -_MESSAGE_ASSISTANT_COLOR = _ACTION_COLOR - -DEFAULT_HIGHLIGHT_REGEX = { - r"^Reasoning:": f"bold {_THOUGHT_COLOR}", - r"^Thought:": f"bold {_THOUGHT_COLOR}", - r"^Action:": f"bold {_ACTION_COLOR}", - r"^Arguments:": f"bold {_ACTION_COLOR}", - r"^Tool:": f"bold {_OBSERVATION_COLOR}", - r"^Result:": f"bold {_OBSERVATION_COLOR}", - r"^Rejection Reason:": f"bold {_ERROR_COLOR}", - # Markdown-style - r"\*\*(.*?)\*\*": "bold", - r"\*(.*?)\*": "italic", -} - -_PANEL_PADDING = (1, 1) - - -class CLIVisualizer(ConversationVisualizerBase): - """Handles visualization of conversation events with Rich formatting. - - Provides Rich-formatted output with panels and complete content display. - """ - - _console: Console - _skip_user_messages: bool - _highlight_patterns: dict[str, str] - - def __init__( - self, - name: str | None = None, - highlight_regex: dict[str, str] | None = DEFAULT_HIGHLIGHT_REGEX, - skip_user_messages: bool = False, - ): - """Initialize the visualizer. - - Args: - name: Optional name to prefix in panel titles to identify - which agent/conversation is speaking. - highlight_regex: Dictionary mapping regex patterns to Rich color styles - for highlighting keywords in the visualizer. - For example: {"Reasoning:": "bold blue", - "Thought:": "bold green"} - skip_user_messages: If True, skip displaying user messages. Useful for - scenarios where user input is not relevant to show. - """ - super().__init__( - name=name, - ) - self._console = Console() - self._skip_user_messages = skip_user_messages - self._highlight_patterns = highlight_regex or {} - - def on_event(self, event: Event) -> None: - """Main event handler that displays events with Rich formatting.""" - panel = self._create_event_panel(event) - if panel: - self._console.print(panel) - self._console.print() # Add spacing between events - - def _apply_highlighting(self, text: Text) -> Text: - """Apply regex-based highlighting to text content. - - Args: - text: The Rich Text object to highlight - - Returns: - A new Text object with highlighting applied - """ - if not self._highlight_patterns: - return text - - # Create a copy to avoid modifying the original - highlighted = text.copy() - - # Apply each pattern using Rich's built-in highlight_regex method - for pattern, style in self._highlight_patterns.items(): - pattern_compiled = re.compile(pattern, re.MULTILINE) - highlighted.highlight_regex(pattern_compiled, style) - - return highlighted - - def _create_event_panel(self, event: Event) -> Panel | None: - """Create a Rich Panel for the event with appropriate styling.""" - # Use the event's visualize property for content - content = event.visualize - - if not content.plain.strip(): - return None - - # Apply highlighting if configured - if self._highlight_patterns: - content = self._apply_highlighting(content) - - # Don't emit system prompt in CLI - if isinstance(event, SystemPromptEvent): - title = f"[bold {_SYSTEM_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"System Prompt[/bold {_SYSTEM_COLOR}]" - return None - elif isinstance(event, ActionEvent): - # Check if action is None (non-executable) - title = f"[bold {_ACTION_COLOR}]" - if self._name: - title += f"{self._name} " - if event.action is None: - title += f"Agent Action (Not Executed)[/bold {_ACTION_COLOR}]" - else: - title += f"Agent Action[/bold {_ACTION_COLOR}]" - return Panel( - content, - title=title, - subtitle=self._format_metrics_subtitle(), - border_style=_ACTION_COLOR, - padding=_PANEL_PADDING, - expand=True, - ) - elif isinstance(event, ObservationEvent): - title = f"[bold {_OBSERVATION_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"Observation[/bold {_OBSERVATION_COLOR}]" - return Panel( - content, - title=title, - border_style=_OBSERVATION_COLOR, - padding=_PANEL_PADDING, - expand=True, - ) - elif isinstance(event, UserRejectObservation): - title = f"[bold {_ERROR_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"User Rejected Action[/bold {_ERROR_COLOR}]" - return Panel( - content, - title=title, - border_style=_ERROR_COLOR, - padding=_PANEL_PADDING, - expand=True, - ) - elif isinstance(event, MessageEvent): - if ( - self._skip_user_messages - and event.llm_message - and event.llm_message.role == "user" - ): - return - assert event.llm_message is not None - # Role-based styling - role_colors = { - "user": _MESSAGE_USER_COLOR, - "assistant": _MESSAGE_ASSISTANT_COLOR, - } - role_color = role_colors.get(event.llm_message.role, "white") - - # "User Message To [Name] Agent" for user - # "Message from [Name] Agent" for agent - agent_name = f"{self._name} " if self._name else "" - - if event.llm_message.role == "user": - title_text = ( - f"[bold {role_color}]User Message to " - f"{agent_name}Agent[/bold {role_color}]" - ) - else: - title_text = ( - f"[bold {role_color}]Message from " - f"{agent_name}Agent[/bold {role_color}]" - ) - return Panel( - content, - title=title_text, - subtitle=self._format_metrics_subtitle(), - border_style=role_color, - padding=_PANEL_PADDING, - expand=True, - ) - elif isinstance(event, AgentErrorEvent): - title = f"[bold {_ERROR_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"Agent Error[/bold {_ERROR_COLOR}]" - return Panel( - content, - title=title, - subtitle=self._format_metrics_subtitle(), - border_style=_ERROR_COLOR, - padding=_PANEL_PADDING, - expand=True, - ) - elif isinstance(event, PauseEvent): - title = f"[bold {_PAUSE_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"User Paused[/bold {_PAUSE_COLOR}]" - return Panel( - content, - title=title, - border_style=_PAUSE_COLOR, - padding=_PANEL_PADDING, - expand=True, - ) - elif isinstance(event, Condensation): - title = f"[bold {_SYSTEM_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"Condensation[/bold {_SYSTEM_COLOR}]" - return Panel( - content, - title=title, - subtitle=self._format_metrics_subtitle(), - border_style=_SYSTEM_COLOR, - expand=True, - ) - else: - # Fallback panel for unknown event types - title = f"[bold {_ERROR_COLOR}]" - if self._name: - title += f"{self._name} " - title += f"UNKNOWN Event: {event.__class__.__name__}[/bold {_ERROR_COLOR}]" - return Panel( - content, - title=title, - subtitle=f"({event.source})", - border_style=_ERROR_COLOR, - padding=_PANEL_PADDING, - expand=True, - ) - - def _format_metrics_subtitle(self) -> str | None: - """Format LLM metrics as a visually appealing subtitle string with icons, - colors, and k/m abbreviations using conversation stats.""" - stats = self.conversation_stats - if not stats: - return None - - combined_metrics = stats.get_combined_metrics() - if not combined_metrics or not combined_metrics.accumulated_token_usage: - return None - - usage = combined_metrics.accumulated_token_usage - cost = combined_metrics.accumulated_cost or 0.0 - - # helper: 1234 -> "1.2K", 1200000 -> "1.2M" - def abbr(n: int | float) -> str: - n = int(n or 0) - if n >= 1_000_000_000: - val, suffix = n / 1_000_000_000, "B" - elif n >= 1_000_000: - val, suffix = n / 1_000_000, "M" - elif n >= 1_000: - val, suffix = n / 1_000, "K" - else: - return str(n) - return f"{val:.2f}".rstrip("0").rstrip(".") + suffix - - input_tokens = abbr(usage.prompt_tokens or 0) - output_tokens = abbr(usage.completion_tokens or 0) - - # Cache hit rate (prompt + cache) - prompt = usage.prompt_tokens or 0 - cache_read = usage.cache_read_tokens or 0 - cache_rate = f"{(cache_read / prompt * 100):.2f}%" if prompt > 0 else "N/A" - reasoning_tokens = usage.reasoning_tokens or 0 - - # Cost - cost_str = f"{cost:.4f}" if cost > 0 else "0.00" - - # Build with fixed color scheme - parts: list[str] = [] - parts.append(f"[cyan]↑ input {input_tokens}[/cyan]") - parts.append(f"[magenta]cache hit {cache_rate}[/magenta]") - if reasoning_tokens > 0: - parts.append(f"[yellow] reasoning {abbr(reasoning_tokens)}[/yellow]") - parts.append(f"[blue]↓ output {output_tokens}[/blue]") - parts.append(f"[green]$ {cost_str}[/green]") - - return "Tokens: " + " • ".join(parts) diff --git a/openhands-cli/openhands_cli/user_actions/__init__.py b/openhands-cli/openhands_cli/user_actions/__init__.py deleted file mode 100644 index 9bfa461c6a..0000000000 --- a/openhands-cli/openhands_cli/user_actions/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from openhands_cli.user_actions.agent_action import ask_user_confirmation -from openhands_cli.user_actions.exit_session import ( - exit_session_confirmation, -) -from openhands_cli.user_actions.settings_action import ( - choose_llm_provider, - settings_type_confirmation, -) -from openhands_cli.user_actions.types import UserConfirmation - -__all__ = [ - 'ask_user_confirmation', - 'exit_session_confirmation', - 'UserConfirmation', - 'settings_type_confirmation', - 'choose_llm_provider', -] diff --git a/openhands-cli/openhands_cli/user_actions/agent_action.py b/openhands-cli/openhands_cli/user_actions/agent_action.py deleted file mode 100644 index 630cae7f52..0000000000 --- a/openhands-cli/openhands_cli/user_actions/agent_action.py +++ /dev/null @@ -1,80 +0,0 @@ -import html -from prompt_toolkit import HTML, print_formatted_text - -from openhands.sdk.security.confirmation_policy import ( - ConfirmRisky, - NeverConfirm, - SecurityRisk, -) -from openhands_cli.user_actions.types import ConfirmationResult, UserConfirmation -from openhands_cli.user_actions.utils import cli_confirm, cli_text_input - - -def ask_user_confirmation( - pending_actions: list, using_risk_based_policy: bool = False -) -> ConfirmationResult: - """Ask user to confirm pending actions. - - Args: - pending_actions: List of pending actions from the agent - - Returns: - ConfirmationResult with decision, optional policy_change, and reason - """ - - if not pending_actions: - return ConfirmationResult(decision=UserConfirmation.ACCEPT) - - print_formatted_text( - HTML( - f'🔍 Agent created {len(pending_actions)} action(s) and is waiting for confirmation:' - ) - ) - - for i, action in enumerate(pending_actions, 1): - tool_name = getattr(action, 'tool_name', '[unknown tool]') - action_content = ( - str(getattr(action, 'action', ''))[:100].replace('\n', ' ') - or '[unknown action]' - ) - print_formatted_text( - HTML(f' {i}. {tool_name}: {html.escape(action_content)}...') - ) - - question = 'Choose an option:' - options = [ - 'Yes, proceed', - 'Reject', - "Always proceed (don't ask again)", - ] - - if not using_risk_based_policy: - options.append('Auto-confirm LOW/MEDIUM risk, ask for HIGH risk') - - try: - index = cli_confirm(question, options, escapable=True) - except (EOFError, KeyboardInterrupt): - print_formatted_text(HTML('\nNo input received; pausing agent.')) - return ConfirmationResult(decision=UserConfirmation.DEFER) - - if index == 0: - return ConfirmationResult(decision=UserConfirmation.ACCEPT) - elif index == 1: - # Handle "Reject" option with optional reason - try: - reason = cli_text_input('Reason (and let OpenHands know why): ').strip() - except (EOFError, KeyboardInterrupt): - return ConfirmationResult(decision=UserConfirmation.DEFER) - - return ConfirmationResult(decision=UserConfirmation.REJECT, reason=reason) - elif index == 2: - return ConfirmationResult( - decision=UserConfirmation.ACCEPT, policy_change=NeverConfirm() - ) - elif index == 3: - return ConfirmationResult( - decision=UserConfirmation.ACCEPT, - policy_change=ConfirmRisky(threshold=SecurityRisk.HIGH), - ) - - return ConfirmationResult(decision=UserConfirmation.REJECT) diff --git a/openhands-cli/openhands_cli/user_actions/exit_session.py b/openhands-cli/openhands_cli/user_actions/exit_session.py deleted file mode 100644 index c624d5209b..0000000000 --- a/openhands-cli/openhands_cli/user_actions/exit_session.py +++ /dev/null @@ -1,18 +0,0 @@ -from openhands_cli.user_actions.types import UserConfirmation -from openhands_cli.user_actions.utils import cli_confirm - - -def exit_session_confirmation() -> UserConfirmation: - """ - Ask user to confirm exiting session. - """ - - question = 'Terminate session?' - options = ['Yes, proceed', 'No, dismiss'] - index = cli_confirm(question, options) # Blocking UI, not escapable - - options_mapping = { - 0: UserConfirmation.ACCEPT, # User accepts termination session - 1: UserConfirmation.REJECT, # User does not terminate session - } - return options_mapping.get(index, UserConfirmation.REJECT) diff --git a/openhands-cli/openhands_cli/user_actions/settings_action.py b/openhands-cli/openhands_cli/user_actions/settings_action.py deleted file mode 100644 index e41e08bdb0..0000000000 --- a/openhands-cli/openhands_cli/user_actions/settings_action.py +++ /dev/null @@ -1,171 +0,0 @@ -from enum import Enum - -from openhands.sdk.llm import UNVERIFIED_MODELS_EXCLUDING_BEDROCK, VERIFIED_MODELS -from prompt_toolkit.completion import FuzzyWordCompleter -from pydantic import SecretStr - -from openhands_cli.tui.utils import StepCounter -from openhands_cli.user_actions.utils import ( - NonEmptyValueValidator, - cli_confirm, - cli_text_input, -) - - -class SettingsType(Enum): - BASIC = 'basic' - ADVANCED = 'advanced' - - -def settings_type_confirmation(first_time: bool = False) -> SettingsType: - question = ( - '\nWelcome to OpenHands! Let\'s configure your LLM settings.\n' - 'Choose your preferred setup method:' - ) - choices = [ - 'LLM (Basic)', - 'LLM (Advanced)' - ] - if not first_time: - question = 'Which settings would you like to modify?' - choices.append('Go back') - - - index = cli_confirm(question, choices, escapable=True) - - if choices[index] == 'Go back': - raise KeyboardInterrupt - - options_map = {0: SettingsType.BASIC, 1: SettingsType.ADVANCED} - - return options_map.get(index) - - -def choose_llm_provider(step_counter: StepCounter, escapable=True) -> str: - question = step_counter.next_step( - 'Select LLM Provider (TAB for options, CTRL-c to cancel): ' - ) - options = ( - list(VERIFIED_MODELS.keys()).copy() - + list(UNVERIFIED_MODELS_EXCLUDING_BEDROCK.keys()).copy() - ) - alternate_option = 'Select another provider' - - display_options = options[:4] + [alternate_option] - - index = cli_confirm(question, display_options, escapable=escapable) - chosen_option = display_options[index] - if display_options[index] != alternate_option: - return chosen_option - - question = step_counter.existing_step( - 'Type LLM Provider (TAB to complete, CTRL-c to cancel): ' - ) - return cli_text_input( - question, escapable=True, completer=FuzzyWordCompleter(options, WORD=True) - ) - - -def choose_llm_model(step_counter: StepCounter, provider: str, escapable=True) -> str: - """Choose LLM model using spec-driven approach. Return (model, deferred).""" - - models = VERIFIED_MODELS.get( - provider, [] - ) + UNVERIFIED_MODELS_EXCLUDING_BEDROCK.get(provider, []) - - if provider == 'openhands': - question = ( - step_counter.next_step('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' - ) - else: - question = step_counter.next_step( - 'Select LLM Model (TAB for options, CTRL-c to cancel): ' - ) - alternate_option = 'Select another model' - display_options = models[:4] + [alternate_option] - index = cli_confirm(question, display_options, escapable=escapable) - chosen_option = display_options[index] - - if chosen_option != alternate_option: - return chosen_option - - question = step_counter.existing_step( - 'Type model id (TAB to complete, CTRL-c to cancel): ' - ) - - return cli_text_input( - question, escapable=True, completer=FuzzyWordCompleter(models, WORD=True) - ) - - -def prompt_api_key( - step_counter: StepCounter, - provider: str, - existing_api_key: SecretStr | None = None, - escapable=True, -) -> str: - helper_text = ( - '\nYou can find your OpenHands LLM API Key in the API Keys tab of OpenHands Cloud: ' - 'https://app.all-hands.dev/settings/api-keys\n' - if provider == 'openhands' - else '' - ) - - if existing_api_key: - masked_key = existing_api_key.get_secret_value()[:3] + '***' - question = f'Enter API Key [{masked_key}] (CTRL-c to cancel, ENTER to keep current, type new to change): ' - # For existing keys, allow empty input to keep current key - validator = None - else: - question = 'Enter API Key (CTRL-c to cancel): ' - # For new keys, require non-empty input - validator = NonEmptyValueValidator() - - question = helper_text + step_counter.next_step(question) - user_input = cli_text_input( - question, escapable=escapable, validator=validator, is_password=True - ) - - # If user pressed ENTER with existing key (empty input), return the existing key - if existing_api_key and not user_input.strip(): - return existing_api_key.get_secret_value() - - return user_input - - -# Advanced settings functions -def prompt_custom_model(step_counter: StepCounter, escapable=True) -> str: - """Prompt for custom model name.""" - question = step_counter.next_step('Custom Model (CTRL-c to cancel): ') - return cli_text_input(question, escapable=escapable) - - -def prompt_base_url(step_counter: StepCounter, escapable=True) -> str: - """Prompt for base URL.""" - question = step_counter.next_step('Base URL (CTRL-c to cancel): ') - return cli_text_input( - question, escapable=escapable, validator=NonEmptyValueValidator() - ) - - -def choose_memory_condensation(step_counter: StepCounter, escapable=True) -> bool: - """Choose memory condensation setting.""" - question = step_counter.next_step('Memory Condensation (CTRL-c to cancel): ') - choices = ['Enable', 'Disable'] - - index = cli_confirm(question, choices, escapable=escapable) - return index == 0 # True for Enable, False for Disable - - -def save_settings_confirmation() -> bool: - """Prompt user to confirm saving settings.""" - question = 'Save new settings? (They will take effect after restart)' - discard = 'No, discard' - options = ['Yes, save', discard] - - index = cli_confirm(question, options, escapable=True) - if options[index] == discard: - raise KeyboardInterrupt - - return options[index] diff --git a/openhands-cli/openhands_cli/user_actions/types.py b/openhands-cli/openhands_cli/user_actions/types.py deleted file mode 100644 index 472f5b02d0..0000000000 --- a/openhands-cli/openhands_cli/user_actions/types.py +++ /dev/null @@ -1,18 +0,0 @@ -from enum import Enum -from typing import Optional - -from pydantic import BaseModel - -from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase - - -class UserConfirmation(Enum): - ACCEPT = 'accept' - REJECT = 'reject' - DEFER = 'defer' - - -class ConfirmationResult(BaseModel): - decision: UserConfirmation - policy_change: Optional[ConfirmationPolicyBase] = None - reason: str = '' diff --git a/openhands-cli/openhands_cli/user_actions/utils.py b/openhands-cli/openhands_cli/user_actions/utils.py deleted file mode 100644 index bf27a7782c..0000000000 --- a/openhands-cli/openhands_cli/user_actions/utils.py +++ /dev/null @@ -1,199 +0,0 @@ -from prompt_toolkit import HTML, PromptSession -from prompt_toolkit.application import Application -from prompt_toolkit.completion import Completer -from prompt_toolkit.input.base import Input -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.key_binding.key_processor import KeyPressEvent -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.output.base import Output -from prompt_toolkit.shortcuts import prompt -from prompt_toolkit.validation import ValidationError, Validator - -from openhands_cli.tui import DEFAULT_STYLE -from openhands_cli.tui.tui import CommandCompleter - - -def build_keybindings( - choices: list[str], selected: list[int], escapable: bool -) -> KeyBindings: - """Create keybindings for the confirm UI. Split for testability.""" - kb = KeyBindings() - - @kb.add('up') - def _handle_up(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) - - @kb.add('enter') - def _handle_enter(event: KeyPressEvent) -> None: - event.app.exit(result=selected[0]) - - if escapable: - - @kb.add('c-c') # Ctrl+C - def _handle_hard_interrupt(event: KeyPressEvent) -> None: - event.app.exit(exception=KeyboardInterrupt()) - - @kb.add('c-p') # Ctrl+P - def _handle_pause_interrupt(event: KeyPressEvent) -> None: - event.app.exit(exception=KeyboardInterrupt()) - - @kb.add('escape') # Escape key - def _handle_escape(event: KeyPressEvent) -> None: - event.app.exit(exception=KeyboardInterrupt()) - - return kb - - -def build_layout(question: str, choices: list[str], selected_ref: list[int]) -> Layout: - """Create the layout for the confirm UI. Split for testability.""" - - def get_choice_text() -> list[tuple[str, str]]: - lines: list[tuple[str, str]] = [] - lines.append(('class:question', f'{question}\n\n')) - for i, choice in enumerate(choices): - is_selected = i == selected_ref[0] - prefix = '> ' if is_selected else ' ' - style = 'class:selected' if is_selected else 'class:unselected' - lines.append((style, f'{prefix}{choice}\n')) - return lines - - content_window = Window( - FormattedTextControl(get_choice_text), - always_hide_cursor=True, - height=Dimension(max=8), - ) - return Layout(HSplit([content_window])) - - -def cli_confirm( - question: str = 'Are you sure?', - choices: list[str] | None = None, - initial_selection: int = 0, - escapable: bool = False, - input: Input | None = None, # strictly for unit testing - output: Output | None = None, # strictly for unit testing -) -> 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 - - kb = build_keybindings(choices, selected, escapable) - layout = build_layout(question, choices, selected) - - app = Application( - layout=layout, - key_bindings=kb, - style=DEFAULT_STYLE, - full_screen=False, - input=input, - output=output, - ) - - return int(app.run(in_thread=True)) - - -def cli_text_input( - question: str, - escapable: bool = True, - completer: Completer | None = None, - validator: Validator = None, - is_password: bool = False, -) -> str: - """Prompt user to enter text input with optional validation. - - Args: - question: The prompt question to display - escapable: Whether the user can escape with Ctrl+C or Ctrl+P - completer: Optional completer for tab completion - validator: Optional callable that takes a string and returns True if valid. - If validation fails, the callable should display error messages - and the user will be reprompted. - - Returns: - The validated user input string (stripped of whitespace) - """ - - kb = KeyBindings() - - if escapable: - - @kb.add('c-c') - def _(event: KeyPressEvent) -> None: - event.app.exit(exception=KeyboardInterrupt()) - - @kb.add('c-p') - def _(event: KeyPressEvent) -> None: - event.app.exit(exception=KeyboardInterrupt()) - - @kb.add('enter') - def _handle_enter(event: KeyPressEvent): - event.app.exit(result=event.current_buffer.text) - - reason = str( - prompt( - question, - style=DEFAULT_STYLE, - key_bindings=kb, - completer=completer, - is_password=is_password, - validator=validator, - ) - ) - return reason.strip() - - -def get_session_prompter( - input: Input | None = None, # strictly for unit testing - output: Output | None = None, # strictly for unit testing -) -> PromptSession: - bindings = KeyBindings() - - @bindings.add('\\', 'enter') - def _(event: KeyPressEvent) -> None: - # Typing '\' + Enter forces a newline regardless - event.current_buffer.insert_text('\n') - - @bindings.add('enter') - def _handle_enter(event: KeyPressEvent): - event.app.exit(result=event.current_buffer.text) - - @bindings.add('c-c') - def _keyboard_interrupt(event: KeyPressEvent): - event.app.exit(exception=KeyboardInterrupt()) - - session = PromptSession( - completer=CommandCompleter(), - key_bindings=bindings, - prompt_continuation=lambda width, line_number, is_soft_wrap: '...', - multiline=True, - input=input, - output=output, - style=DEFAULT_STYLE, - placeholder=HTML( - '' - 'Type your message… (tip: press \\ + Enter to insert a newline)' - '' - ), - ) - - return session - - -class NonEmptyValueValidator(Validator): - def validate(self, document): - text = document.text - if not text: - raise ValidationError( - message='API key cannot be empty. Please enter a valid API key.' - ) diff --git a/openhands-cli/openhands_cli/utils.py b/openhands-cli/openhands_cli/utils.py deleted file mode 100644 index 50571cd7be..0000000000 --- a/openhands-cli/openhands_cli/utils.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Utility functions for LLM configuration in OpenHands CLI.""" - -import os -from typing import Any -from openhands.tools.preset import get_default_agent -from openhands.sdk import LLM - -def get_llm_metadata( - model_name: str, - llm_type: str, - session_id: str | None = None, - user_id: str | None = None, -) -> dict[str, Any]: - """ - Generate LLM metadata for OpenHands CLI. - - Args: - model_name: Name of the LLM model - agent_name: Name of the agent (defaults to "openhands") - session_id: Optional session identifier - user_id: Optional user identifier - - Returns: - Dictionary containing metadata for LLM initialization - """ - # Import here to avoid circular imports - openhands_sdk_version: str = 'n/a' - try: - import openhands.sdk - - openhands_sdk_version = openhands.sdk.__version__ - except (ModuleNotFoundError, AttributeError): - pass - - openhands_tools_version: str = 'n/a' - try: - import openhands.tools - - openhands_tools_version = openhands.tools.__version__ - except (ModuleNotFoundError, AttributeError): - pass - - metadata = { - 'trace_version': openhands_sdk_version, - 'tags': [ - 'app:openhands', - f'model:{model_name}', - f'type:{llm_type}', - f'web_host:{os.environ.get("WEB_HOST", "unspecified")}', - f'openhands_sdk_version:{openhands_sdk_version}', - f'openhands_tools_version:{openhands_tools_version}', - ], - } - if session_id is not None: - metadata['session_id'] = session_id - if user_id is not None: - metadata['trace_user_id'] = user_id - return metadata - - -def get_default_cli_agent( - llm: LLM -): - agent = get_default_agent( - llm=llm, - cli_mode=True - ) - - return agent diff --git a/openhands-cli/pyproject.toml b/openhands-cli/pyproject.toml index 7d2e600e2a..2d0e101d5f 100644 --- a/openhands-cli/pyproject.toml +++ b/openhands-cli/pyproject.toml @@ -24,8 +24,6 @@ dependencies = [ "typer>=0.17.4", ] -scripts = { openhands = "openhands_cli.simple_main:main" } - [dependency-groups] # Hatchling wheel target: include the package directory dev = [ @@ -46,9 +44,6 @@ dev = [ [tool.hatch.metadata] allow-direct-references = true -[tool.hatch.build.targets.wheel] -packages = [ "openhands_cli" ] - # uv source pins for internal packages [tool.black] @@ -86,12 +81,6 @@ line_length = 88 relative_files = true omit = [ "tests/*", "**/test_*" ] -[tool.coverage.paths] -source = [ - "openhands_cli/", - "openhands-cli/openhands_cli/", -] - [tool.mypy] python_version = "3.12" warn_return_any = true diff --git a/openhands-cli/tests/__init__.py b/openhands-cli/tests/__init__.py deleted file mode 100644 index 91a8d7c4bb..0000000000 --- a/openhands-cli/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for OpenHands CLI.""" diff --git a/openhands-cli/tests/commands/test_confirm_command.py b/openhands-cli/tests/commands/test_confirm_command.py deleted file mode 100644 index 95c8d0a4e0..0000000000 --- a/openhands-cli/tests/commands/test_confirm_command.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 - -from unittest.mock import MagicMock, patch, call -import pytest - -from openhands_cli.runner import ConversationRunner -from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm - -CONV_ID = "test-conversation-id" - - -# ---------- Helpers ---------- -def make_conv(enabled: bool) -> MagicMock: - """Return a conversation mock in enabled/disabled confirmation mode.""" - m = MagicMock() - m.id = CONV_ID - m.agent.security_analyzer = MagicMock() if enabled else None - m.confirmation_policy_active = enabled - m.is_confirmation_mode_active = enabled - return m - - -@pytest.fixture -def runner_disabled() -> ConversationRunner: - """Runner starting with confirmation mode disabled.""" - return ConversationRunner(make_conv(enabled=False)) - - -@pytest.fixture -def runner_enabled() -> ConversationRunner: - """Runner starting with confirmation mode enabled.""" - return ConversationRunner(make_conv(enabled=True)) - - -# ---------- Core toggle behavior (parametrized) ---------- -@pytest.mark.parametrize( - "start_enabled, include_security_analyzer, expected_enabled, expected_policy_cls", - [ - # disabled -> enable - (False, True, True, AlwaysConfirm), - # enabled -> disable - (True, False, False, NeverConfirm), - ], -) -def test_toggle_confirmation_mode_transitions( - start_enabled, include_security_analyzer, expected_enabled, expected_policy_cls -): - # Arrange: pick starting runner & prepare the target conversation - runner = ConversationRunner(make_conv(enabled=start_enabled)) - target_conv = make_conv(enabled=expected_enabled) - - with patch("openhands_cli.runner.setup_conversation", return_value=target_conv) as mock_setup: - # Act - runner.toggle_confirmation_mode() - - # Assert state - assert runner.is_confirmation_mode_active is expected_enabled - assert runner.conversation is target_conv - - # Assert setup called with same conversation ID + correct analyzer flag - mock_setup.assert_called_once_with(CONV_ID, include_security_analyzer=include_security_analyzer) - - # Assert policy applied to the *new* conversation - target_conv.set_confirmation_policy.assert_called_once() - assert isinstance(target_conv.set_confirmation_policy.call_args.args[0], expected_policy_cls) - - -# ---------- Conversation ID is preserved across multiple toggles ---------- -def test_maintains_conversation_id_across_toggles(runner_disabled: ConversationRunner): - enabled_conv = make_conv(enabled=True) - disabled_conv = make_conv(enabled=False) - - with patch("openhands_cli.runner.setup_conversation") as mock_setup: - mock_setup.side_effect = [enabled_conv, disabled_conv] - - # Toggle on, then off - runner_disabled.toggle_confirmation_mode() - runner_disabled.toggle_confirmation_mode() - - assert runner_disabled.conversation.id == CONV_ID - mock_setup.assert_has_calls( - [ - call(CONV_ID, include_security_analyzer=True), - call(CONV_ID, include_security_analyzer=False), - ], - any_order=False, - ) - - -# ---------- Idempotency under rapid alternating toggles ---------- -def test_rapid_alternating_toggles_produce_expected_states(runner_disabled: ConversationRunner): - enabled_conv = make_conv(enabled=True) - disabled_conv = make_conv(enabled=False) - - with patch("openhands_cli.runner.setup_conversation") as mock_setup: - mock_setup.side_effect = [enabled_conv, disabled_conv, enabled_conv, disabled_conv] - - # Start disabled - assert runner_disabled.is_confirmation_mode_active is False - - # Enable, Disable, Enable, Disable - runner_disabled.toggle_confirmation_mode() - assert runner_disabled.is_confirmation_mode_active is True - - runner_disabled.toggle_confirmation_mode() - assert runner_disabled.is_confirmation_mode_active is False - - runner_disabled.toggle_confirmation_mode() - assert runner_disabled.is_confirmation_mode_active is True - - runner_disabled.toggle_confirmation_mode() - assert runner_disabled.is_confirmation_mode_active is False - - mock_setup.assert_has_calls( - [ - call(CONV_ID, include_security_analyzer=True), - call(CONV_ID, include_security_analyzer=False), - call(CONV_ID, include_security_analyzer=True), - call(CONV_ID, include_security_analyzer=False), - ], - any_order=False, - ) diff --git a/openhands-cli/tests/commands/test_new_command.py b/openhands-cli/tests/commands/test_new_command.py deleted file mode 100644 index a02f69f49b..0000000000 --- a/openhands-cli/tests/commands/test_new_command.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Tests for the /new command functionality.""" - -from unittest.mock import MagicMock, patch -from uuid import UUID - -from prompt_toolkit.input.defaults import create_pipe_input -from prompt_toolkit.output.defaults import DummyOutput - -from openhands_cli.setup import ( - MissingAgentSpec, - verify_agent_exists_or_setup_agent, -) -from openhands_cli.user_actions import UserConfirmation - - -@patch("openhands_cli.setup.load_agent_specs") -def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs): - """Test that verify_agent_exists_or_setup_agent returns agent successfully.""" - # Mock the agent object - mock_agent = MagicMock() - mock_load_agent_specs.return_value = mock_agent - - # Call the function - result = verify_agent_exists_or_setup_agent() - - # Verify the result - assert result == mock_agent - mock_load_agent_specs.assert_called_once_with() - - -@patch("openhands_cli.setup.SettingsScreen") -@patch("openhands_cli.setup.load_agent_specs") -def test_verify_agent_exists_or_setup_agent_missing_agent_spec( - mock_load_agent_specs, mock_settings_screen_class -): - """Test that verify_agent_exists_or_setup_agent handles MissingAgentSpec exception.""" - # Mock the SettingsScreen instance - mock_settings_screen = MagicMock() - mock_settings_screen_class.return_value = mock_settings_screen - - # Mock load_agent_specs to raise MissingAgentSpec on first call, then succeed - mock_agent = MagicMock() - mock_load_agent_specs.side_effect = [ - MissingAgentSpec("Agent spec missing"), - mock_agent, - ] - - # Call the function - result = verify_agent_exists_or_setup_agent() - - # Verify the result - assert result == mock_agent - # Should be called twice: first fails, second succeeds - assert mock_load_agent_specs.call_count == 2 - # Settings screen should be called once with first_time=True (new behavior) - mock_settings_screen.configure_settings.assert_called_once_with(first_time=True) - - -@patch("openhands_cli.agent_chat.exit_session_confirmation") -@patch("openhands_cli.agent_chat.get_session_prompter") -@patch("openhands_cli.agent_chat.setup_conversation") -@patch("openhands_cli.agent_chat.verify_agent_exists_or_setup_agent") -@patch("openhands_cli.agent_chat.ConversationRunner") -def test_new_command_resets_confirmation_mode( - mock_runner_cls, - mock_verify_agent, - mock_setup_conversation, - mock_get_session_prompter, - mock_exit_confirm, -): - # Auto-accept the exit prompt to avoid interactive UI and EOFError - mock_exit_confirm.return_value = UserConfirmation.ACCEPT - - # Mock agent verification to succeed - mock_agent = MagicMock() - mock_verify_agent.return_value = mock_agent - - # Mock conversation - only one is created when /new is called - conv1 = MagicMock() - conv1.id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - mock_setup_conversation.return_value = conv1 - - # One runner instance for the conversation - runner1 = MagicMock() - runner1.is_confirmation_mode_active = True - mock_runner_cls.return_value = runner1 - - # Real session fed by a pipe (no interactive confirmation now) - from openhands_cli.user_actions.utils import ( - get_session_prompter as real_get_session_prompter, - ) - - with create_pipe_input() as pipe: - output = DummyOutput() - session = real_get_session_prompter(input=pipe, output=output) - mock_get_session_prompter.return_value = session - - from openhands_cli.agent_chat import run_cli_entry - - # Trigger /new - # First user message should trigger runner creation - # Then /exit (exit will be auto-accepted) - for ch in "/new\rhello\r/exit\r": - pipe.send_text(ch) - - run_cli_entry(None) - - # Assert we created one runner for the conversation when a message was processed after /new - assert mock_runner_cls.call_count == 1 - assert mock_runner_cls.call_args_list[0].args[0] is conv1 diff --git a/openhands-cli/tests/commands/test_resume_command.py b/openhands-cli/tests/commands/test_resume_command.py deleted file mode 100644 index af9a040f18..0000000000 --- a/openhands-cli/tests/commands/test_resume_command.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Tests for the /resume command functionality.""" - -from unittest.mock import MagicMock, patch -from uuid import UUID -import pytest -from prompt_toolkit.input.defaults import create_pipe_input -from prompt_toolkit.output.defaults import DummyOutput - -from openhands.sdk.conversation.state import ConversationExecutionStatus -from openhands_cli.user_actions import UserConfirmation - - -# ---------- Fixtures & helpers ---------- - -@pytest.fixture -def mock_agent(): - """Mock agent for verification.""" - return MagicMock() - - -@pytest.fixture -def mock_conversation(): - """Mock conversation with default settings.""" - conv = MagicMock() - conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') - return conv - - -@pytest.fixture -def mock_runner(): - """Mock conversation runner.""" - return MagicMock() - - -def run_resume_command_test(commands, agent_status=None, expect_runner_created=True): - """Helper function to run resume command tests with common setup.""" - with patch('openhands_cli.agent_chat.exit_session_confirmation') as mock_exit_confirm, \ - patch('openhands_cli.agent_chat.get_session_prompter') as mock_get_session_prompter, \ - patch('openhands_cli.agent_chat.setup_conversation') as mock_setup_conversation, \ - patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent') as mock_verify_agent, \ - patch('openhands_cli.agent_chat.ConversationRunner') as mock_runner_cls: - - # Auto-accept the exit prompt to avoid interactive UI - mock_exit_confirm.return_value = UserConfirmation.ACCEPT - - # Mock agent verification to succeed - mock_agent = MagicMock() - mock_verify_agent.return_value = mock_agent - - # Mock conversation setup - conv = MagicMock() - conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') - if agent_status: - conv.state.execution_status = agent_status - mock_setup_conversation.return_value = conv - - # Mock runner - runner = MagicMock() - runner.conversation = conv - mock_runner_cls.return_value = runner - - # Real session fed by a pipe - from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter - with create_pipe_input() as pipe: - output = DummyOutput() - session = real_get_session_prompter(input=pipe, output=output) - mock_get_session_prompter.return_value = session - - from openhands_cli.agent_chat import run_cli_entry - - # Send commands - for ch in commands: - pipe.send_text(ch) - - # Capture printed output - with patch('openhands_cli.agent_chat.print_formatted_text') as mock_print: - run_cli_entry(None) - - return mock_runner_cls, runner, mock_print - - -# ---------- Warning tests (parametrized) ---------- - -@pytest.mark.parametrize( - "commands,expected_warning,expect_runner_created", - [ - # No active conversation - /resume immediately - ("/resume\r/exit\r", "No active conversation running", False), - # Conversation exists but not in paused state - send message first, then /resume - ("hello\r/resume\r/exit\r", "No paused conversation to resume", True), - ], -) -def test_resume_command_warnings(commands, expected_warning, expect_runner_created): - """Test /resume command shows appropriate warnings.""" - # Set agent status to FINISHED for the "conversation exists but not paused" test - agent_status = ConversationExecutionStatus.FINISHED if expect_runner_created else None - - mock_runner_cls, runner, mock_print = run_resume_command_test( - commands, agent_status=agent_status, expect_runner_created=expect_runner_created - ) - - # Verify warning message was printed - warning_calls = [call for call in mock_print.call_args_list - if expected_warning in str(call)] - assert len(warning_calls) > 0, f"Expected warning about {expected_warning}" - - # Verify runner creation expectation - if expect_runner_created: - assert mock_runner_cls.call_count == 1 - runner.process_message.assert_called() - else: - assert mock_runner_cls.call_count == 0 - - -# ---------- Successful resume tests (parametrized) ---------- - -@pytest.mark.parametrize( - "agent_status", - [ - ConversationExecutionStatus.PAUSED, - ConversationExecutionStatus.WAITING_FOR_CONFIRMATION, - ], -) -def test_resume_command_successful_resume(agent_status): - """Test /resume command successfully resumes paused/waiting conversations.""" - commands = "hello\r/resume\r/exit\r" - - mock_runner_cls, runner, mock_print = run_resume_command_test( - commands, agent_status=agent_status, expect_runner_created=True - ) - - # Verify runner was created and process_message was called - assert mock_runner_cls.call_count == 1 - - # Verify process_message was called twice: once with the initial message, once with None for resume - assert runner.process_message.call_count == 2 - - # Check the calls to process_message - calls = runner.process_message.call_args_list - - # First call should have a message (the "hello" message) - first_call_args = calls[0][0] - assert first_call_args[0] is not None, "First call should have a message" - - # Second call should have None (the /resume command) - second_call_args = calls[1][0] - assert second_call_args[0] is None, "Second call should have None message for resume" diff --git a/openhands-cli/tests/commands/test_settings_command.py b/openhands-cli/tests/commands/test_settings_command.py deleted file mode 100644 index b822242517..0000000000 --- a/openhands-cli/tests/commands/test_settings_command.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Test for the /settings command functionality.""" - -from unittest.mock import MagicMock, patch -from prompt_toolkit.input.defaults import create_pipe_input -from prompt_toolkit.output.defaults import DummyOutput - -from openhands_cli.agent_chat import run_cli_entry -from openhands_cli.user_actions import UserConfirmation - - -@patch('openhands_cli.agent_chat.exit_session_confirmation') -@patch('openhands_cli.agent_chat.get_session_prompter') -@patch('openhands_cli.agent_chat.setup_conversation') -@patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent') -@patch('openhands_cli.agent_chat.ConversationRunner') -@patch('openhands_cli.agent_chat.SettingsScreen') -def test_settings_command_works_without_conversation( - mock_settings_screen_class, - mock_runner_cls, - mock_verify_agent, - mock_setup_conversation, - mock_get_session_prompter, - mock_exit_confirm, -): - """Test that /settings command works when no conversation is active (bug fix scenario).""" - # Auto-accept the exit prompt to avoid interactive UI - mock_exit_confirm.return_value = UserConfirmation.ACCEPT - - # Mock agent verification to succeed - mock_agent = MagicMock() - mock_verify_agent.return_value = mock_agent - - # Mock the SettingsScreen instance - mock_settings_screen = MagicMock() - mock_settings_screen_class.return_value = mock_settings_screen - - # No runner initially (simulates starting CLI without a conversation) - mock_runner_cls.return_value = None - - # Real session fed by a pipe - from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter - with create_pipe_input() as pipe: - output = DummyOutput() - session = real_get_session_prompter(input=pipe, output=output) - mock_get_session_prompter.return_value = session - - # Trigger /settings, then /exit (exit will be auto-accepted) - for ch in "/settings\r/exit\r": - pipe.send_text(ch) - - run_cli_entry(None) - - # Assert SettingsScreen was created with None conversation (the bug fix) - mock_settings_screen_class.assert_called_once_with(None) - - # Assert display_settings was called (settings screen was shown) - mock_settings_screen.display_settings.assert_called_once() \ No newline at end of file diff --git a/openhands-cli/tests/commands/test_status_command.py b/openhands-cli/tests/commands/test_status_command.py deleted file mode 100644 index a8f0c778cd..0000000000 --- a/openhands-cli/tests/commands/test_status_command.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Simplified tests for the /status command functionality.""" - -from datetime import datetime, timedelta -from uuid import uuid4 -from unittest.mock import Mock, patch - -import pytest - -from openhands_cli.tui.status import display_status -from openhands.sdk.llm.utils.metrics import Metrics, TokenUsage - - -# ---------- Fixtures & helpers ---------- - -@pytest.fixture -def conversation(): - """Minimal conversation with empty events and pluggable stats.""" - conv = Mock() - conv.id = uuid4() - conv.state = Mock(events=[]) - conv.conversation_stats = Mock() - return conv - - -def make_metrics(cost=None, usage=None) -> Metrics: - m = Metrics() - if cost is not None: - m.accumulated_cost = cost - m.accumulated_token_usage = usage - return m - - -def call_display_status(conversation, session_start): - """Call display_status with prints patched; return (mock_pf, mock_pc, text).""" - with patch('openhands_cli.tui.status.print_formatted_text') as pf, \ - patch('openhands_cli.tui.status.print_container') as pc: - display_status(conversation, session_start_time=session_start) - # First container call; extract the Frame/TextArea text - container = pc.call_args_list[0][0][0] - text = getattr(container.body, "text", "") - return pf, pc, str(text) - - -# ---------- Tests ---------- - -def test_display_status_box_title(conversation): - session_start = datetime.now() - conversation.conversation_stats.get_combined_metrics.return_value = make_metrics() - - with patch('openhands_cli.tui.status.print_formatted_text') as pf, \ - patch('openhands_cli.tui.status.print_container') as pc: - display_status(conversation, session_start_time=session_start) - - assert pf.called and pc.called - - container = pc.call_args_list[0][0][0] - assert hasattr(container, "title") - assert "Usage Metrics" in container.title - - -@pytest.mark.parametrize( - "delta,expected", - [ - (timedelta(seconds=0), "0h 0m"), - (timedelta(minutes=5, seconds=30), "5m"), - (timedelta(hours=1, minutes=30, seconds=45), "1h 30m"), - (timedelta(hours=2, minutes=15, seconds=30), "2h 15m"), - ], -) -def test_display_status_uptime(conversation, delta, expected): - session_start = datetime.now() - delta - conversation.conversation_stats.get_combined_metrics.return_value = make_metrics() - - with patch('openhands_cli.tui.status.print_formatted_text') as pf, \ - patch('openhands_cli.tui.status.print_container'): - display_status(conversation, session_start_time=session_start) - # uptime is printed in the 2nd print_formatted_text call - uptime_call_str = str(pf.call_args_list[1]) - assert expected in uptime_call_str - # conversation id appears in the first print call - id_call_str = str(pf.call_args_list[0]) - assert str(conversation.id) in id_call_str - - -@pytest.mark.parametrize( - "cost,usage,expecteds", - [ - # Empty/zero case - (None, None, ["$0.000000", "0", "0", "0", "0", "0"]), - # Only cost, usage=None - (0.05, None, ["$0.050000", "0", "0", "0", "0", "0"]), - # Full metrics - ( - 0.123456, - TokenUsage( - prompt_tokens=1500, - completion_tokens=800, - cache_read_tokens=200, - cache_write_tokens=100, - ), - ["$0.123456", "1,500", "800", "200", "100", "2,300"], - ), - # Larger numbers (comprehensive) - ( - 1.234567, - TokenUsage( - prompt_tokens=5000, - completion_tokens=3000, - cache_read_tokens=500, - cache_write_tokens=250, - ), - ["$1.234567", "5,000", "3,000", "500", "250", "8,000"], - ), - ], -) -def test_display_status_metrics(conversation, cost, usage, expecteds): - session_start = datetime.now() - conversation.conversation_stats.get_combined_metrics.return_value = make_metrics(cost, usage) - - pf, pc, text = call_display_status(conversation, session_start) - - assert pf.called and pc.called - for expected in expecteds: - assert expected in text diff --git a/openhands-cli/tests/conftest.py b/openhands-cli/tests/conftest.py deleted file mode 100644 index 454b14cb3a..0000000000 --- a/openhands-cli/tests/conftest.py +++ /dev/null @@ -1,56 +0,0 @@ -from unittest.mock import patch - -import pytest - - -# Fixture: mock_verified_models - Simplified model data -@pytest.fixture -def mock_verified_models(): - with ( - patch( - 'openhands_cli.user_actions.settings_action.VERIFIED_MODELS', - { - 'openai': ['gpt-4o', 'gpt-4o-mini'], - 'anthropic': ['claude-3-5-sonnet', 'claude-3-5-haiku'], - }, - ), - patch( - 'openhands_cli.user_actions.settings_action.UNVERIFIED_MODELS_EXCLUDING_BEDROCK', - { - 'openai': ['gpt-custom'], - 'anthropic': [], - 'custom': ['my-model'], - }, - ), - ): - yield - - -# Fixture: mock_cli_interactions - Reusable CLI mock patterns -@pytest.fixture -def mock_cli_interactions(): - class Mocks: - def __init__(self): - self.p_confirm = patch( - 'openhands_cli.user_actions.settings_action.cli_confirm' - ) - self.p_text = patch( - 'openhands_cli.user_actions.settings_action.cli_text_input' - ) - self.cli_confirm = None - self.cli_text_input = None - - def start(self): - self.cli_confirm = self.p_confirm.start() - self.cli_text_input = self.p_text.start() - return self - - def stop(self): - self.p_confirm.stop() - self.p_text.stop() - - mocks = Mocks().start() - try: - yield mocks - finally: - mocks.stop() diff --git a/openhands-cli/tests/settings/test_api_key_preservation.py b/openhands-cli/tests/settings/test_api_key_preservation.py deleted file mode 100644 index 29fa3c405d..0000000000 --- a/openhands-cli/tests/settings/test_api_key_preservation.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Test for API key preservation bug when updating settings.""" - -from unittest.mock import patch -import pytest -from pydantic import SecretStr - -from openhands_cli.user_actions.settings_action import prompt_api_key -from openhands_cli.tui.utils import StepCounter - - -def test_api_key_preservation_when_user_presses_enter(): - """Test that API key is preserved when user presses ENTER to keep current key. - - This test replicates the bug where API keys disappear when updating settings. - When a user presses ENTER to keep the current API key, the function should - return the existing API key, not an empty string. - """ - step_counter = StepCounter(1) - existing_api_key = SecretStr("sk-existing-key-123") - - # Mock cli_text_input to return empty string (simulating user pressing ENTER) - with patch('openhands_cli.user_actions.settings_action.cli_text_input', return_value=''): - result = prompt_api_key( - step_counter=step_counter, - provider='openai', - existing_api_key=existing_api_key, - escapable=True - ) - - # The bug: result is empty string instead of the existing key - # This test will fail initially, demonstrating the bug - assert result == existing_api_key.get_secret_value(), ( - f"Expected existing API key '{existing_api_key.get_secret_value()}' " - f"but got '{result}'. API key should be preserved when user presses ENTER." - ) - - -def test_api_key_update_when_user_enters_new_key(): - """Test that API key is updated when user enters a new key.""" - step_counter = StepCounter(1) - existing_api_key = SecretStr("sk-existing-key-123") - new_api_key = "sk-new-key-456" - - # Mock cli_text_input to return new API key - with patch('openhands_cli.user_actions.settings_action.cli_text_input', return_value=new_api_key): - result = prompt_api_key( - step_counter=step_counter, - provider='openai', - existing_api_key=existing_api_key, - escapable=True - ) - - # Should return the new API key - assert result == new_api_key - - diff --git a/openhands-cli/tests/settings/test_default_agent_security_analyzer.py b/openhands-cli/tests/settings/test_default_agent_security_analyzer.py deleted file mode 100644 index 61ab9b2a2f..0000000000 --- a/openhands-cli/tests/settings/test_default_agent_security_analyzer.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Test that first-time settings screen usage creates a default agent and conversation with security analyzer.""" - -from unittest.mock import patch -import pytest -from openhands_cli.tui.settings.settings_screen import SettingsScreen -from openhands_cli.user_actions.settings_action import SettingsType -from openhands.sdk import LLM, Conversation, Workspace -from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer -from pydantic import SecretStr - - -def test_first_time_settings_creates_default_agent_and_conversation_with_security_analyzer(): - """Test that using the settings screen for the first time creates a default agent and conversation with security analyzer.""" - - # Create a settings screen instance (no conversation initially) - screen = SettingsScreen(conversation=None) - - # Mock all the user interaction steps to simulate first-time setup - with ( - patch( - 'openhands_cli.tui.settings.settings_screen.settings_type_confirmation', - return_value=SettingsType.BASIC, - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_llm_provider', - return_value='openai', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_llm_model', - return_value='gpt-4o-mini', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.prompt_api_key', - return_value='sk-test-key-123', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.save_settings_confirmation', - return_value=True, - ), - ): - # Run the settings configuration workflow - screen.configure_settings(first_time=True) - - # Load the saved agent from the store - saved_agent = screen.agent_store.load() - - # Verify that an agent was created and saved - assert saved_agent is not None, "Agent should be created and saved after first-time settings configuration" - - # Verify that the agent has the expected LLM configuration - assert saved_agent.llm.model == 'openai/gpt-4o-mini', f"Expected model 'openai/gpt-4o-mini', got '{saved_agent.llm.model}'" - assert saved_agent.llm.api_key.get_secret_value() == 'sk-test-key-123', "API key should match the provided value" - - # Test that a conversation can be created with the agent and security analyzer can be set - conversation = Conversation(agent=saved_agent, workspace=Workspace(working_dir='/tmp')) - - # Set security analyzer using the new API - security_analyzer = LLMSecurityAnalyzer() - conversation.set_security_analyzer(security_analyzer) - - # Verify that the security analyzer was set correctly - assert conversation.state.security_analyzer is not None, "Conversation should have a security analyzer" - assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{conversation.state.security_analyzer.kind}'" - - -def test_first_time_settings_with_advanced_configuration(): - """Test that advanced settings also create a default agent and conversation with security analyzer.""" - - screen = SettingsScreen(conversation=None) - - with ( - patch( - 'openhands_cli.tui.settings.settings_screen.settings_type_confirmation', - return_value=SettingsType.ADVANCED, - ), - patch( - 'openhands_cli.tui.settings.settings_screen.prompt_custom_model', - return_value='anthropic/claude-3-5-sonnet', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.prompt_base_url', - return_value='https://api.anthropic.com', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.prompt_api_key', - return_value='sk-ant-test-key', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_memory_condensation', - return_value=True, - ), - patch( - 'openhands_cli.tui.settings.settings_screen.save_settings_confirmation', - return_value=True, - ), - ): - screen.configure_settings(first_time=True) - - saved_agent = screen.agent_store.load() - - # Verify agent creation - assert saved_agent is not None, "Agent should be created with advanced settings" - - # Verify advanced settings were applied - assert saved_agent.llm.model == 'anthropic/claude-3-5-sonnet', "Custom model should be set" - assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set" - - # Test that a conversation can be created with the agent and security analyzer can be set - conversation = Conversation(agent=saved_agent, workspace=Workspace(working_dir='/tmp')) - - # Set security analyzer using the new API - security_analyzer = LLMSecurityAnalyzer() - conversation.set_security_analyzer(security_analyzer) - - # Verify that the security analyzer was set correctly - assert conversation.state.security_analyzer is not None, "Conversation should have a security analyzer" - assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer" \ No newline at end of file diff --git a/openhands-cli/tests/settings/test_first_time_user_settings.py b/openhands-cli/tests/settings/test_first_time_user_settings.py deleted file mode 100644 index 64d4a59a2f..0000000000 --- a/openhands-cli/tests/settings/test_first_time_user_settings.py +++ /dev/null @@ -1,54 +0,0 @@ -from unittest.mock import patch -from openhands_cli.agent_chat import run_cli_entry -import pytest - - -@patch("openhands_cli.agent_chat.print_formatted_text") -@patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation") -@patch("openhands_cli.tui.settings.settings_screen.prompt_api_key") -@patch("openhands_cli.tui.settings.settings_screen.choose_llm_model") -@patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider") -@patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation") -@patch("openhands_cli.tui.settings.store.AgentStore.load") -@pytest.mark.parametrize("interrupt_step", ["settings_type", "provider", "model", "api_key", "save"]) -def test_first_time_users_can_escape_settings_flow_and_exit_app( - mock_agentstore_load, - mock_type, - mock_provider, - mock_model, - mock_api_key, - mock_save, - mock_print, - interrupt_step, -): - """Test that KeyboardInterrupt is handled at each step of basic settings.""" - - # Force first-time user: no saved agent - mock_agentstore_load.return_value = None - - # Happy path defaults - mock_type.return_value = "basic" - mock_provider.return_value = "openai" - mock_model.return_value = "gpt-4o-mini" - mock_api_key.return_value = "sk-test" - mock_save.return_value = True - - # Inject KeyboardInterrupt at the specified step - if interrupt_step == "settings_type": - mock_type.side_effect = KeyboardInterrupt() - elif interrupt_step == "provider": - mock_provider.side_effect = KeyboardInterrupt() - elif interrupt_step == "model": - mock_model.side_effect = KeyboardInterrupt() - elif interrupt_step == "api_key": - mock_api_key.side_effect = KeyboardInterrupt() - elif interrupt_step == "save": - mock_save.side_effect = KeyboardInterrupt() - - # Run - run_cli_entry() - - # Assert graceful messaging - calls = [call.args[0] for call in mock_print.call_args_list] - assert any("Setup is required" in str(c) for c in calls) - assert any("Goodbye!" in str(c) for c in calls) diff --git a/openhands-cli/tests/settings/test_mcp_settings_reconciliation.py b/openhands-cli/tests/settings/test_mcp_settings_reconciliation.py deleted file mode 100644 index 65a5687335..0000000000 --- a/openhands-cli/tests/settings/test_mcp_settings_reconciliation.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Minimal tests: mcp.json overrides persisted agent MCP servers.""" - -import json -from pathlib import Path -from unittest.mock import patch -import pytest -from pydantic import SecretStr - -from openhands.sdk import Agent, LLM -from openhands_cli.locations import MCP_CONFIG_FILE, AGENT_SETTINGS_PATH -from openhands_cli.tui.settings.store import AgentStore - - -# ---------------------- tiny helpers ---------------------- - -def write_json(path: Path, obj: dict) -> None: - path.write_text(json.dumps(obj)) - - -def write_agent(root: Path, agent: Agent) -> None: - (root / AGENT_SETTINGS_PATH).write_text( - agent.model_dump_json(context={"expose_secrets": True}) - ) - - -# ---------------------- fixtures ---------------------- - -@pytest.fixture -def persistence_dir(tmp_path, monkeypatch) -> Path: - # Create root dir and point AgentStore at it - root = tmp_path / "openhands" - root.mkdir() - monkeypatch.setattr("openhands_cli.tui.settings.store.PERSISTENCE_DIR", str(root)) - return root - - -@pytest.fixture -def agent_store() -> AgentStore: - return AgentStore() - - -# ---------------------- tests ---------------------- - -@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[]) -@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={}) -def test_load_overrides_persisted_mcp_with_mcp_json_file( - mock_meta, - mock_tools, - persistence_dir, - agent_store -): - """If agent has MCP servers, mcp.json must replace them entirely.""" - # Persist an agent that already contains MCP servers - persisted_agent = Agent( - llm=LLM(model="gpt-4", api_key=SecretStr("k"), usage_id="svc"), - tools=[], - mcp_config={ - "mcpServers": { - "persistent_server": {"command": "python", "args": ["-m", "old_server"]} - } - }, - ) - write_agent(persistence_dir, persisted_agent) - - # Create mcp.json with different servers (this must fully override) - write_json( - persistence_dir / MCP_CONFIG_FILE, - { - "mcpServers": { - "file_server": {"command": "uvx", "args": ["mcp-server-fetch"]} - } - }, - ) - - loaded = agent_store.load() - assert loaded is not None - # Expect ONLY the MCP json file's config - assert loaded.mcp_config == { - "mcpServers": { - "file_server": { - "command": "uvx", - "args": ["mcp-server-fetch"], - "env": {}, - "transport": "stdio", - } - } - } - - -@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[]) -@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={}) -def test_load_when_mcp_file_missing_ignores_persisted_mcp( - mock_meta, - mock_tools, - persistence_dir, - agent_store -): - """If mcp.json is absent, loaded agent.mcp_config should be empty (persisted MCP ignored).""" - persisted_agent = Agent( - llm=LLM(model="gpt-4", api_key=SecretStr("k"), usage_id="svc"), - tools=[], - mcp_config={ - "mcpServers": { - "persistent_server": {"command": "python", "args": ["-m", "old_server"]} - } - }, - ) - write_agent(persistence_dir, persisted_agent) - - # No mcp.json created - - loaded = agent_store.load() - assert loaded is not None - assert loaded.mcp_config == {} # persisted MCP is ignored if file is missin diff --git a/openhands-cli/tests/settings/test_settings_input.py b/openhands-cli/tests/settings/test_settings_input.py deleted file mode 100644 index 744ba0cdee..0000000000 --- a/openhands-cli/tests/settings/test_settings_input.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -""" -Core Settings Logic tests -""" - -from typing import Any -from unittest.mock import MagicMock - -import pytest -from openhands_cli.user_actions.settings_action import ( - NonEmptyValueValidator, - SettingsType, - choose_llm_model, - choose_llm_provider, - prompt_api_key, - settings_type_confirmation, -) -from prompt_toolkit.completion import FuzzyWordCompleter -from prompt_toolkit.validation import ValidationError -from pydantic import SecretStr - -# ------------------------------- -# Settings type selection -# ------------------------------- - - -def test_settings_type_selection(mock_cli_interactions: Any) -> None: - mocks = mock_cli_interactions - - # Basic - mocks.cli_confirm.return_value = 0 - assert settings_type_confirmation() == SettingsType.BASIC - - # Cancel/Go back - mocks.cli_confirm.return_value = 2 - with pytest.raises(KeyboardInterrupt): - settings_type_confirmation() - - -# ------------------------------- -# Provider selection flows -# ------------------------------- - - -def test_provider_selection_with_predefined_options( - mock_verified_models: Any, mock_cli_interactions: Any -) -> None: - from openhands_cli.tui.utils import StepCounter - - mocks = mock_cli_interactions - # first option among display_options is index 0 - mocks.cli_confirm.return_value = 0 - step_counter = StepCounter(1) - result = choose_llm_provider(step_counter) - assert result == 'openai' - - -def test_provider_selection_with_custom_input( - mock_verified_models: Any, mock_cli_interactions: Any -) -> None: - from openhands_cli.tui.utils import StepCounter - - mocks = mock_cli_interactions - # Due to overlapping provider keys between VERIFIED and UNVERIFIED in fixture, - # display_options contains 4 providers (with duplicates) + alternate at index 4 - mocks.cli_confirm.return_value = 4 - mocks.cli_text_input.return_value = 'my-provider' - step_counter = StepCounter(1) - result = choose_llm_provider(step_counter) - assert result == 'my-provider' - - # Verify fuzzy completer passed - _, kwargs = mocks.cli_text_input.call_args - assert isinstance(kwargs['completer'], FuzzyWordCompleter) - - -# ------------------------------- -# Model selection flows -# ------------------------------- - - -def test_model_selection_flows( - mock_verified_models: Any, mock_cli_interactions: Any -) -> None: - from openhands_cli.tui.utils import StepCounter - - mocks = mock_cli_interactions - - # Direct pick from predefined list - mocks.cli_confirm.return_value = 0 - step_counter = StepCounter(1) - result = choose_llm_model(step_counter, 'openai') - assert result in ['gpt-4o'] - - # Choose custom model via input - mocks.cli_confirm.return_value = 4 # for provider with >=4 models this would be alt; in our data openai has 3 -> alt index is 3 - mocks.cli_text_input.return_value = 'custom-model' - # Adjust to actual alt index produced by code (len(models[:4]) yields 3 + 1 alt -> index 3) - mocks.cli_confirm.return_value = 3 - step_counter2 = StepCounter(1) - result2 = choose_llm_model(step_counter2, 'openai') - assert result2 == 'custom-model' - - -# ------------------------------- -# API key validation and prompting -# ------------------------------- - - -def test_api_key_validation_and_prompting(mock_cli_interactions: Any) -> None: - # Validator standalone - validator = NonEmptyValueValidator() - doc = MagicMock() - doc.text = 'sk-abc' - validator.validate(doc) - - doc_empty = MagicMock() - doc_empty.text = '' - with pytest.raises(ValidationError): - validator.validate(doc_empty) - - # Prompting for new key enforces validator - from openhands_cli.tui.utils import StepCounter - - mocks = mock_cli_interactions - mocks.cli_text_input.return_value = 'sk-new' - step_counter = StepCounter(1) - new_key = prompt_api_key(step_counter, 'provider') - assert new_key == 'sk-new' - assert mocks.cli_text_input.call_args[1]['validator'] is not None - - # Prompting with existing key shows mask and no validator - mocks.cli_text_input.reset_mock() - mocks.cli_text_input.return_value = 'sk-updated' - existing = SecretStr('sk-existing-123') - step_counter2 = StepCounter(1) - updated = prompt_api_key(step_counter2, 'provider', existing) - assert updated == 'sk-updated' - assert mocks.cli_text_input.call_args[1]['validator'] is None - assert 'sk-***' in mocks.cli_text_input.call_args[0][0] diff --git a/openhands-cli/tests/settings/test_settings_workflow.py b/openhands-cli/tests/settings/test_settings_workflow.py deleted file mode 100644 index 157b3cddad..0000000000 --- a/openhands-cli/tests/settings/test_settings_workflow.py +++ /dev/null @@ -1,210 +0,0 @@ -import json -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -from openhands_cli.tui.settings.settings_screen import SettingsScreen -from openhands_cli.tui.settings.store import AgentStore -from openhands_cli.user_actions.settings_action import SettingsType -from openhands_cli.utils import get_default_cli_agent -from pydantic import SecretStr - -from openhands.sdk import LLM, Conversation, LocalFileStore - - -def read_json(path: Path) -> dict: - with open(path, 'r') as f: - return json.load(f) - - -def make_screen_with_conversation(model='openai/gpt-4o-mini', api_key='sk-xyz'): - llm = LLM(model=model, api_key=SecretStr(api_key), usage_id='test-service') - # Conversation(agent) signature may vary across versions; adapt if needed: - from openhands.sdk.agent import Agent - - agent = Agent(llm=llm, tools=[]) - conv = Conversation(agent) - return SettingsScreen(conversation=conv) - - -def seed_file(path: Path, model: str = 'openai/gpt-4o-mini', api_key: str = 'sk-old'): - store = AgentStore() - store.file_store = LocalFileStore(root=str(path)) - agent = get_default_cli_agent( - llm=LLM(model=model, api_key=SecretStr(api_key), usage_id='test-service') - ) - store.save(agent) - - -def test_llm_settings_save_and_load(tmp_path: Path): - """Test that the settings screen can save basic LLM settings.""" - screen = SettingsScreen(conversation=None) - - # Mock the spec store to verify settings are saved - with patch.object(screen.agent_store, 'save') as mock_save: - screen._save_llm_settings(model='openai/gpt-4o-mini', api_key='sk-test-123') - - # Verify that save was called - mock_save.assert_called_once() - - # Get the agent spec that was saved - saved_spec = mock_save.call_args[0][0] - assert saved_spec.llm.model == 'openai/gpt-4o-mini' - assert saved_spec.llm.api_key.get_secret_value() == 'sk-test-123' - - -def test_first_time_setup_workflow(tmp_path: Path): - """Test that the basic settings workflow completes without errors.""" - screen = SettingsScreen() - - with ( - patch( - 'openhands_cli.tui.settings.settings_screen.settings_type_confirmation', - return_value=SettingsType.BASIC, - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_llm_provider', - return_value='openai', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_llm_model', - return_value='gpt-4o-mini', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.prompt_api_key', - return_value='sk-first', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.save_settings_confirmation', - return_value=True, - ), - ): - # The workflow should complete without errors - screen.configure_settings() - - # Since the current implementation doesn't save to file, we just verify the workflow completed - assert True # If we get here, the workflow completed successfully - - -def test_update_existing_settings_workflow(tmp_path: Path): - """Test that the settings update workflow completes without errors.""" - settings_path = tmp_path / 'agent_settings.json' - seed_file(settings_path, model='openai/gpt-4o-mini', api_key='sk-old') - screen = make_screen_with_conversation(model='openai/gpt-4o-mini', api_key='sk-old') - - with ( - patch( - 'openhands_cli.tui.settings.settings_screen.settings_type_confirmation', - return_value=SettingsType.BASIC, - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_llm_provider', - return_value='anthropic', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_llm_model', - return_value='claude-3-5-sonnet', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.prompt_api_key', - return_value='sk-updated', - ), - patch( - 'openhands_cli.tui.settings.settings_screen.save_settings_confirmation', - return_value=True, - ), - ): - # The workflow should complete without errors - screen.configure_settings() - - # Since the current implementation doesn't save to file, we just verify the workflow completed - assert True # If we get here, the workflow completed successfully - - -def test_all_llms_in_agent_are_updated(): - """Test that modifying LLM settings creates multiple LLMs with same API key but different usage_ids.""" - # Create a screen with existing agent settings - screen = SettingsScreen(conversation=None) - initial_llm = LLM(model='openai/gpt-3.5-turbo', api_key=SecretStr('sk-initial'), usage_id='test-service') - initial_agent = get_default_cli_agent(llm=initial_llm) - - # Mock the agent store to return the initial agent and capture the save call - with ( - patch.object(screen.agent_store, 'load', return_value=initial_agent), - patch.object(screen.agent_store, 'save') as mock_save - ): - # Modify the LLM settings with new API key - screen._save_llm_settings(model='openai/gpt-4o-mini', api_key='sk-updated-123') - mock_save.assert_called_once() - - # Get the saved agent from the mock - saved_agent = mock_save.call_args[0][0] - all_llms = list(saved_agent.get_all_llms()) - assert len(all_llms) >= 2, f"Expected at least 2 LLMs, got {len(all_llms)}" - - # Verify all LLMs have the same API key - api_keys = [llm.api_key.get_secret_value() for llm in all_llms] - assert all(api_key == 'sk-updated-123' for api_key in api_keys), \ - f"Not all LLMs have the same API key: {api_keys}" - - # Verify none of the usage_id attributes match - usage_ids = [llm.usage_id for llm in all_llms] - assert len(set(usage_ids)) == len(usage_ids), \ - f"Some usage_ids are duplicated: {usage_ids}" - - -@pytest.mark.parametrize( - 'step_to_cancel', - ['type', 'provider', 'model', 'apikey', 'save'], -) -def test_workflow_cancellation_at_each_step(tmp_path: Path, step_to_cancel: str): - screen = make_screen_with_conversation() - - # Base happy-path patches - patches = { - 'settings_type_confirmation': MagicMock(return_value=SettingsType.BASIC), - 'choose_llm_provider': MagicMock(return_value='openai'), - 'choose_llm_model': MagicMock(return_value='gpt-4o-mini'), - 'prompt_api_key': MagicMock(return_value='sk-new'), - 'save_settings_confirmation': MagicMock(return_value=True), - } - - # Turn one step into a cancel - if step_to_cancel == 'type': - patches['settings_type_confirmation'].side_effect = KeyboardInterrupt() - elif step_to_cancel == 'provider': - patches['choose_llm_provider'].side_effect = KeyboardInterrupt() - elif step_to_cancel == 'model': - patches['choose_llm_model'].side_effect = KeyboardInterrupt() - elif step_to_cancel == 'apikey': - patches['prompt_api_key'].side_effect = KeyboardInterrupt() - elif step_to_cancel == 'save': - patches['save_settings_confirmation'].side_effect = KeyboardInterrupt() - - with ( - patch( - 'openhands_cli.tui.settings.settings_screen.settings_type_confirmation', - patches['settings_type_confirmation'], - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_llm_provider', - patches['choose_llm_provider'], - ), - patch( - 'openhands_cli.tui.settings.settings_screen.choose_llm_model', - patches['choose_llm_model'], - ), - patch( - 'openhands_cli.tui.settings.settings_screen.prompt_api_key', - patches['prompt_api_key'], - ), - patch( - 'openhands_cli.tui.settings.settings_screen.save_settings_confirmation', - patches['save_settings_confirmation'], - ), - patch.object(screen.agent_store, 'save') as mock_save, - ): - screen.configure_settings() - - # No settings should be saved on cancel - mock_save.assert_not_called() diff --git a/openhands-cli/tests/test_confirmation_mode.py b/openhands-cli/tests/test_confirmation_mode.py deleted file mode 100644 index e5832e7522..0000000000 --- a/openhands-cli/tests/test_confirmation_mode.py +++ /dev/null @@ -1,511 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for confirmation mode functionality in OpenHands CLI. -""" - -import os -import uuid -from concurrent.futures import ThreadPoolExecutor -from typing import Any -from unittest.mock import ANY, MagicMock, patch - -import pytest -from openhands_cli.runner import ConversationRunner -from openhands_cli.setup import MissingAgentSpec, setup_conversation -from openhands_cli.user_actions import agent_action, ask_user_confirmation, utils -from openhands_cli.user_actions.types import ConfirmationResult, UserConfirmation -from prompt_toolkit.input.defaults import create_pipe_input -from prompt_toolkit.output.defaults import DummyOutput - -from openhands.sdk import Action -from openhands.sdk.security.confirmation_policy import ( - AlwaysConfirm, - ConfirmRisky, - NeverConfirm, - SecurityRisk, -) -from tests.utils import _send_keys - - -class MockAction(Action): - """Mock action schema for testing.""" - - command: str - - -class TestConfirmationMode: - """Test suite for confirmation mode functionality.""" - - def test_setup_conversation_creates_conversation(self) -> None: - """Test that setup_conversation creates a conversation successfully.""" - with patch.dict(os.environ, {'LLM_MODEL': 'test-model'}): - with ( - patch('openhands_cli.setup.Conversation') as mock_conversation_class, - patch('openhands_cli.setup.AgentStore') as mock_agent_store_class, - patch('openhands_cli.setup.print_formatted_text') as mock_print, - patch('openhands_cli.setup.HTML'), - patch('openhands_cli.setup.uuid') as mock_uuid, - patch('openhands_cli.setup.CLIVisualizer') as mock_visualizer, - ): - # Mock dependencies - mock_conversation_id = MagicMock() - mock_uuid.uuid4.return_value = mock_conversation_id - - # Mock AgentStore - mock_agent_store_instance = MagicMock() - mock_agent_instance = MagicMock() - mock_agent_instance.llm.model = 'test-model' - mock_agent_store_instance.load.return_value = mock_agent_instance - mock_agent_store_class.return_value = mock_agent_store_instance - - # Mock Conversation constructor to return a mock conversation - mock_conversation_instance = MagicMock() - mock_conversation_class.return_value = mock_conversation_instance - - result = setup_conversation(mock_conversation_id) - - # Verify conversation was created and returned - assert result == mock_conversation_instance - mock_agent_store_class.assert_called_once() - mock_agent_store_instance.load.assert_called_once() - mock_conversation_class.assert_called_once_with( - agent=mock_agent_instance, - workspace=ANY, - persistence_dir=ANY, - conversation_id=mock_conversation_id, - visualizer=mock_visualizer - ) - - def test_setup_conversation_raises_missing_agent_spec(self) -> None: - """Test that setup_conversation raises MissingAgentSpec when agent is not found.""" - with ( - patch('openhands_cli.setup.AgentStore') as mock_agent_store_class, - ): - # Mock AgentStore to return None (no agent found) - mock_agent_store_instance = MagicMock() - mock_agent_store_instance.load.return_value = None - mock_agent_store_class.return_value = mock_agent_store_instance - - # Should raise MissingAgentSpec - with pytest.raises(MissingAgentSpec) as exc_info: - setup_conversation(uuid.uuid4()) - - assert 'Agent specification not found' in str(exc_info.value) - mock_agent_store_class.assert_called_once() - mock_agent_store_instance.load.assert_called_once() - - def test_conversation_runner_set_confirmation_mode(self) -> None: - """Test that ConversationRunner can set confirmation policy.""" - - mock_conversation = MagicMock() - mock_conversation.confirmation_policy_active = False - mock_conversation.is_confirmation_mode_active = False - runner = ConversationRunner(mock_conversation) - - # Test enabling confirmation mode - runner.set_confirmation_policy(AlwaysConfirm()) - mock_conversation.set_confirmation_policy.assert_called_with(AlwaysConfirm()) - - # Test disabling confirmation mode - runner.set_confirmation_policy(NeverConfirm()) - mock_conversation.set_confirmation_policy.assert_called_with(NeverConfirm()) - - def test_conversation_runner_initial_state(self) -> None: - """Test that ConversationRunner starts with confirmation mode disabled.""" - - mock_conversation = MagicMock() - mock_conversation.confirmation_policy_active = False - mock_conversation.is_confirmation_mode_active = False - runner = ConversationRunner(mock_conversation) - - # Verify initial state - assert runner.is_confirmation_mode_active is False - - def test_ask_user_confirmation_empty_actions(self) -> None: - """Test that ask_user_confirmation returns ACCEPT for empty actions list.""" - result = ask_user_confirmation([]) - assert isinstance(result, ConfirmationResult) - assert result.decision == UserConfirmation.ACCEPT - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - assert result.policy_change is None - - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_yes(self, mock_cli_confirm: Any) -> None: - """Test that ask_user_confirmation returns ACCEPT when user selects yes.""" - mock_cli_confirm.return_value = 0 # First option (Yes, proceed) - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'ls -la' - - result = ask_user_confirmation([mock_action]) - assert isinstance(result, ConfirmationResult) - assert result.decision == UserConfirmation.ACCEPT - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - assert result.policy_change is None - - @patch('openhands_cli.user_actions.agent_action.cli_text_input') - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_no(self, mock_cli_confirm: Any, mock_cli_text_input: Any) -> None: - """Test that ask_user_confirmation returns REJECT when user selects reject without reason.""" - mock_cli_confirm.return_value = 1 # Second option (Reject) - mock_cli_text_input.return_value = '' # Empty reason (reject without reason) - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'rm -rf /' - - result = ask_user_confirmation([mock_action]) - assert isinstance(result, ConfirmationResult) - assert result.decision == UserConfirmation.REJECT - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - assert result.policy_change is None - mock_cli_text_input.assert_called_once_with('Reason (and let OpenHands know why): ') - - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_y_shorthand(self, mock_cli_confirm: Any) -> None: - """Test that ask_user_confirmation accepts first option as yes.""" - mock_cli_confirm.return_value = 0 # First option (Yes, proceed) - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'echo hello' - - result = ask_user_confirmation([mock_action]) - assert result.decision == UserConfirmation.ACCEPT - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - - @patch('openhands_cli.user_actions.agent_action.cli_text_input') - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_n_shorthand(self, mock_cli_confirm: Any, mock_cli_text_input: Any) -> None: - """Test that ask_user_confirmation accepts second option as reject.""" - mock_cli_confirm.return_value = 1 # Second option (Reject) - mock_cli_text_input.return_value = '' # Empty reason (reject without reason) - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'dangerous command' - - result = ask_user_confirmation([mock_action]) - assert result.decision == UserConfirmation.REJECT - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - mock_cli_text_input.assert_called_once_with('Reason (and let OpenHands know why): ') - - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_invalid_then_yes( - self, mock_cli_confirm: Any - ) -> None: - """Test that ask_user_confirmation handles selection and accepts yes.""" - mock_cli_confirm.return_value = 0 # First option (Yes, proceed) - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'echo test' - - result = ask_user_confirmation([mock_action]) - assert result.decision == UserConfirmation.ACCEPT - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - assert mock_cli_confirm.call_count == 1 - - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_keyboard_interrupt( - self, mock_cli_confirm: Any - ) -> None: - """Test that ask_user_confirmation handles KeyboardInterrupt gracefully.""" - mock_cli_confirm.side_effect = KeyboardInterrupt() - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'echo test' - - result = ask_user_confirmation([mock_action]) - assert result.decision == UserConfirmation.DEFER - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_eof_error(self, mock_cli_confirm: Any) -> None: - """Test that ask_user_confirmation handles EOFError gracefully.""" - mock_cli_confirm.side_effect = EOFError() - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'echo test' - - result = ask_user_confirmation([mock_action]) - assert result.decision == UserConfirmation.DEFER - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - - def test_ask_user_confirmation_multiple_actions(self) -> None: - """Test that ask_user_confirmation displays multiple actions correctly.""" - with ( - patch( - 'openhands_cli.user_actions.agent_action.cli_confirm' - ) as mock_cli_confirm, - patch( - 'openhands_cli.user_actions.agent_action.print_formatted_text' - ) as mock_print, - ): - mock_cli_confirm.return_value = 0 # First option (Yes, proceed) - - mock_action1 = MagicMock() - mock_action1.tool_name = 'bash' - mock_action1.action = 'ls -la' - - mock_action2 = MagicMock() - mock_action2.tool_name = 'str_replace_editor' - mock_action2.action = 'create file.txt' - - result = ask_user_confirmation([mock_action1, mock_action2]) - assert isinstance(result, ConfirmationResult) - assert result.decision == UserConfirmation.ACCEPT - assert result.reason == '' - assert result.policy_change is None - - # Verify that both actions were displayed - assert mock_print.call_count >= 3 # Header + 2 actions - - @patch('openhands_cli.user_actions.agent_action.cli_text_input') - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_no_with_reason( - self, mock_cli_confirm: Any, mock_cli_text_input: Any - ) -> None: - """Test that ask_user_confirmation returns REJECT when user selects 'Reject' and provides a reason.""" - mock_cli_confirm.return_value = 1 # Second option (Reject) - mock_cli_text_input.return_value = 'This action is too risky' - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'rm -rf /' - - result = ask_user_confirmation([mock_action]) - assert isinstance(result, ConfirmationResult) - assert result.decision == UserConfirmation.REJECT - assert result.reason == 'This action is too risky' - assert result.policy_change is None - mock_cli_text_input.assert_called_once_with('Reason (and let OpenHands know why): ') - - @patch('openhands_cli.user_actions.agent_action.cli_text_input') - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_no_with_reason_cancelled( - self, mock_cli_confirm: Any, mock_cli_text_input: Any - ) -> None: - """Test that ask_user_confirmation falls back to DEFER when reason input is cancelled.""" - mock_cli_confirm.return_value = 1 # Second option (Reject) - mock_cli_text_input.side_effect = KeyboardInterrupt() # User cancelled reason input - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'dangerous command' - - result = ask_user_confirmation([mock_action]) - assert result.decision == UserConfirmation.DEFER - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert result.policy_change is None - mock_cli_text_input.assert_called_once_with('Reason (and let OpenHands know why): ') - - @patch('openhands_cli.user_actions.agent_action.cli_text_input') - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_reject_empty_reason( - self, mock_cli_confirm: Any, mock_cli_text_input: Any - ) -> None: - """Test that ask_user_confirmation handles empty reason input correctly.""" - mock_cli_confirm.return_value = 1 # Second option (Reject) - mock_cli_text_input.return_value = ' ' # Whitespace-only reason (should be treated as empty) - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'dangerous command' - - result = ask_user_confirmation([mock_action]) - assert result.decision == UserConfirmation.REJECT - assert isinstance(result, ConfirmationResult) - assert result.reason == '' # Should be empty after stripping whitespace - assert result.policy_change is None - mock_cli_text_input.assert_called_once_with('Reason (and let OpenHands know why): ') - - def test_user_confirmation_is_escapable_e2e( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """E2E: non-escapable should ignore Ctrl-C/Ctrl-P/Esc; only Enter returns.""" - real_cli_confirm = utils.cli_confirm - - with create_pipe_input() as pipe: - output = DummyOutput() - - def wrapper( - question: str, - choices: list[str] | None = None, - initial_selection: int = 0, - escapable: bool = False, - **extra: object, - ) -> int: - # keep original params; inject test IO - return real_cli_confirm( - question=question, - choices=choices, - initial_selection=initial_selection, - escapable=escapable, - input=pipe, - output=output, - ) - - # Patch the symbol the caller uses - monkeypatch.setattr(agent_action, 'cli_confirm', wrapper, raising=True) - - with ThreadPoolExecutor(max_workers=1) as ex: - fut = ex.submit( - ask_user_confirmation, [MockAction(command='echo hello world')] - ) - - _send_keys(pipe, '\x03') # Ctrl-C (ignored) - result = fut.result(timeout=2.0) - assert isinstance(result, ConfirmationResult) - assert ( - result.decision == UserConfirmation.DEFER - ) # escaped confirmation view - assert result.reason == '' - assert result.policy_change is None - - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_always_accept(self, mock_cli_confirm: Any) -> None: - """Test that ask_user_confirmation returns ACCEPT with NeverConfirm policy when user selects third option.""" - mock_cli_confirm.return_value = 2 # Third option (Always proceed) - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'echo test' - - result = ask_user_confirmation([mock_action]) - assert result.decision == UserConfirmation.ACCEPT - assert isinstance(result, ConfirmationResult) - assert result.reason == '' - assert isinstance(result.policy_change, NeverConfirm) - - def test_conversation_runner_handles_always_accept(self) -> None: - """Test that ConversationRunner disables confirmation mode when NeverConfirm policy is returned.""" - mock_conversation = MagicMock() - mock_conversation.confirmation_policy_active = True - mock_conversation.is_confirmation_mode_active = True - runner = ConversationRunner(mock_conversation) - - # Enable confirmation mode first - runner.set_confirmation_policy(AlwaysConfirm()) - assert runner.is_confirmation_mode_active is True - - # Mock get_unmatched_actions to return some actions - with patch( - 'openhands_cli.runner.ConversationState.get_unmatched_actions' - ) as mock_get_actions: - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'echo test' - mock_get_actions.return_value = [mock_action] - - # Mock ask_user_confirmation to return ACCEPT with NeverConfirm policy - with patch('openhands_cli.runner.ask_user_confirmation') as mock_ask: - mock_ask.return_value = ConfirmationResult( - decision=UserConfirmation.ACCEPT, - reason='', - policy_change=NeverConfirm(), - ) - - # Mock print_formatted_text to avoid output during test - with patch('openhands_cli.runner.print_formatted_text'): - # Mock setup_conversation to avoid real conversation creation - with patch('openhands_cli.runner.setup_conversation') as mock_setup: - # Return a new mock conversation with confirmation mode disabled - new_mock_conversation = MagicMock() - new_mock_conversation.id = mock_conversation.id - new_mock_conversation.is_confirmation_mode_active = False - mock_setup.return_value = new_mock_conversation - - result = runner._handle_confirmation_request() - - # Verify that confirmation mode was disabled - assert result == UserConfirmation.ACCEPT - # Should have called setup_conversation to toggle confirmation mode - mock_setup.assert_called_once_with( - mock_conversation.id, include_security_analyzer=False - ) - # Should have called set_confirmation_policy with NeverConfirm on new conversation - new_mock_conversation.set_confirmation_policy.assert_called_with( - NeverConfirm() - ) - - @patch('openhands_cli.user_actions.agent_action.cli_confirm') - def test_ask_user_confirmation_auto_confirm_safe( - self, mock_cli_confirm: Any - ) -> None: - """Test that ask_user_confirmation returns ACCEPT with policy_change when user selects fourth option.""" - mock_cli_confirm.return_value = ( - 3 # Fourth option (Auto-confirm LOW/MEDIUM, ask for HIGH) - ) - - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'echo test' - - result = ask_user_confirmation([mock_action]) - assert isinstance(result, ConfirmationResult) - assert result.decision == UserConfirmation.ACCEPT - assert result.reason == '' - assert result.policy_change is not None - assert isinstance(result.policy_change, ConfirmRisky) - assert result.policy_change.threshold == SecurityRisk.HIGH - - def test_conversation_runner_handles_auto_confirm_safe(self) -> None: - """Test that ConversationRunner sets ConfirmRisky policy when policy_change is provided.""" - mock_conversation = MagicMock() - mock_conversation.confirmation_policy_active = True - mock_conversation.is_confirmation_mode_active = True - runner = ConversationRunner(mock_conversation) - - # Enable confirmation mode first - runner.set_confirmation_policy(AlwaysConfirm()) - assert runner.is_confirmation_mode_active is True - - # Mock get_unmatched_actions to return some actions - with patch( - 'openhands_cli.runner.ConversationState.get_unmatched_actions' - ) as mock_get_actions: - mock_action = MagicMock() - mock_action.tool_name = 'bash' - mock_action.action = 'echo test' - mock_get_actions.return_value = [mock_action] - - # Mock ask_user_confirmation to return ConfirmationResult with policy_change - with patch('openhands_cli.runner.ask_user_confirmation') as mock_ask: - expected_policy = ConfirmRisky(threshold=SecurityRisk.HIGH) - mock_ask.return_value = ConfirmationResult( - decision=UserConfirmation.ACCEPT, - reason='', - policy_change=expected_policy, - ) - - # Mock print_formatted_text to avoid output during test - with patch('openhands_cli.runner.print_formatted_text'): - result = runner._handle_confirmation_request() - - # Verify that security-based confirmation policy was set - assert result == UserConfirmation.ACCEPT - # Should set ConfirmRisky policy with HIGH threshold - mock_conversation.set_confirmation_policy.assert_called_with( - expected_policy - ) diff --git a/openhands-cli/tests/test_conversation_runner.py b/openhands-cli/tests/test_conversation_runner.py deleted file mode 100644 index ef085fa12b..0000000000 --- a/openhands-cli/tests/test_conversation_runner.py +++ /dev/null @@ -1,155 +0,0 @@ -from typing import Any, Self -from unittest.mock import patch - -import pytest -from openhands_cli.runner import ConversationRunner -from openhands_cli.user_actions.types import UserConfirmation -from pydantic import ConfigDict, SecretStr, model_validator - -from openhands.sdk import Conversation, ConversationCallbackType, LocalConversation -from openhands.sdk.agent.base import AgentBase -from openhands.sdk.conversation import ConversationState -from openhands.sdk.conversation.state import ConversationExecutionStatus -from openhands.sdk.llm import LLM -from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm -from unittest.mock import MagicMock - -class FakeLLM(LLM): - @model_validator(mode='after') - def _set_env_side_effects(self) -> Self: - return self - - -def default_config() -> dict[str, Any]: - return { - 'model': 'gpt-4o', - 'api_key': SecretStr('test_key'), - 'num_retries': 2, - 'retry_min_wait': 1, - 'retry_max_wait': 2, - } - - -class FakeAgent(AgentBase): - model_config = ConfigDict(frozen=False) - step_count: int = 0 - finish_on_step: int | None = None - - def init_state( - self, state: ConversationState, on_event: ConversationCallbackType - ) -> None: - pass - - def step( - self, conversation: LocalConversation, on_event: ConversationCallbackType - ) -> None: - self.step_count += 1 - if self.step_count == self.finish_on_step: - conversation.state.execution_status = ConversationExecutionStatus.FINISHED - - -@pytest.fixture() -def agent() -> FakeAgent: - llm = LLM(**default_config(), usage_id='test-service') - return FakeAgent(llm=llm, tools=[]) - - -class TestConversationRunner: - @pytest.mark.parametrize( - 'agent_status', [ConversationExecutionStatus.RUNNING, ConversationExecutionStatus.PAUSED] - ) - def test_non_confirmation_mode_runs_once( - self, agent: FakeAgent, agent_status: ConversationExecutionStatus - ) -> None: - """ - 1. Confirmation mode is not on - 2. Process message resumes paused conversation or continues running conversation - """ - - convo = Conversation(agent) - convo.max_iteration_per_run = 1 - convo.state.execution_status = agent_status - cr = ConversationRunner(convo) - cr.set_confirmation_policy(NeverConfirm()) - cr.process_message(message=None) - - assert agent.step_count == 1 - assert ( - convo.state.execution_status != ConversationExecutionStatus.PAUSED - ) - - @pytest.mark.parametrize( - 'confirmation, final_status, expected_run_calls', - [ - # Case 1: Agent waiting for confirmation; user DEFERS -> early return, no run() - ( - UserConfirmation.DEFER, - ConversationExecutionStatus.WAITING_FOR_CONFIRMATION, - 0, - ), - # Case 2: Agent waiting for confirmation; user ACCEPTS -> run() once, break (finished=True) - ( - UserConfirmation.ACCEPT, - ConversationExecutionStatus.FINISHED, - 1, - ), - ], - ) - def test_confirmation_mode_waiting_and_user_decision_controls_run( - self, - agent: FakeAgent, - confirmation: UserConfirmation, - final_status: ConversationExecutionStatus, - expected_run_calls: int, - ) -> None: - """ - 1. Agent may be paused but is waiting for consent on actions - 2. If paused, we should have asked for confirmation on action - 3. If not paused, we should still ask for confirmation on actions - 4. If deferred no run call to agent should be made - 5. If accepted, run call to agent should be made - """ - if final_status == ConversationExecutionStatus.FINISHED: - agent.finish_on_step = 1 - - convo = Conversation(agent) - - # Set security analyzer using the new API to enable confirmation mode - convo.set_security_analyzer(MagicMock()) - - convo.state.execution_status = ( - ConversationExecutionStatus.WAITING_FOR_CONFIRMATION - ) - cr = ConversationRunner(convo) - cr.set_confirmation_policy(AlwaysConfirm()) - - with patch.object( - cr, '_handle_confirmation_request', return_value=confirmation - ) as mock_confirmation_request: - cr.process_message(message=None) - - mock_confirmation_request.assert_called_once() - assert agent.step_count == expected_run_calls - assert convo.state.execution_status == final_status - - def test_confirmation_mode_not_waiting__runs_once_when_finished_true( - self, agent: FakeAgent - ) -> None: - """ - 1. Agent was not waiting - 2. Agent finished without any actions - 3. Conversation should finished without asking user for instructions - """ - agent.finish_on_step = 1 - convo = Conversation(agent) - convo.state.execution_status = ConversationExecutionStatus.PAUSED - - cr = ConversationRunner(convo) - cr.set_confirmation_policy(AlwaysConfirm()) - - with patch.object(cr, '_handle_confirmation_request') as _mock_h: - cr.process_message(message=None) - - # No confirmation was needed up front; we still expect exactly one run. - assert agent.step_count == 1 - _mock_h.assert_not_called() diff --git a/openhands-cli/tests/test_directory_separation.py b/openhands-cli/tests/test_directory_separation.py deleted file mode 100644 index 444583455f..0000000000 --- a/openhands-cli/tests/test_directory_separation.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests to demonstrate the fix for WORK_DIR and PERSISTENCE_DIR separation.""" - -import os -from unittest.mock import MagicMock, patch - -from openhands_cli.locations import PERSISTENCE_DIR, WORK_DIR -from openhands_cli.tui.settings.store import AgentStore - -from openhands.sdk import LLM, Agent, Tool - - -class TestDirectorySeparation: - """Test that WORK_DIR and PERSISTENCE_DIR are properly separated.""" - - def test_work_dir_and_persistence_dir_are_different(self): - """Test that WORK_DIR and PERSISTENCE_DIR are separate directories.""" - # WORK_DIR should be the current working directory - assert WORK_DIR == os.getcwd() - - # PERSISTENCE_DIR should be ~/.openhands - expected_config_dir = os.path.expanduser('~/.openhands') - assert PERSISTENCE_DIR == expected_config_dir - - # They should be different - assert WORK_DIR != PERSISTENCE_DIR - - def test_agent_store_uses_persistence_dir(self): - """Test that AgentStore uses PERSISTENCE_DIR for file storage.""" - agent_store = AgentStore() - assert agent_store.file_store.root == PERSISTENCE_DIR - - -class TestToolFix: - """Test that tool specs are replaced with default tools using current directory.""" - - def test_tools_replaced_with_default_tools_on_load(self): - """Test that entire tools list is replaced with default tools when loading agent.""" - # Create a mock agent with different tools and working directories - mock_agent = Agent( - llm=LLM(model='test/model', api_key='test-key', usage_id='test-service'), - tools=[ - Tool(name='BashTool'), - Tool(name='FileEditorTool'), - Tool(name='TaskTrackerTool'), - ], - ) - - # Mock the file store to return our test agent - with patch( - 'openhands_cli.tui.settings.store.LocalFileStore' - ) as mock_file_store: - mock_store_instance = MagicMock() - mock_file_store.return_value = mock_store_instance - mock_store_instance.read.return_value = mock_agent.model_dump_json() - - agent_store = AgentStore() - loaded_agent = agent_store.load() - - # Verify the agent was loaded - assert loaded_agent is not None - - # Verify that tools are replaced with default tools - assert ( - len(loaded_agent.tools) == 3 - ) # BashTool, FileEditorTool, TaskTrackerTool - - tool_names = [tool.name for tool in loaded_agent.tools] - assert 'terminal' in tool_names - assert 'file_editor' in tool_names - assert 'task_tracker' in tool_names diff --git a/openhands-cli/tests/test_exit_session_confirmation.py b/openhands-cli/tests/test_exit_session_confirmation.py deleted file mode 100644 index 8525b7d05b..0000000000 --- a/openhands-cli/tests/test_exit_session_confirmation.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for exit_session_confirmation functionality in OpenHands CLI. -""" - -from collections.abc import Iterator -from concurrent.futures import ThreadPoolExecutor -from unittest.mock import MagicMock, patch - -import pytest -from openhands_cli.user_actions import ( - exit_session, - exit_session_confirmation, - utils, -) -from openhands_cli.user_actions.types import UserConfirmation -from prompt_toolkit.input.defaults import create_pipe_input -from prompt_toolkit.output.defaults import DummyOutput - -from tests.utils import _send_keys - -QUESTION = 'Terminate session?' -OPTIONS = ['Yes, proceed', 'No, dismiss'] - - -@pytest.fixture() -def confirm_patch() -> Iterator[MagicMock]: - """Patch cli_confirm once per test and yield the mock.""" - with patch('openhands_cli.user_actions.exit_session.cli_confirm') as m: - yield m - - -def _assert_called_once_with_defaults(mock_cli_confirm: MagicMock) -> None: - """Ensure the question/options are correct and 'escapable' is not enabled.""" - mock_cli_confirm.assert_called_once() - args, kwargs = mock_cli_confirm.call_args - # Positional args - assert args == (QUESTION, OPTIONS) - # Should not opt into escapable mode - assert 'escapable' not in kwargs or kwargs['escapable'] is False - - -class TestExitSessionConfirmation: - """Test suite for exit_session_confirmation functionality.""" - - @pytest.mark.parametrize( - 'index,expected', - [ - (0, UserConfirmation.ACCEPT), # Yes - (1, UserConfirmation.REJECT), # No - (999, UserConfirmation.REJECT), # Invalid => default reject - (-1, UserConfirmation.REJECT), # Negative => default reject - ], - ) - def test_index_mapping( - self, confirm_patch: MagicMock, index: int, expected: UserConfirmation - ) -> None: - """All index-to-result mappings, including invalid/negative, in one place.""" - confirm_patch.return_value = index - - result = exit_session_confirmation() - - assert isinstance(result, UserConfirmation) - assert result == expected - _assert_called_once_with_defaults(confirm_patch) - - def test_exit_session_confirmation_non_escapable_e2e( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """E2E: non-escapable should ignore Ctrl-C/Ctrl-P/Esc; only Enter returns.""" - real_cli_confirm = utils.cli_confirm - - with create_pipe_input() as pipe: - output = DummyOutput() - - def wrapper( - question: str, - choices: list[str] | None = None, - initial_selection: int = 0, - escapable: bool = False, - **extra: object, - ) -> int: - # keep original params; inject test IO - return real_cli_confirm( - question=question, - choices=choices, - initial_selection=initial_selection, - escapable=escapable, - input=pipe, - output=output, - ) - - # Patch the symbol the caller uses - monkeypatch.setattr(exit_session, 'cli_confirm', wrapper, raising=True) - - with ThreadPoolExecutor(max_workers=1) as ex: - fut = ex.submit(exit_session_confirmation) - - _send_keys(pipe, '\x03') # Ctrl-C (ignored) - _send_keys(pipe, '\x10') # Ctrl-P (ignored) - _send_keys(pipe, '\x1b') # Esc (ignored) - - _send_keys(pipe, '\x1b[B') # Arrow Down to "No, dismiss" - _send_keys(pipe, '\r') # Enter - - result = fut.result(timeout=2.0) - assert result == UserConfirmation.REJECT diff --git a/openhands-cli/tests/test_gui_launcher.py b/openhands-cli/tests/test_gui_launcher.py deleted file mode 100644 index 7bf036e91a..0000000000 --- a/openhands-cli/tests/test_gui_launcher.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Tests for GUI launcher functionality.""" - -import os -import subprocess -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from openhands_cli.gui_launcher import ( - _format_docker_command_for_logging, - check_docker_requirements, - get_openhands_version, - launch_gui_server, -) - - -class TestFormatDockerCommand: - """Test the Docker command formatting function.""" - - @pytest.mark.parametrize( - "cmd,expected", - [ - ( - ['docker', 'run', 'hello-world'], - 'Running Docker command: docker run hello-world', - ), - ( - ['docker', 'run', '-it', '--rm', '-p', '3000:3000', 'openhands:latest'], - 'Running Docker command: docker run -it --rm -p 3000:3000 openhands:latest', - ), - ([], 'Running Docker command: '), - ], - ) - def test_format_docker_command(self, cmd, expected): - """Test formatting Docker commands.""" - result = _format_docker_command_for_logging(cmd) - assert result == expected - - -class TestCheckDockerRequirements: - """Test Docker requirements checking.""" - - @pytest.mark.parametrize( - "which_return,run_side_effect,expected_result,expected_print_count", - [ - # Docker not installed - (None, None, False, 2), - # Docker daemon not running - ('/usr/bin/docker', MagicMock(returncode=1), False, 2), - # Docker timeout - ('/usr/bin/docker', subprocess.TimeoutExpired('docker info', 10), False, 2), - # Docker available - ('/usr/bin/docker', MagicMock(returncode=0), True, 0), - ], - ) - @patch('shutil.which') - @patch('subprocess.run') - def test_docker_requirements( - self, mock_run, mock_which, which_return, run_side_effect, expected_result, expected_print_count - ): - """Test Docker requirements checking scenarios.""" - mock_which.return_value = which_return - if run_side_effect is not None: - if isinstance(run_side_effect, Exception): - mock_run.side_effect = run_side_effect - else: - mock_run.return_value = run_side_effect - - with patch('openhands_cli.gui_launcher.print_formatted_text') as mock_print: - result = check_docker_requirements() - - assert result is expected_result - assert mock_print.call_count == expected_print_count - - -class TestGetOpenHandsVersion: - """Test version retrieval.""" - - @pytest.mark.parametrize( - "env_value,expected", - [ - (None, 'latest'), # No environment variable set - ('1.2.3', '1.2.3'), # Environment variable set - ], - ) - def test_version_retrieval(self, env_value, expected): - """Test version retrieval from environment.""" - if env_value: - os.environ['OPENHANDS_VERSION'] = env_value - result = get_openhands_version() - assert result == expected - - -class TestLaunchGuiServer: - """Test GUI server launching.""" - - @patch('openhands_cli.gui_launcher.check_docker_requirements') - @patch('openhands_cli.gui_launcher.print_formatted_text') - def test_launch_gui_server_docker_not_available(self, mock_print, mock_check_docker): - """Test that launch_gui_server exits when Docker is not available.""" - mock_check_docker.return_value = False - - with pytest.raises(SystemExit) as exc_info: - launch_gui_server() - - assert exc_info.value.code == 1 - - @pytest.mark.parametrize( - "pull_side_effect,run_side_effect,expected_exit_code,mount_cwd,gpu", - [ - # Docker pull failure - (subprocess.CalledProcessError(1, 'docker pull'), None, 1, False, False), - # Docker run failure - (MagicMock(returncode=0), subprocess.CalledProcessError(1, 'docker run'), 1, False, False), - # KeyboardInterrupt during run - (MagicMock(returncode=0), KeyboardInterrupt(), 0, False, False), - # Success with mount_cwd - (MagicMock(returncode=0), MagicMock(returncode=0), None, True, False), - # Success with GPU - (MagicMock(returncode=0), MagicMock(returncode=0), None, False, True), - ], - ) - @patch('openhands_cli.gui_launcher.check_docker_requirements') - @patch('openhands_cli.gui_launcher.ensure_config_dir_exists') - @patch('openhands_cli.gui_launcher.get_openhands_version') - @patch('subprocess.run') - @patch('subprocess.check_output') - @patch('pathlib.Path.cwd') - @patch('openhands_cli.gui_launcher.print_formatted_text') - def test_launch_gui_server_scenarios( - self, - mock_print, - mock_cwd, - mock_check_output, - mock_run, - mock_version, - mock_config_dir, - mock_check_docker, - pull_side_effect, - run_side_effect, - expected_exit_code, - mount_cwd, - gpu, - ): - """Test various GUI server launch scenarios.""" - # Setup mocks - mock_check_docker.return_value = True - mock_config_dir.return_value = Path('/home/user/.openhands') - mock_version.return_value = 'latest' - mock_check_output.return_value = '1000\n' - mock_cwd.return_value = Path('/current/dir') - - # Configure subprocess.run side effects - side_effects = [] - if pull_side_effect is not None: - if isinstance(pull_side_effect, Exception): - side_effects.append(pull_side_effect) - else: - side_effects.append(pull_side_effect) - - if run_side_effect is not None: - if isinstance(run_side_effect, Exception): - side_effects.append(run_side_effect) - else: - side_effects.append(run_side_effect) - - mock_run.side_effect = side_effects - - # Test the function - if expected_exit_code is not None: - with pytest.raises(SystemExit) as exc_info: - launch_gui_server(mount_cwd=mount_cwd, gpu=gpu) - assert exc_info.value.code == expected_exit_code - else: - # Should not raise SystemExit for successful cases - launch_gui_server(mount_cwd=mount_cwd, gpu=gpu) - - # Verify subprocess.run was called correctly - assert mock_run.call_count == 2 # Pull and run commands - - # Check pull command - pull_call = mock_run.call_args_list[0] - pull_cmd = pull_call[0][0] - assert pull_cmd[0:3] == ['docker', 'pull', 'docker.openhands.dev/openhands/runtime:latest-nikolaik'] - - # Check run command - run_call = mock_run.call_args_list[1] - run_cmd = run_call[0][0] - assert run_cmd[0:2] == ['docker', 'run'] - - if mount_cwd: - assert 'SANDBOX_VOLUMES=/current/dir:/workspace:rw' in ' '.join(run_cmd) - assert 'SANDBOX_USER_ID=1000' in ' '.join(run_cmd) - - if gpu: - assert '--gpus' in run_cmd - assert 'all' in run_cmd - assert 'SANDBOX_ENABLE_GPU=true' in ' '.join(run_cmd) diff --git a/openhands-cli/tests/test_main.py b/openhands-cli/tests/test_main.py deleted file mode 100644 index 2e2a4a47ca..0000000000 --- a/openhands-cli/tests/test_main.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Tests for main entry point functionality.""" - -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest -from openhands_cli import simple_main -from openhands_cli.simple_main import main - - - -class TestMainEntryPoint: - """Test the main entry point behavior.""" - - @patch('openhands_cli.agent_chat.run_cli_entry') - @patch('sys.argv', ['openhands']) - def test_main_starts_agent_chat_directly( - self, mock_run_agent_chat: MagicMock - ) -> None: - """Test that main() starts agent chat directly when setup succeeds.""" - # Mock run_cli_entry to raise KeyboardInterrupt to exit gracefully - mock_run_agent_chat.side_effect = KeyboardInterrupt() - - # Should complete without raising an exception (graceful exit) - simple_main.main() - - # Should call run_cli_entry with no resume conversation ID - mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None) - - @patch('openhands_cli.agent_chat.run_cli_entry') - @patch('sys.argv', ['openhands']) - def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None: - """Test that main() handles ImportError gracefully.""" - mock_run_agent_chat.side_effect = ImportError('Missing dependency') - - # Should raise ImportError (re-raised after handling) - with pytest.raises(ImportError) as exc_info: - simple_main.main() - - assert str(exc_info.value) == 'Missing dependency' - - @patch('openhands_cli.agent_chat.run_cli_entry') - @patch('sys.argv', ['openhands']) - def test_main_handles_keyboard_interrupt( - self, mock_run_agent_chat: MagicMock - ) -> None: - """Test that main() handles KeyboardInterrupt gracefully.""" - # Mock run_cli_entry to raise KeyboardInterrupt - mock_run_agent_chat.side_effect = KeyboardInterrupt() - - # Should complete without raising an exception (graceful exit) - simple_main.main() - - @patch('openhands_cli.agent_chat.run_cli_entry') - @patch('sys.argv', ['openhands']) - def test_main_handles_eof_error(self, mock_run_agent_chat: MagicMock) -> None: - """Test that main() handles EOFError gracefully.""" - # Mock run_cli_entry to raise EOFError - mock_run_agent_chat.side_effect = EOFError() - - # Should complete without raising an exception (graceful exit) - simple_main.main() - - @patch('openhands_cli.agent_chat.run_cli_entry') - @patch('sys.argv', ['openhands']) - def test_main_handles_general_exception( - self, mock_run_agent_chat: MagicMock - ) -> None: - """Test that main() handles general exceptions.""" - mock_run_agent_chat.side_effect = Exception('Unexpected error') - - # Should raise Exception (re-raised after handling) - with pytest.raises(Exception) as exc_info: - simple_main.main() - - assert str(exc_info.value) == 'Unexpected error' - - @patch('openhands_cli.agent_chat.run_cli_entry') - @patch('sys.argv', ['openhands', '--resume', 'test-conversation-id']) - def test_main_with_resume_argument(self, mock_run_agent_chat: MagicMock) -> None: - """Test that main() passes resume conversation ID when provided.""" - # Mock run_cli_entry to raise KeyboardInterrupt to exit gracefully - mock_run_agent_chat.side_effect = KeyboardInterrupt() - - # Should complete without raising an exception (graceful exit) - simple_main.main() - - # Should call run_cli_entry with the provided resume conversation ID - mock_run_agent_chat.assert_called_once_with( - resume_conversation_id='test-conversation-id' - ) - - - - -@pytest.mark.parametrize( - "argv,expected_kwargs", - [ - (['openhands'], {"resume_conversation_id": None}), - (['openhands', '--resume', 'test-id'], {"resume_conversation_id": 'test-id'}), - ], -) -def test_main_cli_calls_run_cli_entry(monkeypatch, argv, expected_kwargs): - # Patch sys.argv since main() takes no params - monkeypatch.setattr(sys, "argv", argv, raising=False) - - called = {} - fake_agent_chat = SimpleNamespace( - run_cli_entry=lambda **kw: called.setdefault("kwargs", kw) - ) - # Provide the symbol that main() will import - monkeypatch.setitem(sys.modules, "openhands_cli.agent_chat", fake_agent_chat) - - # Execute (no SystemExit expected on success) - main() - assert called["kwargs"] == expected_kwargs - - -@pytest.mark.parametrize( - "argv,expected_kwargs", - [ - (['openhands', 'serve'], {"mount_cwd": False, "gpu": False}), - (['openhands', 'serve', '--mount-cwd'], {"mount_cwd": True, "gpu": False}), - (['openhands', 'serve', '--gpu'], {"mount_cwd": False, "gpu": True}), - (['openhands', 'serve', '--mount-cwd', '--gpu'], {"mount_cwd": True, "gpu": True}), - ], -) -def test_main_serve_calls_launch_gui_server(monkeypatch, argv, expected_kwargs): - monkeypatch.setattr(sys, "argv", argv, raising=False) - - called = {} - fake_gui = SimpleNamespace( - launch_gui_server=lambda **kw: called.setdefault("kwargs", kw) - ) - monkeypatch.setitem(sys.modules, "openhands_cli.gui_launcher", fake_gui) - - main() - assert called["kwargs"] == expected_kwargs - - -@pytest.mark.parametrize( - "argv,expected_exit_code", - [ - (['openhands', 'invalid-command'], 2), # argparse error - (['openhands', '--help'], 0), # top-level help - (['openhands', 'serve', '--help'], 0), # subcommand help - ], -) -def test_help_and_invalid(monkeypatch, argv, expected_exit_code): - monkeypatch.setattr(sys, "argv", argv, raising=False) - with pytest.raises(SystemExit) as exc: - main() - assert exc.value.code == expected_exit_code diff --git a/openhands-cli/tests/test_mcp_config_validation.py b/openhands-cli/tests/test_mcp_config_validation.py deleted file mode 100644 index b549768192..0000000000 --- a/openhands-cli/tests/test_mcp_config_validation.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Parametrized tests for MCP configuration screen functionality.""" - -import json -from pathlib import Path -from unittest.mock import patch - -import pytest -from openhands_cli.locations import MCP_CONFIG_FILE -from openhands_cli.tui.settings.mcp_screen import MCPScreen - -from openhands.sdk import LLM, Agent - - -@pytest.fixture -def persistence_dir(tmp_path, monkeypatch): - """Patch PERSISTENCE_DIR to tmp and return the directory Path.""" - monkeypatch.setattr( - 'openhands_cli.tui.settings.mcp_screen.PERSISTENCE_DIR', - str(tmp_path), - raising=True, - ) - return tmp_path - - -def _create_agent(mcp_config=None) -> Agent: - if mcp_config is None: - mcp_config = {} - return Agent( - llm=LLM(model='test-model', api_key='test-key', usage_id='test-service'), - tools=[], - mcp_config=mcp_config, - ) - - -def _maybe_write_mcp_file(dirpath: Path, file_content): - """Write mcp.json if file_content is provided. - - file_content: - - None -> do not create file (missing) - - "INVALID"-> write invalid JSON - - dict -> dump as JSON - """ - if file_content is None: - return - cfg_path = dirpath / MCP_CONFIG_FILE - if file_content == 'INVALID': - cfg_path.write_text('{"invalid": json content}') - else: - cfg_path.write_text(json.dumps(file_content)) - - -# Shared "always expected" help text snippets -ALWAYS_EXPECTED = [ - 'MCP (Model Context Protocol) Configuration', - 'To get started:', - '~/.openhands/mcp.json', - 'https://gofastmcp.com/clients/client#configuration-format', - 'Restart your OpenHands session', -] - - -CASES = [ - # Agent has an existing server; should list "Current Agent MCP Servers" - dict( - id='agent_has_existing', - agent_mcp={ - 'mcpServers': { - 'existing_server': { - 'command': 'python', - 'args': ['-m', 'existing_server'], - } - } - }, - file_content=None, # no incoming file - expected=[ - 'Current Agent MCP Servers:', - 'existing_server', - ], - unexpected=[], - ), - # Agent has none; should show "None configured on the current agent" - dict( - id='agent_has_none', - agent_mcp={}, - file_content=None, - expected=[ - 'Current Agent MCP Servers:', - 'None configured on the current agent', - ], - unexpected=[], - ), - # New servers present only in mcp.json - dict( - id='new_servers_on_restart', - agent_mcp={}, - file_content={ - 'mcpServers': { - 'fetch': {'command': 'uvx', 'args': ['mcp-server-fetch']}, - 'notion': {'url': 'https://mcp.notion.com/mcp', 'auth': 'oauth'}, - } - }, - expected=[ - 'Incoming Servers on Restart', - 'New servers (will be added):', - 'fetch', - 'notion', - ], - unexpected=[], - ), - # Overriding/updating servers present in both agent and mcp.json (but different config) - dict( - id='overriding_servers_on_restart', - agent_mcp={ - 'mcpServers': { - 'fetch': {'command': 'python', 'args': ['-m', 'old_fetch_server']} - } - }, - file_content={ - 'mcpServers': {'fetch': {'command': 'uvx', 'args': ['mcp-server-fetch']}} - }, - expected=[ - 'Incoming Servers on Restart', - 'Updated servers (configuration will change):', - 'fetch', - 'Current:', - 'Incoming:', - ], - unexpected=[], - ), - # All servers already synced (matching config) - dict( - id='already_synced', - agent_mcp={ - 'mcpServers': { - 'fetch': { - 'command': 'uvx', - 'args': ['mcp-server-fetch'], - 'env': {}, - 'transport': 'stdio', - } - } - }, - file_content={ - 'mcpServers': {'fetch': {'command': 'uvx', 'args': ['mcp-server-fetch']}} - }, - expected=[ - 'Incoming Servers on Restart', - 'All configured servers match the current agent configuration', - ], - unexpected=[], - ), - # Invalid JSON file handling - dict( - id='invalid_json_file', - agent_mcp={}, - file_content='INVALID', - expected=[ - 'Invalid MCP configuration file', - 'Please check your configuration file format', - ], - unexpected=[], - ), - # Missing JSON file handling - dict( - id='missing_json_file', - agent_mcp={}, - file_content=None, # explicitly missing - expected=[ - 'Configuration file not found', - 'No incoming servers detected for next restart', - ], - unexpected=[], - ), -] - - -@pytest.mark.parametrize('case', CASES, ids=[c['id'] for c in CASES]) -@patch('openhands_cli.tui.settings.mcp_screen.print_formatted_text') -def test_display_mcp_info_parametrized(mock_print, case, persistence_dir): - """Table-driven test for MCPScreen.display_mcp_info covering all scenarios.""" - # Arrange - agent = _create_agent(case['agent_mcp']) - _maybe_write_mcp_file(persistence_dir, case['file_content']) - screen = MCPScreen() - - # Act - screen.display_mcp_info(agent) - - # Gather output - all_calls = [str(call_args) for call_args in mock_print.call_args_list] - content = ' '.join(all_calls) - - # Invariants: help instructions should always be present - for snippet in ALWAYS_EXPECTED: - assert snippet in content, f'Missing help snippet: {snippet}' - - # Scenario-specific expectations - for snippet in case['expected']: - assert snippet in content, ( - f'Expected snippet not found for case {case["id"]}: {snippet}' - ) - - for snippet in case.get('unexpected', []): - assert snippet not in content, ( - f'Unexpected snippet found for case {case["id"]}: {snippet}' - ) diff --git a/openhands-cli/tests/test_pause_listener.py b/openhands-cli/tests/test_pause_listener.py deleted file mode 100644 index 0471b0b80d..0000000000 --- a/openhands-cli/tests/test_pause_listener.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for pause listener in OpenHands CLI. -""" - -import time -from unittest.mock import MagicMock - -from openhands_cli.listeners.pause_listener import PauseListener, pause_listener -from prompt_toolkit.input.defaults import create_pipe_input - -from openhands.sdk import Conversation - - -class TestPauseListener: - """Test suite for PauseListener class.""" - - def test_pause_listener_stop(self) -> None: - """Test PauseListener stop functionality.""" - mock_callback = MagicMock() - listener = PauseListener(on_pause=mock_callback) - - listener.start() - - # Initially not paused - assert not listener.is_paused() - assert listener.is_alive() - - # Stop the listener - listener.stop() - - # Listner was shutdown not paused - assert not listener.is_paused() - assert listener.is_stopped() - - def test_pause_listener_context_manager(self) -> None: - """Test pause_listener context manager.""" - mock_conversation = MagicMock(spec=Conversation) - mock_conversation.pause = MagicMock() - - with create_pipe_input() as pipe: - with pause_listener(mock_conversation, pipe) as listener: - assert isinstance(listener, PauseListener) - assert listener.on_pause == mock_conversation.pause - # Listener should be started (daemon thread) - assert listener.is_alive() - assert not listener.is_paused() - pipe.send_text('\x10') # Ctrl-P - time.sleep(0.1) - assert listener.is_paused() - - assert listener.is_stopped() - assert not listener.is_alive() diff --git a/openhands-cli/tests/test_session_prompter.py b/openhands-cli/tests/test_session_prompter.py deleted file mode 100644 index befb1efdfb..0000000000 --- a/openhands-cli/tests/test_session_prompter.py +++ /dev/null @@ -1,106 +0,0 @@ -import time -from concurrent.futures import ThreadPoolExecutor -from typing import Optional - -import pytest -from openhands_cli.user_actions.utils import get_session_prompter -from prompt_toolkit.formatted_text import HTML -from prompt_toolkit.input.defaults import create_pipe_input -from prompt_toolkit.output.defaults import DummyOutput - -from tests.utils import _send_keys - - -def _run_prompt_and_type( - prompt_text: str, - keys: str, - *, - expect_exception: Optional[type[BaseException]] = None, - timeout: float = 2.0, - settle: float = 0.05, -) -> str | None: - """ - Helper to: - 1) create a pipe + session, - 2) start session.prompt in a background thread, - 3) send keys, and - 4) return the result or raise the expected exception. - - Returns: - - The prompt result (str) if no exception expected. - - None if an exception is expected and raised. - """ - with create_pipe_input() as pipe: - session = get_session_prompter(input=pipe, output=DummyOutput()) - with ThreadPoolExecutor(max_workers=1) as ex: - fut = ex.submit(session.prompt, HTML(prompt_text)) - # Allow the prompt loop to start consuming input - time.sleep(settle) - _send_keys(pipe, keys) - if expect_exception: - with pytest.raises(expect_exception): - fut.result(timeout=timeout) - return None - return fut.result(timeout=timeout) - - -@pytest.mark.parametrize( - 'desc,keys,expected', - [ - ('basic single line', 'hello world\r', 'hello world'), - ('empty input', '\r', ''), - ( - 'single multiline via backslash-enter', - 'line 1\\\rline 2\r', - 'line 1\nline 2', - ), - ( - 'multiple multiline segments', - 'first line\\\rsecond line\\\rthird line\r', - 'first line\nsecond line\nthird line', - ), - ( - 'backslash-only newline then text', - '\\\rafter newline\r', - '\nafter newline', - ), - ( - 'mixed content (code-like)', - "def function():\\\r return 'hello'\\\r # end of function\r", - "def function():\n return 'hello'\n # end of function", - ), - ( - 'whitespace preservation (including blank line)', - ' indented line\\\r\\\r more indented\r', - ' indented line\n\n more indented', - ), - ( - 'special characters', - 'echo \'hello world\'\\\rgrep -n "pattern" file.txt\r', - 'echo \'hello world\'\ngrep -n "pattern" file.txt', - ), - ], -) -def test_get_session_prompter_scenarios(desc, keys, expected): - """Covers most behaviors via parametrization to reduce duplication.""" - result = _run_prompt_and_type('> ', keys) - assert result == expected - - -def test_get_session_prompter_keyboard_interrupt(): - """Focused test for Ctrl+C behavior.""" - _run_prompt_and_type('> ', '\x03', expect_exception=KeyboardInterrupt) - - -def test_get_session_prompter_default_parameters(): - """Lightweight sanity check for default construction.""" - session = get_session_prompter() - assert session is not None - assert session.multiline is True - assert session.key_bindings is not None - assert session.completer is not None - - # Prompt continuation should be callable and return the expected string - cont = session.prompt_continuation - assert callable(cont) - assert cont(80, 1, False) == '...' diff --git a/openhands-cli/tests/test_tui.py b/openhands-cli/tests/test_tui.py deleted file mode 100644 index 067bef177c..0000000000 --- a/openhands-cli/tests/test_tui.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Tests for TUI functionality.""" - -from openhands_cli.tui.tui import COMMANDS, CommandCompleter -from prompt_toolkit.completion import CompleteEvent -from prompt_toolkit.document import Document - - -class TestCommandCompleter: - """Test the CommandCompleter class.""" - - def test_command_completion_with_slash(self) -> None: - """Test that commands are completed when starting with /.""" - completer = CommandCompleter() - document = Document('/') - completions = list(completer.get_completions(document, CompleteEvent())) - - # Should return all available commands - assert len(completions) == len(COMMANDS) - - # Check that all commands are included - completion_texts = [c.text for c in completions] - for command in COMMANDS.keys(): - assert command in completion_texts - - def test_command_completion_partial_match(self) -> None: - """Test that partial command matches work correctly.""" - completer = CommandCompleter() - document = Document('/ex') - completions = list(completer.get_completions(document, CompleteEvent())) - - # Should return only /exit - assert len(completions) == 1 - assert completions[0].text == '/exit' - # display_meta is a FormattedText object, so we need to check its content - # Extract the text from FormattedText - meta_text = completions[0].display_meta - if hasattr(meta_text, '_formatted_text'): - # Extract text from FormattedText - text_content = ''.join([item[1] for item in meta_text._formatted_text]) - else: - text_content = str(meta_text) - assert COMMANDS['/exit'] in text_content - - def test_command_completion_no_slash(self) -> None: - """Test that no completions are returned without /.""" - completer = CommandCompleter() - document = Document('help') - completions = list(completer.get_completions(document, CompleteEvent())) - - # Should return no completions - assert len(completions) == 0 - - def test_command_completion_no_match(self) -> None: - """Test that no completions are returned for non-matching commands.""" - completer = CommandCompleter() - document = Document('/nonexistent') - completions = list(completer.get_completions(document, CompleteEvent())) - - # Should return no completions - assert len(completions) == 0 - - def test_command_completion_styling(self) -> None: - """Test that completions have proper styling.""" - completer = CommandCompleter() - document = Document('/help') - completions = list(completer.get_completions(document, CompleteEvent())) - - assert len(completions) == 1 - completion = completions[0] - assert completion.style == 'bg:ansidarkgray fg:gold' - assert completion.start_position == -5 # Length of "/help" - - -def test_commands_dict() -> None: - """Test that COMMANDS dictionary contains expected commands.""" - expected_commands = { - '/exit', - '/help', - '/clear', - '/new', - '/status', - '/confirm', - '/resume', - '/settings', - '/mcp', - } - assert set(COMMANDS.keys()) == expected_commands - - # Check that all commands have descriptions - for command, description in COMMANDS.items(): - assert isinstance(command, str) - assert command.startswith('/') - assert isinstance(description, str) - assert len(description) > 0 diff --git a/openhands-cli/tests/utils.py b/openhands-cli/tests/utils.py deleted file mode 100644 index d0a7f12d11..0000000000 --- a/openhands-cli/tests/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -import time - -from prompt_toolkit.input import PipeInput - - -def _send_keys(pipe: PipeInput, text: str, delay: float = 0.05) -> None: - """Helper: small delay then send keys to avoid race with app.run().""" - time.sleep(delay) - pipe.send_text(text) diff --git a/openhands-cli/tests/visualizer/test_visualizer.py b/openhands-cli/tests/visualizer/test_visualizer.py deleted file mode 100644 index 92ead3643a..0000000000 --- a/openhands-cli/tests/visualizer/test_visualizer.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Tests for the conversation visualizer and event visualization.""" - -import json - -from rich.text import Text - -from openhands_cli.tui.visualizer import ( - CLIVisualizer, -) -from openhands.sdk.event import ( - ActionEvent, - SystemPromptEvent, - UserRejectObservation, -) -from openhands.sdk.llm import ( - MessageToolCall, - TextContent, -) -from openhands.sdk.tool import Action - - -class VisualizerMockAction(Action): - """Mock action for testing.""" - - command: str = "test command" - working_dir: str = "/tmp" - - -class VisualizerCustomAction(Action): - """Custom action with overridden visualize method.""" - - task_list: list[dict] = [] - - @property - def visualize(self) -> Text: - """Custom visualization for task tracker.""" - content = Text() - content.append("Task Tracker Action\n", style="bold") - content.append(f"Tasks: {len(self.task_list)}") - for i, task in enumerate(self.task_list): - content.append(f"\n {i + 1}. {task.get('title', 'Untitled')}") - return content - - -def create_tool_call( - call_id: str, function_name: str, arguments: dict -) -> MessageToolCall: - """Helper to create a MessageToolCall.""" - return MessageToolCall( - id=call_id, - name=function_name, - arguments=json.dumps(arguments), - origin="completion", - ) - - -def test_conversation_visualizer_initialization(): - """Test DefaultConversationVisualizer can be initialized.""" - visualizer = CLIVisualizer() - assert visualizer is not None - assert hasattr(visualizer, "on_event") - assert hasattr(visualizer, "_create_event_panel") - - -def test_visualizer_event_panel_creation(): - """Test that visualizer creates panels for different event types.""" - conv_viz = CLIVisualizer() - - # Test with a simple action event - action = VisualizerMockAction(command="test") - tool_call = create_tool_call("call_1", "test", {}) - action_event = ActionEvent( - thought=[TextContent(text="Testing")], - action=action, - tool_name="test", - tool_call_id="call_1", - tool_call=tool_call, - llm_response_id="response_1", - ) - panel = conv_viz._create_event_panel(action_event) - assert panel is not None - assert hasattr(panel, "renderable") - - -def test_visualizer_action_event_with_none_action_panel(): - """ActionEvent with action=None should render as 'Agent Action (Not Executed)'.""" - visualizer = CLIVisualizer() - tc = create_tool_call("call_ne_1", "missing_fn", {}) - action_event = ActionEvent( - thought=[TextContent(text="...")], - tool_call=tc, - tool_name=tc.name, - tool_call_id=tc.id, - llm_response_id="resp_viz_1", - action=None, - ) - panel = visualizer._create_event_panel(action_event) - assert panel is not None - # Ensure it doesn't fall back to UNKNOWN - assert "UNKNOWN Event" not in str(panel.title) - # And uses the 'Agent Action (Not Executed)' title - assert "Agent Action (Not Executed)" in str(panel.title) - - -def test_visualizer_user_reject_observation_panel(): - """UserRejectObservation should render a dedicated panel.""" - visualizer = CLIVisualizer() - event = UserRejectObservation( - tool_name="demo_tool", - tool_call_id="fc_call_1", - action_id="action_1", - rejection_reason="User rejected the proposed action.", - ) - - panel = visualizer._create_event_panel(event) - assert panel is not None - title = str(panel.title) - assert "UNKNOWN Event" not in title - assert "User Rejected Action" in title - # ensure the reason is part of the renderable text - renderable = panel.renderable - assert isinstance(renderable, Text) - assert "User rejected the proposed action." in renderable.plain - - -def test_metrics_formatting(): - """Test metrics subtitle formatting.""" - from unittest.mock import MagicMock - - from openhands.sdk.conversation.conversation_stats import ConversationStats - from openhands.sdk.llm.utils.metrics import Metrics - - # Create conversation stats with metrics - conversation_stats = ConversationStats() - - # Create metrics and add to conversation stats - metrics = Metrics(model_name="test-model") - metrics.add_cost(0.0234) - metrics.add_token_usage( - prompt_tokens=1500, - completion_tokens=500, - cache_read_tokens=300, - cache_write_tokens=0, - reasoning_tokens=200, - context_window=8000, - response_id="test_response", - ) - - # Add metrics to conversation stats - conversation_stats.usage_to_metrics["test_usage"] = metrics - - # Create visualizer and initialize with mock state - visualizer = CLIVisualizer() - mock_state = MagicMock() - mock_state.stats = conversation_stats - visualizer.initialize(mock_state) - - # Test the metrics subtitle formatting - subtitle = visualizer._format_metrics_subtitle() - assert subtitle is not None - assert "1.5K" in subtitle # Input tokens abbreviated (trailing zeros removed) - assert "500" in subtitle # Output tokens - assert "20.00%" in subtitle # Cache hit rate - assert "200" in subtitle # Reasoning tokens - assert "0.0234" in subtitle # Cost - - -def test_metrics_abbreviation_formatting(): - """Test number abbreviation with various edge cases.""" - from unittest.mock import MagicMock - - from openhands.sdk.conversation.conversation_stats import ConversationStats - from openhands.sdk.llm.utils.metrics import Metrics - - test_cases = [ - # (input_tokens, expected_abbr) - (999, "999"), # Below threshold - (1000, "1K"), # Exact K boundary, trailing zeros removed - (1500, "1.5K"), # K with one decimal, trailing zero removed - (89080, "89.08K"), # K with two decimals (regression test for bug) - (89000, "89K"), # K with trailing zeros removed - (1000000, "1M"), # Exact M boundary - (1234567, "1.23M"), # M with decimals - (1000000000, "1B"), # Exact B boundary - ] - - for tokens, expected in test_cases: - stats = ConversationStats() - metrics = Metrics(model_name="test-model") - metrics.add_token_usage( - prompt_tokens=tokens, - completion_tokens=100, - cache_read_tokens=0, - cache_write_tokens=0, - reasoning_tokens=0, - context_window=8000, - response_id="test", - ) - stats.usage_to_metrics["test"] = metrics - - visualizer = CLIVisualizer() - mock_state = MagicMock() - mock_state.stats = stats - visualizer.initialize(mock_state) - subtitle = visualizer._format_metrics_subtitle() - - assert subtitle is not None, f"Failed for {tokens}" - assert expected in subtitle, ( - f"Expected '{expected}' in subtitle for {tokens}, got: {subtitle}" - ) - - -def test_event_base_fallback_visualize(): - """Test that Event provides fallback visualization.""" - from openhands.sdk.event.base import Event - from openhands.sdk.event.types import SourceType - - class UnknownEvent(Event): - source: SourceType = "agent" - - event = UnknownEvent() - - conv_viz = CLIVisualizer() - panel = conv_viz._create_event_panel(event) - - assert "UNKNOWN Event" in str(panel.title) - - -def test_visualizer_does_not_render_system_prompt(): - """Test that Event provides fallback visualization.""" - system_prompt_event = SystemPromptEvent( - source="agent", - system_prompt=TextContent(text="dummy"), - tools=[] - ) - conv_viz = CLIVisualizer() - panel = conv_viz._create_event_panel(system_prompt_event) - assert panel is None