mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
Chore: move CLI code its own repo (#11724)
This commit is contained in:
parent
494eba094f
commit
1a33606987
13
.github/scripts/update_pr_description.sh
vendored
13
.github/scripts/update_pr_description.sh
vendored
@ -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
|
||||
|
||||
@ -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 }}
|
||||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
@ -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
|
||||
|
||||
50
.github/workflows/py-tests.yml
vendored
50
.github/workflows/py-tests.yml
vendored
@ -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
|
||||
|
||||
34
.github/workflows/pypi-release.yml
vendored
34
.github/workflows/pypi-release.yml
vendored
@ -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 }}
|
||||
|
||||
@ -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
|
||||
|
||||
56
openhands-cli/.gitignore
vendored
56
openhands-cli/.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
```
|
||||
@ -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)
|
||||
|
||||
@ -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[@]}"
|
||||
@ -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')
|
||||
@ -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
|
||||
)
|
||||
@ -1,8 +0,0 @@
|
||||
"""OpenHands package."""
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
try:
|
||||
__version__ = version("openhands")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -1,3 +0,0 @@
|
||||
from openhands_cli.listeners.pause_listener import PauseListener
|
||||
|
||||
__all__ = ['PauseListener']
|
||||
@ -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()
|
||||
@ -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'
|
||||
@ -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])
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
@ -1,5 +0,0 @@
|
||||
from openhands_cli.tui.tui import DEFAULT_STYLE
|
||||
|
||||
__all__ = [
|
||||
'DEFAULT_STYLE',
|
||||
]
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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}'
|
||||
@ -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)
|
||||
@ -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',
|
||||
]
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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]
|
||||
@ -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 = ''
|
||||
@ -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.'
|
||||
)
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""Tests for OpenHands CLI."""
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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]
|
||||
@ -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()
|
||||
@ -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
|
||||
)
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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}'
|
||||
)
|
||||
@ -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()
|
||||
@ -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) == '...'
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user