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