Chore: move CLI code its own repo (#11724)

This commit is contained in:
Rohit Malhotra 2025-11-18 13:59:12 -06:00 committed by GitHub
parent 494eba094f
commit 1a33606987
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 5 additions and 6766 deletions

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
```

View File

@ -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)

View File

@ -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[@]}"

View File

@ -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')

View File

@ -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
)

View File

@ -1,8 +0,0 @@
"""OpenHands package."""
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("openhands")
except PackageNotFoundError:
__version__ = "0.0.0"

View File

@ -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'<grey>Conversation ID:</grey> <yellow>{conversation_id}</yellow>')
)
print_formatted_text(
HTML(
f'<grey>Hint:</grey> run <gold>openhands --resume {conversation_id}</gold> '
'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"<yellow>Warning: '{resume_conversation_id}' is not a valid UUID.</yellow>"
)
)
return
try:
initialized_agent = verify_agent_exists_or_setup_agent()
except MissingAgentSpec:
print_formatted_text(HTML('\n<yellow>Setup is required to use OpenHands CLI.</yellow>'))
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
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('<gold>> </gold>'),
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('\n<yellow>Goodbye! 👋</yellow>'))
_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('<green>✓ Started fresh conversation</green>')
)
continue
except Exception as e:
print_formatted_text(
HTML(f'<red>Error starting fresh conversation: {e}</red>')
)
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'<yellow>Confirmation mode {new_status}</yellow>')
)
continue
elif command == '/resume':
if not runner:
print_formatted_text(
HTML('<yellow>No active conversation running...</yellow>')
)
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('<red>No paused conversation to resume...</red>')
)
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('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation_id)
break
# Clean up terminal state
_restore_tty()

View File

@ -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

View File

@ -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

View File

@ -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'<grey>Running Docker command: {cmd_str}</grey>'
def check_docker_requirements() -> bool:
"""Check if Docker is installed and running.
Returns:
bool: True if Docker is available and running, False otherwise.
"""
# Check if Docker is installed
if not shutil.which('docker'):
print_formatted_text(
HTML('<ansired>❌ Docker is not installed or not in PATH.</ansired>')
)
print_formatted_text(
HTML(
'<grey>Please install Docker first: https://docs.docker.com/get-docker/</grey>'
)
)
return False
# Check if Docker daemon is running
try:
result = subprocess.run(
['docker', 'info'], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
print_formatted_text(
HTML('<ansired>❌ Docker daemon is not running.</ansired>')
)
print_formatted_text(
HTML('<grey>Please start Docker and try again.</grey>')
)
return False
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
print_formatted_text(
HTML('<ansired>❌ Failed to check Docker status.</ansired>')
)
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
return False
return True
def ensure_config_dir_exists() -> Path:
"""Ensure the OpenHands configuration directory exists and return its path."""
path = Path(PERSISTENCE_DIR)
path.mkdir(exist_ok=True, parents=True)
return path
def get_openhands_version() -> str:
"""Get the OpenHands version for Docker images.
Returns:
str: The version string to use for Docker images
"""
# For now, use 'latest' as the default version
# In the future, this could be read from a version file or environment variable
return os.environ.get('OPENHANDS_VERSION', 'latest')
def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
"""Launch the OpenHands GUI server using Docker.
Args:
mount_cwd: If True, mount the current working directory into the container.
gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker.
"""
print_formatted_text(
HTML('<ansiblue>🚀 Launching OpenHands GUI server...</ansiblue>')
)
print_formatted_text('')
# Check Docker requirements
if not check_docker_requirements():
sys.exit(1)
# Ensure config directory exists
config_dir = ensure_config_dir_exists()
# Get the current version for the Docker image
version = get_openhands_version()
runtime_image = f'docker.openhands.dev/openhands/runtime:{version}-nikolaik'
app_image = f'docker.openhands.dev/openhands/openhands:{version}'
print_formatted_text(HTML('<grey>Pulling required Docker images...</grey>'))
# Pull the runtime image first
pull_cmd = ['docker', 'pull', runtime_image]
print_formatted_text(HTML(_format_docker_command_for_logging(pull_cmd)))
try:
subprocess.run(pull_cmd, check=True)
except subprocess.CalledProcessError:
print_formatted_text(
HTML('<ansired>❌ Failed to pull runtime image.</ansired>')
)
sys.exit(1)
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Starting OpenHands GUI server...</ansigreen>')
)
print_formatted_text(
HTML('<grey>The server will be available at: http://localhost:3000</grey>')
)
print_formatted_text(HTML('<grey>Press Ctrl+C to stop the server.</grey>'))
print_formatted_text('')
# Build the Docker command
docker_cmd = [
'docker',
'run',
'-it',
'--rm',
'--pull=always',
'-e',
f'SANDBOX_RUNTIME_CONTAINER_IMAGE={runtime_image}',
'-e',
'LOG_ALL_EVENTS=true',
'-v',
'/var/run/docker.sock:/var/run/docker.sock',
'-v',
f'{config_dir}:/.openhands',
]
# Add GPU support if requested
if gpu:
print_formatted_text(
HTML('<ansigreen>🖥️ Enabling GPU support via nvidia-docker...</ansigreen>')
)
# Add the --gpus all flag to enable all GPUs
docker_cmd.insert(2, '--gpus')
docker_cmd.insert(3, 'all')
# Add environment variable to pass GPU support to sandbox containers
docker_cmd.extend(
[
'-e',
'SANDBOX_ENABLE_GPU=true',
]
)
# Add current working directory mount if requested
if mount_cwd:
cwd = Path.cwd()
# Following the documentation at https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem
docker_cmd.extend(
[
'-e',
f'SANDBOX_VOLUMES={cwd}:/workspace:rw',
]
)
# Set user ID for Unix-like systems only
if os.name != 'nt': # Not Windows
try:
user_id = subprocess.check_output(['id', '-u'], text=True).strip()
docker_cmd.extend(['-e', f'SANDBOX_USER_ID={user_id}'])
except (subprocess.CalledProcessError, FileNotFoundError):
# If 'id' command fails or doesn't exist, skip setting user ID
pass
# Print the folder that will be mounted to inform the user
print_formatted_text(
HTML(
f'<ansigreen>📂 Mounting current directory:</ansigreen> <ansiyellow>{cwd}</ansiyellow> <ansigreen>to</ansigreen> <ansiyellow>/workspace</ansiyellow>'
)
)
docker_cmd.extend(
[
'-p',
'3000:3000',
'--add-host',
'host.docker.internal:host-gateway',
'--name',
'openhands-app',
app_image,
]
)
try:
# Log and run the Docker command
print_formatted_text(HTML(_format_docker_command_for_logging(docker_cmd)))
subprocess.run(docker_cmd, check=True)
except subprocess.CalledProcessError as e:
print_formatted_text('')
print_formatted_text(
HTML('<ansired>❌ Failed to start OpenHands GUI server.</ansired>')
)
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
sys.exit(1)
except KeyboardInterrupt:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✓ OpenHands GUI server stopped successfully.</ansigreen>')
)
sys.exit(0)

View File

@ -1,3 +0,0 @@
from openhands_cli.listeners.pause_listener import PauseListener
__all__ = ['PauseListener']

View File

@ -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('<gold>Pausing agent once step is completed...</gold>')
)
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()

View File

@ -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'

View File

@ -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])

View File

@ -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(
'<yellow>Resuming paused conversation...</yellow><grey> (Press Ctrl-P to pause)</grey>'
)
)
else:
print_formatted_text(
HTML(
'<yellow>Agent running...</yellow><grey> (Press Ctrl-P to pause)</grey>'
)
)
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(
'<yellow>Confirmation mode disabled. Agent will proceed without asking.</yellow>'
)
)
# Remove security analyzer when policy is never confirm
self.toggle_confirmation_mode()
return decision
if isinstance(policy_change, ConfirmRisky):
print_formatted_text(
HTML(
'<yellow>Security-based confirmation enabled. '
'LOW/MEDIUM risk actions will auto-confirm, HIGH risk actions will ask for confirmation.</yellow>'
)
)
# 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

View File

@ -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'<white>Initializing agent...</white>')
)
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 /<conversation_id> 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'<green>✓ Agent initialized with model: {agent.llm.model}</green>')
)
return conversation

View File

@ -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('\n<yellow>Goodbye! 👋</yellow>'))
except EOFError:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
except Exception as e:
print_formatted_text(HTML(f'<red>Error: {e}</red>'))
import traceback
traceback.print_exc()
raise
if __name__ == '__main__':
main()

View File

@ -1,5 +0,0 @@
from openhands_cli.tui.tui import DEFAULT_STYLE
__all__ = [
'DEFAULT_STYLE',
]

View File

@ -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('<white>Current Agent MCP Servers:</white>'))
if current:
for name, cfg in current.items():
self._render_server_summary(name, cfg, indent=2)
else:
print_formatted_text(
HTML(' <yellow>None configured on the current agent.</yellow>')
)
print_formatted_text('')
# If no incoming, we're done
if not incoming:
print_formatted_text(
HTML('<grey>No incoming servers detected for next restart.</grey>')
)
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(
'<white>Incoming Servers on Restart (from ~/.openhands/mcp.json):</white>'
)
)
if not new_servers and not overriden_servers:
print_formatted_text(
HTML(
' <grey>All configured servers match the current agent configuration.</grey>'
)
)
print_formatted_text('')
return
if new_servers:
print_formatted_text(HTML(' <green>New servers (will be added):</green>'))
for name in new_servers:
self._render_server_summary(name, incoming[name], indent=4)
if overriden_servers:
print_formatted_text(
HTML(' <yellow>Updated servers (configuration will change):</yellow>')
)
for name in overriden_servers:
print_formatted_text(HTML(f' <white>• {name}</white>'))
print_formatted_text(HTML(' <grey>Current:</grey>'))
self._render_server_summary(None, current[name], indent=8)
print_formatted_text(HTML(' <grey>Incoming:</grey>'))
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}<white>• {server_name}</white>'))
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} <grey>Type: Command-based</grey>'))
if cmd or args_str:
print_formatted_text(
HTML(f'{pad} <grey>Command: {cmd} {args_str}</grey>')
)
elif 'url' in server_spec:
url = server_spec.get('url', '')
auth = server_spec.get('auth', 'none')
print_formatted_text(HTML(f'{pad} <grey>Type: URL-based</grey>'))
if url:
print_formatted_text(HTML(f'{pad} <grey>URL: {url}</grey>'))
print_formatted_text(HTML(f'{pad} <grey>Auth: {auth}</grey>'))
def _display_information_header(self) -> None:
print_formatted_text(
HTML('<gold>MCP (Model Context Protocol) Configuration</gold>')
)
print_formatted_text('')
print_formatted_text(HTML('<white>To get started:</white>'))
print_formatted_text(
HTML(
' 1. Create the configuration file: <cyan>~/.openhands/mcp.json</cyan>'
)
)
print_formatted_text(
HTML(
' 2. Add your MCP server configurations '
'<cyan>https://gofastmcp.com/clients/client#configuration-format</cyan>'
)
)
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('<yellow>Status: Configuration file not found</yellow>')
)
elif not status['valid']:
print_formatted_text(HTML(f'<red>Status: {status["message"]}</red>'))
print_formatted_text('')
print_formatted_text(
HTML('<white>Please check your configuration file format.</white>')
)
else:
print_formatted_text(HTML(f'<green>Status: {status["message"]}</green>'))
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)

View File

@ -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('\n<red>Cancelled settings change.</red>'))
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('\n<red>Cancelled settings change.</red>'))
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)

View File

@ -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('\n<red>Agent configuration file is corrupted!</red>')
)
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)

View File

@ -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'<grey>Conversation ID: {conversation.id}</grey>'))
print_formatted_text(HTML(f'<grey>Uptime: {uptime_str}</grey>'))
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)

View File

@ -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"""<gold>
___ _ _ _
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|_|
</gold>"""),
style=DEFAULT_STYLE,
)
print_formatted_text('')
if not resume:
print_formatted_text(
HTML(f'<grey>Initialized conversation {conversation_id}</grey>')
)
else:
print_formatted_text(
HTML(f'<grey>Resumed conversation {conversation_id}</grey>')
)
print_formatted_text('')
def display_help() -> None:
"""Display help information about available commands."""
print_formatted_text('')
print_formatted_text(HTML('<gold>🤖 OpenHands CLI Help</gold>'))
print_formatted_text(HTML('<grey>Available commands:</grey>'))
print_formatted_text('')
for command, description in COMMANDS.items():
print_formatted_text(HTML(f' <white>{command}</white> - {description}'))
print_formatted_text('')
print_formatted_text(HTML('<grey>Tips:</grey>'))
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("<gold>Let's start building!</gold>"))
print_formatted_text(
HTML(
'<green>What do you want to build? <grey>Type /help for help</grey></green>'
)
)
print()

View File

@ -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}'

View File

@ -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)

View File

@ -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',
]

View File

@ -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'<yellow>🔍 Agent created {len(pending_actions)} action(s) and is waiting for confirmation:</yellow>'
)
)
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'<grey> {i}. {tool_name}: {html.escape(action_content)}...</grey>')
)
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('\n<red>No input received; pausing agent.</red>'))
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)

View File

@ -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)

View File

@ -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]

View File

@ -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 = ''

View File

@ -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(
'<placeholder>'
'Type your message… (tip: press <b>\\</b> + <b>Enter</b> to insert a newline)'
'</placeholder>'
),
)
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.'
)

View File

@ -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

View File

@ -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

View File

@ -1 +0,0 @@
"""Tests for OpenHands CLI."""

View File

@ -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,
)

View File

@ -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

View File

@ -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"

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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]

View File

@ -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()

View File

@ -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
)

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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'],
'<grey>Running Docker command: docker run hello-world</grey>',
),
(
['docker', 'run', '-it', '--rm', '-p', '3000:3000', 'openhands:latest'],
'<grey>Running Docker command: docker run -it --rm -p 3000:3000 openhands:latest</grey>',
),
([], '<grey>Running Docker command: </grey>'),
],
)
def test_format_docker_command(self, cmd, expected):
"""Test formatting Docker commands."""
result = _format_docker_command_for_logging(cmd)
assert result == expected
class TestCheckDockerRequirements:
"""Test Docker requirements checking."""
@pytest.mark.parametrize(
"which_return,run_side_effect,expected_result,expected_print_count",
[
# Docker not installed
(None, None, False, 2),
# Docker daemon not running
('/usr/bin/docker', MagicMock(returncode=1), False, 2),
# Docker timeout
('/usr/bin/docker', subprocess.TimeoutExpired('docker info', 10), False, 2),
# Docker available
('/usr/bin/docker', MagicMock(returncode=0), True, 0),
],
)
@patch('shutil.which')
@patch('subprocess.run')
def test_docker_requirements(
self, mock_run, mock_which, which_return, run_side_effect, expected_result, expected_print_count
):
"""Test Docker requirements checking scenarios."""
mock_which.return_value = which_return
if run_side_effect is not None:
if isinstance(run_side_effect, Exception):
mock_run.side_effect = run_side_effect
else:
mock_run.return_value = run_side_effect
with patch('openhands_cli.gui_launcher.print_formatted_text') as mock_print:
result = check_docker_requirements()
assert result is expected_result
assert mock_print.call_count == expected_print_count
class TestGetOpenHandsVersion:
"""Test version retrieval."""
@pytest.mark.parametrize(
"env_value,expected",
[
(None, 'latest'), # No environment variable set
('1.2.3', '1.2.3'), # Environment variable set
],
)
def test_version_retrieval(self, env_value, expected):
"""Test version retrieval from environment."""
if env_value:
os.environ['OPENHANDS_VERSION'] = env_value
result = get_openhands_version()
assert result == expected
class TestLaunchGuiServer:
"""Test GUI server launching."""
@patch('openhands_cli.gui_launcher.check_docker_requirements')
@patch('openhands_cli.gui_launcher.print_formatted_text')
def test_launch_gui_server_docker_not_available(self, mock_print, mock_check_docker):
"""Test that launch_gui_server exits when Docker is not available."""
mock_check_docker.return_value = False
with pytest.raises(SystemExit) as exc_info:
launch_gui_server()
assert exc_info.value.code == 1
@pytest.mark.parametrize(
"pull_side_effect,run_side_effect,expected_exit_code,mount_cwd,gpu",
[
# Docker pull failure
(subprocess.CalledProcessError(1, 'docker pull'), None, 1, False, False),
# Docker 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)

View File

@ -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

View File

@ -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}'
)

View File

@ -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()

View File

@ -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('<gold>> </gold>', keys)
assert result == expected
def test_get_session_prompter_keyboard_interrupt():
"""Focused test for Ctrl+C behavior."""
_run_prompt_and_type('<gold>> </gold>', '\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) == '...'

View File

@ -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

View File

@ -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)

View File

@ -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