Compare commits

...

5 Commits

Author SHA1 Message Date
openhands
0defdbf07d Fix remaining path references after directory reorganization
- Remove duplicate openhands-cli path from packages/cli/pyproject.toml
- Update CODEOWNERS to reference packages/ui instead of openhands-ui
- Update dev_config to exclude vendor/ instead of third_party/
2025-11-17 19:08:25 +00:00
openhands
49e1165d7f Fix final path references in deployment/docker directory
- Update build.sh to reference deployment/docker paths
- Fix README.md docker build commands
- Update dev/compose.yml dockerfile path
- Fix dev/dev.sh script path
- Update dev/README.md documentation paths
2025-11-17 19:07:15 +00:00
openhands
07bc62c22e Fix remaining path references after directory reorganization
- Update docker-compose.yml to reference deployment/docker/app/Dockerfile
- Fix E2B README to reference deployment/docker/e2b-sandbox
- Update .gitignore paths for deployment/docker/runtime
- Fix enterprise README docker build command
- Update Dockerfile to reference deployment/docker/app/entrypoint.sh
- Fix runtime config.sh and README paths
- Update Kubernetes README to reference deployment/kubernetes/cluster.yaml
- Fix packages/ui package.json and documentation paths
2025-11-17 19:06:01 +00:00
openhands
1d47d725f9 Fix GitHub Actions workflow paths after directory reorganization
- Update lint.yml to use packages/cli instead of openhands-cli
- Update pypi-release.yml to use packages/cli instead of openhands-cli
- Update py-tests.yml to use packages/cli instead of openhands-cli
- Update pre-commit config to use new directory names (vendor/, packages/cli/, packages/ui/)
2025-11-17 18:53:45 +00:00
openhands
ff7ce15737 Reorganize repository structure to reduce root directory clutter
- Move configuration files to config/ directory (docker-compose.yml, pytest.ini, etc.)
- Move documentation files to docs/ directory (CONTRIBUTING.md, CODE_OF_CONDUCT.md, etc.)
- Move infrastructure to deployment/ directory:
  - containers/ → deployment/docker/
  - kind/ → deployment/kubernetes/
- Move packages to packages/ directory:
  - openhands-cli/ → packages/cli/
  - openhands-ui/ → packages/ui/
- Move build files to build/ directory (MANIFEST.in)
- Rename directories for clarity:
  - third_party/ → vendor/
  - cache/ → .cache/
- Delete unnecessary files (trigger_commit.txt, build.sh)
- Update all references in CI workflows, Makefile, pyproject.toml, and documentation

This reduces the root directory from 40+ items to ~12 items, making the repository
structure cleaner and more organized while preserving git history for all moves.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-17 18:40:59 +00:00
216 changed files with 1001 additions and 894 deletions

2
.github/CODEOWNERS vendored
View File

@@ -3,7 +3,7 @@
# Frontend code owners
/frontend/ @amanape
/openhands-ui/ @amanape
/packages/ui/ @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig

View File

@@ -75,6 +75,6 @@ updates:
- package-ecosystem: "docker"
directories:
- "containers/*"
- "deployment/docker/*"
schedule:
interval: "weekly"

View File

@@ -10,7 +10,7 @@ on:
- "*-cli"
pull_request:
paths:
- "openhands-cli/**"
- "packages/cli/**"
permissions:
contents: write # needed to create releases or upload assets
@@ -29,11 +29,11 @@ jobs:
# Build on Ubuntu 22.04 for maximum GLIBC compatibility (GLIBC 2.31)
- os: ubuntu-22.04
platform: linux
artifact_name: openhands-cli-linux
artifact_name: packages/cli-linux
# Build on macOS for macOS users
- os: macos-15
platform: macos
artifact_name: openhands-cli-macos
artifact_name: packages/cli-macos
runs-on: ${{ matrix.os }}
steps:
@@ -53,12 +53,12 @@ jobs:
version: "latest"
- name: Install dependencies
working-directory: openhands-cli
working-directory: packages/cli
run: |
uv sync
- name: Build binary executable
working-directory: openhands-cli
working-directory: packages/cli
run: |
./build.sh --install-pyinstaller | tee output.log
echo "Full output:"
@@ -73,7 +73,7 @@ jobs:
- name: Verify binary files exist
run: |
if ! ls openhands-cli/dist/openhands* 1> /dev/null 2>&1; then
if ! ls packages/cli/dist/openhands* 1> /dev/null 2>&1; then
echo "❌ No binaries found to upload!"
exit 1
fi
@@ -83,7 +83,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: openhands-cli/dist/openhands*
path: packages/cli/dist/openhands*
retention-days: 30
create-github-release:
@@ -104,11 +104,11 @@ jobs:
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
if [ -f artifacts/packages/cli-linux/openhands ]; then
cp artifacts/packages/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
if [ -f artifacts/packages/cli-macos/openhands ]; then
cp artifacts/packages/cli-macos/openhands release-assets/openhands-macos
fi
ls -la release-assets/

View File

@@ -82,7 +82,7 @@ jobs:
- name: Build and push app image
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
./deployment/docker/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
# Builds the runtime Docker images
ghcr_build_runtime:
@@ -124,7 +124,7 @@ jobs:
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder deployment/docker/runtime --force_rebuild
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
@@ -136,7 +136,7 @@ jobs:
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
./deployment/docker/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
@@ -150,7 +150,7 @@ jobs:
tags: ${{ env.DOCKER_TAGS }}
platforms: ${{ env.DOCKER_PLATFORM }}
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
context: deployment/docker/runtime
provenance: false
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
@@ -158,13 +158,13 @@ jobs:
uses: useblacksmith/build-push-action@v1
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
context: deployment/docker/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
path: deployment/docker/runtime
ghcr_build_enterprise:
name: Push Enterprise Image
@@ -271,7 +271,7 @@ jobs:
uses: actions/download-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
path: deployment/docker/runtime
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
@@ -282,7 +282,7 @@ jobs:
with:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
context: deployment/docker/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
@@ -333,7 +333,7 @@ jobs:
uses: actions/download-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
path: deployment/docker/runtime
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
@@ -344,7 +344,7 @@ jobs:
with:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
context: deployment/docker/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python

View File

@@ -88,5 +88,5 @@ jobs:
- name: Install pre-commit
run: pip install pre-commit==4.2.0
- name: Run pre-commit hooks
working-directory: ./openhands-cli
working-directory: ./packages/cli
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml

View File

@@ -1,13 +1,13 @@
name: Publish OpenHands UI Package
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
# * Run on PRs that have changes in the "packages/ui" folder or this workflow
on:
push:
branches:
- main
paths:
- "openhands-ui/**"
- "packages/ui/**"
- ".github/workflows/npm-publish-ui.yml"
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
@@ -35,13 +35,13 @@ jobs:
id: version-check
run: |
# Get current version from package.json
CURRENT_VERSION=$(jq -r .version openhands-ui/package.json)
CURRENT_VERSION=$(jq -r .version packages/ui/package.json)
echo "current-version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# Check if package.json version changed in this commit
if git diff HEAD~1 HEAD --name-only | grep -q "openhands-ui/package.json"; then
if git diff HEAD~1 HEAD --name-only | grep -q "packages/ui/package.json"; then
# Check if the version field specifically changed
if git diff HEAD~1 HEAD openhands-ui/package.json | grep -q '"version"'; then
if git diff HEAD~1 HEAD packages/ui/package.json | grep -q '"version"'; then
echo "Version changed in package.json, will publish"
echo "should-publish=true" >> $GITHUB_OUTPUT
else
@@ -68,19 +68,19 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
bun-version-file: "packages/ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
working-directory: ./packages/ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
working-directory: ./packages/ui
run: bun run build
- name: Check if package already exists on npm
id: npm-check
working-directory: ./openhands-ui
working-directory: ./packages/ui
run: |
PACKAGE_NAME=$(jq -r .name package.json)
VERSION="${{ needs.check-version.outputs.current-version }}"
@@ -101,7 +101,7 @@ jobs:
- name: Publish to npm
if: steps.npm-check.outputs.already-exists == 'false'
working-directory: ./openhands-ui
working-directory: ./packages/ui
run: |
# The prepublishOnly script will run automatically and build the package
npm publish

View File

@@ -125,15 +125,15 @@ jobs:
version: "latest"
- name: Install dependencies
working-directory: ./openhands-cli
working-directory: ./packages/cli
run: |
uv sync --group dev
- name: Run CLI unit tests
working-directory: ./openhands-cli
working-directory: ./packages/cli
env:
# write coverage to repo root so the merge step finds it
COVERAGE_FILE: "${{ github.workspace }}/.coverage.openhands-cli.${{ matrix.python-version }}"
COVERAGE_FILE: "${{ github.workspace }}/.coverage.packages-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 \
@@ -142,8 +142,8 @@ jobs:
- name: Store coverage file
uses: actions/upload-artifact@v4
with:
name: coverage-openhands-cli
path: ".coverage.openhands-cli.${{ matrix.python-version }}"
name: coverage-packages-cli
path: ".coverage.packages-cli.${{ matrix.python-version }}"
include-hidden-files: true
coverage-comment:
@@ -165,7 +165,7 @@ jobs:
merge-multiple: true
- name: Create symlink for CLI source files
run: ln -sf openhands-cli/openhands_cli openhands_cli
run: ln -sf packages/cli/openhands_cli openhands_cli
- name: Coverage comment
id: coverage_comment

View File

@@ -62,13 +62,13 @@ jobs:
version: "latest"
- name: Build CLI package
working-directory: openhands-cli
working-directory: packages/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
working-directory: packages/cli
run: |
uv publish --token ${{ secrets.PYPI_TOKEN_OPENHANDS }}

View File

@@ -1,14 +1,14 @@
name: Run UI Component Build
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
# * Run on PRs that have changes in the "packages/ui" folder or this workflow
on:
push:
branches:
- main
pull_request:
paths:
- 'openhands-ui/**'
- 'packages/ui/**'
- '.github/workflows/ui-build.yml'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
@@ -18,17 +18,17 @@ concurrency:
jobs:
ui-build:
name: Build openhands-ui
name: Build packages/ui
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
bun-version-file: "packages/ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
working-directory: ./packages/ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
working-directory: ./packages/ui
run: bun run build

6
.gitignore vendored
View File

@@ -253,9 +253,9 @@ run_instance_logs
runtime_*.tar
# docker build
containers/runtime/Dockerfile
containers/runtime/project.tar.gz
containers/runtime/code
deployment/docker/runtime/Dockerfile
deployment/docker/runtime/project.tar.gz
deployment/docker/runtime/code
**/node_modules/
# test results

View File

@@ -160,11 +160,11 @@ install-python-dependencies:
poetry run pip install playwright; \
poetry run playwright install chromium; \
else \
if [ ! -f cache/playwright_chromium_is_installed.txt ]; then \
if [ ! -f .cache/playwright_chromium_is_installed.txt ]; then \
echo "Running playwright install --with-deps chromium..."; \
poetry run playwright install --with-deps chromium; \
mkdir -p cache; \
touch cache/playwright_chromium_is_installed.txt; \
touch .cache/playwright_chromium_is_installed.txt; \
else \
echo "Setup already done. Skipping playwright installation."; \
fi \
@@ -214,7 +214,7 @@ kind:
kubectl config use-context kind-$(KIND_CLUSTER_NAME); \
else \
echo "$(YELLOW)Creating kind cluster '$(KIND_CLUSTER_NAME)'...$(RESET)"; \
kind create cluster --name $(KIND_CLUSTER_NAME) --config kind/cluster.yaml; \
kind create cluster --name $(KIND_CLUSTER_NAME) --config deployment/kubernetes/cluster.yaml; \
fi
@echo "$(YELLOW)Checking if mirrord is installed...$(RESET)"
@if ! command -v mirrord > /dev/null; then \
@@ -224,7 +224,7 @@ kind:
echo "$(BLUE)mirrord $(shell mirrord --version) is already installed.$(RESET)"; \
fi
@echo "$(YELLOW)Installing k8s mirrord resources...$(RESET)"
@kubectl apply -f kind/manifests
@kubectl apply -f deployment/kubernetes/manifests
@echo "$(GREEN)Mirrord resources installed successfully.$(RESET)"
@echo "$(YELLOW)Waiting for Mirrord pod to be ready.$(RESET)"
@sleep 5
@@ -341,7 +341,7 @@ docker-dev:
exit 0; \
else \
echo "$(YELLOW)Build and run in Docker $(OPTIONS)...$(RESET)"; \
./containers/dev/dev.sh $(OPTIONS); \
./deployment/docker/dev/dev.sh $(OPTIONS); \
fi
# Clean up all caches

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -e
poetry build -v

View File

@@ -3,7 +3,7 @@ services:
openhands:
build:
context: ./
dockerfile: ./containers/app/Dockerfile
dockerfile: ./deployment/docker/app/Dockerfile
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:

View File

@@ -7,6 +7,6 @@ by the `ghcr.yml` workflow.
## Building Manually
```bash
docker build -f containers/app/Dockerfile -t openhands .
docker build -f containers/sandbox/Dockerfile -t sandbox .
docker build -f deployment/docker/app/Dockerfile -t openhands .
docker build -f deployment/docker/sandbox/Dockerfile -t sandbox .
```

View File

@@ -85,7 +85,7 @@ RUN python openhands/core/download.py # No-op to download assets
RUN find /app \! -group openhands -exec chgrp openhands {} +
COPY --chown=openhands:openhands --chmod=770 --from=frontend-builder /app/build ./frontend/build
COPY --chown=openhands:openhands --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
COPY --chown=openhands:openhands --chmod=770 ./deployment/docker/app/entrypoint.sh /app/entrypoint.sh
USER root

View File

@@ -78,11 +78,11 @@ fi
echo "Tags: ${tags[@]}"
if [[ "$image_name" == "openhands" ]]; then
dir="./containers/app"
dir="./deployment/docker/app"
elif [[ "$image_name" == "runtime" ]]; then
dir="./containers/runtime"
dir="./deployment/docker/runtime"
else
dir="./containers/$image_name"
dir="./deployment/docker/$image_name"
fi
if [[ (! -f "$dir/Dockerfile") && "$image_name" != "runtime" ]]; then

View File

@@ -8,7 +8,7 @@ Install [Docker](https://docs.docker.com/engine/install/) on your host machine a
```bash
make docker-dev
# same as:
cd ./containers/dev
cd ./deployment/docker/dev
./dev.sh
```
@@ -51,7 +51,7 @@ You could optionally pass additional options to the build script.
```bash
make docker-dev OPTIONS="--build"
# or
./containers/dev/dev.sh --build
./deployment/docker/dev/dev.sh --build
```
See [docker compose run](https://docs.docker.com/reference/cli/docker/compose/run/) for more options.

View File

@@ -4,7 +4,7 @@ services:
privileged: true
build:
context: ${OPENHANDS_WORKSPACE:-../../}
dockerfile: ./containers/dev/Dockerfile
dockerfile: ./deployment/docker/dev/Dockerfile
image: openhands:dev
container_name: openhands-dev
environment:

View File

@@ -26,7 +26,7 @@ check_tools
##
OPENHANDS_WORKSPACE=$(git rev-parse --show-toplevel)
cd "$OPENHANDS_WORKSPACE/containers/dev/" || exit 1
cd "$OPENHANDS_WORKSPACE/deployment/docker/dev/" || exit 1
##
export BACKEND_HOST="0.0.0.0"

View File

@@ -3,10 +3,10 @@
This folder builds a runtime image (sandbox), which will use a dynamically generated `Dockerfile`
that depends on the `base_image` **AND** a [Python source distribution](https://docs.python.org/3.10/distutils/sourcedist.html) that is based on the current commit of `openhands`.
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `containers/runtime`:
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `deployment/docker/runtime`:
```bash
poetry run python3 -m openhands.runtime.utils.runtime_build \
--base_image nikolaik/python-nodejs:python3.12-nodejs22 \
--build_folder containers/runtime
--build_folder deployment/docker/runtime
```

View File

@@ -1,6 +1,6 @@
DOCKER_REGISTRY=ghcr.io
DOCKER_ORG=openhands
DOCKER_BASE_DIR="./containers/runtime"
DOCKER_BASE_DIR="./deployment/docker/runtime"
DOCKER_IMAGE=runtime
# These variables will be appended by the runtime_build.py script
# DOCKER_IMAGE_TAG=

View File

@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
exclude: ^(docs/|modules/|python/|packages/ui/|vendor/|enterprise/|packages/cli/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
exclude: ^(docs/|modules/|python/|packages/ui/|vendor/|enterprise/|packages/cli/)
- 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: ^(vendor/|enterprise/|packages/cli/)
# 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: ^(vendor/|enterprise/|packages/cli/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0

View File

@@ -10,7 +10,7 @@ strict_optional = True
disable_error_code = type-abstract
# Exclude third-party runtime directory from type checking
exclude = (third_party/|enterprise/)
exclude = (vendor/|enterprise/)
[mypy-openhands.memory.condenser.impl.*]
disable_error_code = override

View File

@@ -1,5 +1,5 @@
# Exclude third-party runtime directory from linting
exclude = ["third_party/", "enterprise/"]
exclude = ["vendor/", "enterprise/"]
[lint]
select = [

View File

View File

@@ -169,7 +169,7 @@ TL;DR
make docker-dev
```
See more details [here](./containers/dev/README.md).
See more details [here](./deployment/docker/dev/README.md).
If you are just interested in running `OpenHands` without installing all the required tools on your host.
@@ -180,7 +180,7 @@ make docker-run
If you do not have `make` on your host, run:
```bash
cd ./containers/dev
cd ./deployment/docker/dev
./dev.sh
```
@@ -196,7 +196,7 @@ Here's a guide to the important documentation files in the repository:
- [DOC_STYLE_GUIDE.md](https://github.com/All-Hands-AI/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/deployment/docker/README.md](./deployment/docker/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [/evaluation/README.md](./evaluation/README.md): Documentation for the evaluation framework and benchmarks
- [/microagents/README.md](./microagents/README.md): Information about the microagents architecture and implementation

View File

@@ -110,7 +110,7 @@ export REDIS_PORT=6379
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
```
docker build -f containers/app/Dockerfile -t openhands .
docker build -f deployment/docker/app/Dockerfile -t openhands .
```
### 3. Build SAAS Openhands

View File

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

View File

@@ -1,54 +0,0 @@
from unittest.mock import patch
from openhands_cli.agent_chat import run_cli_entry
import pytest
@patch("openhands_cli.agent_chat.print_formatted_text")
@patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation")
@patch("openhands_cli.tui.settings.settings_screen.prompt_api_key")
@patch("openhands_cli.tui.settings.settings_screen.choose_llm_model")
@patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider")
@patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation")
@patch("openhands_cli.tui.settings.store.AgentStore.load")
@pytest.mark.parametrize("interrupt_step", ["settings_type", "provider", "model", "api_key", "save"])
def test_first_time_users_can_escape_settings_flow_and_exit_app(
mock_agentstore_load,
mock_type,
mock_provider,
mock_model,
mock_api_key,
mock_save,
mock_print,
interrupt_step,
):
"""Test that KeyboardInterrupt is handled at each step of basic settings."""
# Force first-time user: no saved agent
mock_agentstore_load.return_value = None
# Happy path defaults
mock_type.return_value = "basic"
mock_provider.return_value = "openai"
mock_model.return_value = "gpt-4o-mini"
mock_api_key.return_value = "sk-test"
mock_save.return_value = True
# Inject KeyboardInterrupt at the specified step
if interrupt_step == "settings_type":
mock_type.side_effect = KeyboardInterrupt()
elif interrupt_step == "provider":
mock_provider.side_effect = KeyboardInterrupt()
elif interrupt_step == "model":
mock_model.side_effect = KeyboardInterrupt()
elif interrupt_step == "api_key":
mock_api_key.side_effect = KeyboardInterrupt()
elif interrupt_step == "save":
mock_save.side_effect = KeyboardInterrupt()
# Run
run_cli_entry()
# Assert graceful messaging
calls = [call.args[0] for call in mock_print.call_args_list]
assert any("Setup is required" in str(c) for c in calls)
assert any("Goodbye!" in str(c) for c in calls)

View File

@@ -1 +0,0 @@
1.2.17

View File

@@ -1 +0,0 @@
/// <reference types="@vitest/browser/providers/playwright" />

View File

@@ -25,20 +25,20 @@ _THIRD_PARTY_RUNTIME_CLASSES: dict[str, type[Runtime]] = {}
# Dynamically discover and import third-party runtimes
# Check if third_party package exists and discover runtimes
# Check if vendor package exists and discover runtimes
try:
import third_party.runtime.impl
import vendor.runtime.impl
third_party_base = 'third_party.runtime.impl'
vendor_base = 'vendor.runtime.impl'
# List of potential third-party runtime modules to try
# These are discovered from the third_party directory structure
# These are discovered from the vendor directory structure
potential_runtimes = []
try:
import pkgutil
for importer, modname, ispkg in pkgutil.iter_modules(
third_party.runtime.impl.__path__
vendor.runtime.impl.__path__
):
if ispkg:
potential_runtimes.append(modname)
@@ -49,7 +49,7 @@ try:
# Try to import each discovered runtime
for runtime_name in potential_runtimes:
try:
module_path = f'{third_party_base}.{runtime_name}.{runtime_name}_runtime'
module_path = f'{vendor_base}.{runtime_name}.{runtime_name}_runtime'
module = importlib.import_module(module_path)
# Try different class name patterns
@@ -80,7 +80,7 @@ try:
pass
except ImportError:
# third_party package not available
# vendor package not available
pass
# Combine core and third-party runtimes

View File

@@ -76,7 +76,7 @@ make kind # target is stateless and will check for an existing kind cluster or m
This command will:
1. **Check Dependencies**: Verify that `kind`, `kubectl`, and `mirrord` are installed
2. **Create KIND Cluster**: Create a local Kubernetes cluster named "local-hands" using the configuration in `kind/cluster.yaml`
2. **Create KIND Cluster**: Create a local Kubernetes cluster named "local-hands" using the configuration in `deployment/kubernetes/cluster.yaml`
3. **Deploy Infrastructure**: Apply Kubernetes manifests including:
- Ubuntu development pod for runtime execution
- Nginx ingress controller for HTTP routing

View File

@@ -408,7 +408,7 @@ if __name__ == '__main__':
if args.build_folder is not None:
# If a build_folder is provided, we do not actually build the Docker image. We copy the necessary source code
# and create a Dockerfile dynamically and place it in the build_folder only. This allows the Docker image to
# then be created using the Dockerfile (most likely using the containers/build.sh script)
# then be created using the Dockerfile (most likely using the deployment/docker/build.sh script)
build_folder = args.build_folder
assert os.path.exists(build_folder), (
f'Build folder {build_folder} does not exist'
@@ -448,7 +448,7 @@ if __name__ == '__main__':
)
# We now update the config.sh in the build_folder to contain the required values. This is used in the
# containers/build.sh script which is called to actually build the Docker image
# deployment/docker/build.sh script which is called to actually build the Docker image
with open(os.path.join(build_folder, 'config.sh'), 'a') as file:
file.write(
(

View File

@@ -53,4 +53,3 @@ coverage.xml
# Generated artifacts
build

View File

@@ -1,6 +1,6 @@
# 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)).
A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [OpenHands software-agent-sdk](https://github.com/OpenHands/software-agent-sdk)).
---

View File

@@ -15,8 +15,8 @@ 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_cli.utils import get_default_cli_agent, get_llm_metadata
from openhands.sdk import LLM
@@ -269,7 +269,11 @@ def main() -> int:
llm=LLM(
model='dummy-model',
api_key='dummy-key',
litellm_extra_body={"metadata": get_llm_metadata(model_name='dummy-model', llm_type='openhands')},
litellm_extra_body={
'metadata': get_llm_metadata(
model_name='dummy-model', llm_type='openhands'
)
},
)
)
if not test_executable(dummy_agent):
@@ -289,4 +293,3 @@ if __name__ == '__main__':
print(e)
print('❌ Executable test failed')
sys.exit(1)

View File

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

View File

@@ -5,22 +5,22 @@ Provides a conversation interface with an AI agent using OpenHands patterns.
"""
import sys
from datetime import datetime
import uuid
from datetime import datetime
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
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
verify_agent_exists_or_setup_agent,
)
from openhands_cli.tui.settings.mcp_screen import MCPScreen
from openhands_cli.tui.settings.settings_screen import SettingsScreen
@@ -59,7 +59,6 @@ def _print_exit_hint(conversation_id: str) -> None:
)
def run_cli_entry(resume_conversation_id: str | None = None) -> None:
"""Run the agent chat session using the agent SDK.
@@ -74,7 +73,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
if resume_conversation_id:
try:
conversation_id = uuid.UUID(resume_conversation_id)
except ValueError as e:
except ValueError:
print_formatted_text(
HTML(
f"<yellow>Warning: '{resume_conversation_id}' is not a valid UUID.</yellow>"
@@ -85,11 +84,12 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
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>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
@@ -127,7 +127,9 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
break
elif command == '/settings':
settings_screen = SettingsScreen(runner.conversation if runner else None)
settings_screen = SettingsScreen(
runner.conversation if runner else None
)
settings_screen.display_settings()
continue
@@ -184,7 +186,8 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
conversation = runner.conversation
if not (
conversation.state.execution_status == ConversationExecutionStatus.PAUSED
conversation.state.execution_status
== ConversationExecutionStatus.PAUSED
or conversation.state.execution_status
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
):

View File

@@ -5,7 +5,7 @@ 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
"""
@@ -21,36 +21,26 @@ Examples:
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'
)
parser.add_argument('--resume', type=str, help='Conversation ID to resume')
# Only serve as subcommand
subparsers = parser.add_subparsers(
dest='command',
help='Additional commands'
)
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', 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'
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'
'--gpu', action='store_true', help='Enable GPU support in the Docker container'
)
return parser
return parser

View File

@@ -5,16 +5,15 @@ 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', help='Launch the OpenHands GUI server using Docker (web interface)'
)
serve_parser.add_argument(
'--mount-cwd',
@@ -28,4 +27,4 @@ def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.Argumen
action='store_true',
default=False,
)
return serve_parser
return serve_parser

View File

@@ -8,6 +8,7 @@ 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

View File

@@ -12,9 +12,9 @@ from openhands.sdk.security.confirmation_policy import (
NeverConfirm,
)
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
from openhands_cli.setup import setup_conversation
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:
@@ -31,8 +31,7 @@ class ConversationRunner:
new_confirmation_mode_state = not self.is_confirmation_mode_active
self.conversation = setup_conversation(
self.conversation.id,
include_security_analyzer=new_confirmation_mode_state
self.conversation.id, include_security_analyzer=new_confirmation_mode_state
)
if new_confirmation_mode_state:
@@ -47,7 +46,6 @@ class ConversationRunner:
) -> None:
self.conversation.set_confirmation_policy(confirmation_policy)
def _start_listener(self) -> None:
self.listener = PauseListener(on_pause=self.conversation.pause)
self.listener.start()
@@ -129,7 +127,6 @@ class ConversationRunner:
else:
raise Exception('Infinite loop')
def _handle_confirmation_request(self) -> UserConfirmation:
"""Handle confirmation request from user.

View File

@@ -1,22 +1,18 @@
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
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
# register tools
from openhands.tools.terminal import TerminalTool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from openhands_cli.tui.settings.store import AgentStore
from openhands_cli.tui.visualizer import CLIVisualizer
class MissingAgentSpec(Exception):
@@ -25,7 +21,6 @@ class MissingAgentSpec(Exception):
pass
def load_agent_specs(
conversation_id: str | None = None,
) -> Agent:
@@ -39,9 +34,7 @@ def load_agent_specs(
def verify_agent_exists_or_setup_agent() -> Agent:
"""Verify agent specs exists by attempting to load it.
"""
"""Verify agent specs exists by attempting to load it."""
settings_screen = SettingsScreen()
try:
agent = load_agent_specs()
@@ -50,14 +43,12 @@ def verify_agent_exists_or_setup_agent() -> Agent:
# 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
conversation_id: uuid, include_security_analyzer: bool = True
) -> BaseConversation:
"""
Setup the conversation with agent.
@@ -69,14 +60,10 @@ def setup_conversation(
MissingAgentSpec: If agent specification is not found or invalid.
"""
print_formatted_text(
HTML(f'<white>Initializing agent...</white>')
)
print_formatted_text(HTML('<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,
@@ -84,7 +71,7 @@ def setup_conversation(
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id,
visualizer=CLIVisualizer
visualizer=CLIVisualizer,
)
# Security analyzer is set though conversation API now
@@ -98,4 +85,3 @@ def setup_conversation(
HTML(f'<green>✓ Agent initialized with model: {agent.llm.model}</green>')
)
return conversation

View File

@@ -6,7 +6,6 @@ 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()

View File

@@ -1,11 +1,5 @@
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
@@ -21,6 +15,12 @@ from openhands_cli.user_actions.settings_action import (
save_settings_confirmation,
settings_type_confirmation,
)
from openhands_cli.utils import get_default_cli_agent, get_llm_metadata
from prompt_toolkit import HTML, print_formatted_text
from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.widgets import Frame, TextArea
from openhands.sdk import LLM, BaseConversation, LLMSummarizingCondenser, LocalFileStore
class SettingsScreen:
@@ -63,16 +63,19 @@ class SettingsScreen:
)
if self.conversation:
labels_and_values.extend([
(
' Confirmation Mode',
'Enabled'
if self.conversation.is_confirmation_mode_active
else 'Disabled',
)
])
labels_and_values.extend(
[
(
' Confirmation Mode',
'Enabled'
if self.conversation.is_confirmation_mode_active
else 'Disabled',
)
]
)
labels_and_values.extend([
labels_and_values.extend(
[
(
' Memory Condensation',
'Enabled' if agent_spec.condenser else 'Disabled',
@@ -158,7 +161,9 @@ class SettingsScreen:
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,
self.conversation.state.agent.llm.api_key
if self.conversation
else None,
escapable=escapable,
)
memory_condensation = choose_memory_condensation(step_counter)
@@ -180,7 +185,9 @@ class SettingsScreen:
api_key=api_key,
base_url=base_url,
usage_id='agent',
litellm_extra_body={"metadata": get_llm_metadata(model_name=model, llm_type='agent')},
litellm_extra_body={
'metadata': get_llm_metadata(model_name=model, llm_type='agent')
},
)
agent = self.agent_store.load()
@@ -190,9 +197,7 @@ class SettingsScreen:
# Must update all LLMs
agent = agent.model_copy(update={'llm': llm})
condenser = LLMSummarizingCondenser(
llm=llm.model_copy(
update={"usage_id": "condenser"}
)
llm=llm.model_copy(update={'usage_id': 'condenser'})
)
agent = agent.model_copy(update={'condenser': condenser})
self.agent_store.save(agent)

View File

@@ -38,14 +38,11 @@ class AgentStore:
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}
)
agent = agent.model_copy(update={'security_analyzer': None})
self.save(agent)
# Update tools with most recent working directory
@@ -61,7 +58,9 @@ class AgentStore:
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}})
updated_llm = agent.llm.model_copy(
update={'litellm_extra_body': {'metadata': agent_llm_metadata}}
)
condenser_updates = {}
if agent.condenser and isinstance(agent.condenser, LLMSummarizingCondenser):

View File

@@ -2,12 +2,13 @@
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
from openhands.sdk import BaseConversation
def display_status(
conversation: BaseConversation,
@@ -31,7 +32,7 @@ def display_status(
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
uptime_str = f"{hours}h {minutes}m {seconds}s"
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>'))
@@ -54,7 +55,7 @@ def display_status(
total_output_tokens,
cache_hits,
cache_writes,
total_tokens
total_tokens,
)
@@ -64,7 +65,7 @@ def _display_usage_metrics_container(
total_output_tokens: int,
cache_hits: int,
cache_writes: int,
total_tokens: int
total_tokens: int,
) -> None:
"""Display usage metrics using prompt_toolkit containers."""
# Format values with proper formatting

View File

@@ -7,7 +7,6 @@ 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()

View File

@@ -19,30 +19,29 @@ from openhands.sdk.event import (
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"
_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"
_SYSTEM_COLOR = 'magenta'
_THOUGHT_COLOR = 'bright_black'
_ERROR_COLOR = 'red'
# These are agent actions
_ACTION_COLOR = "blue"
_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}",
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",
r'\*\*(.*?)\*\*': 'bold',
r'\*(.*?)\*': 'italic',
}
_PANEL_PADDING = (1, 1)
@@ -126,20 +125,20 @@ class CLIVisualizer(ConversationVisualizerBase):
# Don't emit system prompt in CLI
if isinstance(event, SystemPromptEvent):
title = f"[bold {_SYSTEM_COLOR}]"
title = f'[bold {_SYSTEM_COLOR}]'
if self._name:
title += f"{self._name} "
title += f"System Prompt[/bold {_SYSTEM_COLOR}]"
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}]"
title = f'[bold {_ACTION_COLOR}]'
if self._name:
title += f"{self._name} "
title += f'{self._name} '
if event.action is None:
title += f"Agent Action (Not Executed)[/bold {_ACTION_COLOR}]"
title += f'Agent Action (Not Executed)[/bold {_ACTION_COLOR}]'
else:
title += f"Agent Action[/bold {_ACTION_COLOR}]"
title += f'Agent Action[/bold {_ACTION_COLOR}]'
return Panel(
content,
title=title,
@@ -149,10 +148,10 @@ class CLIVisualizer(ConversationVisualizerBase):
expand=True,
)
elif isinstance(event, ObservationEvent):
title = f"[bold {_OBSERVATION_COLOR}]"
title = f'[bold {_OBSERVATION_COLOR}]'
if self._name:
title += f"{self._name} "
title += f"Observation[/bold {_OBSERVATION_COLOR}]"
title += f'{self._name} '
title += f'Observation[/bold {_OBSERVATION_COLOR}]'
return Panel(
content,
title=title,
@@ -161,10 +160,10 @@ class CLIVisualizer(ConversationVisualizerBase):
expand=True,
)
elif isinstance(event, UserRejectObservation):
title = f"[bold {_ERROR_COLOR}]"
title = f'[bold {_ERROR_COLOR}]'
if self._name:
title += f"{self._name} "
title += f"User Rejected Action[/bold {_ERROR_COLOR}]"
title += f'{self._name} '
title += f'User Rejected Action[/bold {_ERROR_COLOR}]'
return Panel(
content,
title=title,
@@ -176,30 +175,30 @@ class CLIVisualizer(ConversationVisualizerBase):
if (
self._skip_user_messages
and event.llm_message
and event.llm_message.role == "user"
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,
'user': _MESSAGE_USER_COLOR,
'assistant': _MESSAGE_ASSISTANT_COLOR,
}
role_color = role_colors.get(event.llm_message.role, "white")
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 ""
agent_name = f'{self._name} ' if self._name else ''
if event.llm_message.role == "user":
if event.llm_message.role == 'user':
title_text = (
f"[bold {role_color}]User Message to "
f"{agent_name}Agent[/bold {role_color}]"
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}]"
f'[bold {role_color}]Message from '
f'{agent_name}Agent[/bold {role_color}]'
)
return Panel(
content,
@@ -210,10 +209,10 @@ class CLIVisualizer(ConversationVisualizerBase):
expand=True,
)
elif isinstance(event, AgentErrorEvent):
title = f"[bold {_ERROR_COLOR}]"
title = f'[bold {_ERROR_COLOR}]'
if self._name:
title += f"{self._name} "
title += f"Agent Error[/bold {_ERROR_COLOR}]"
title += f'{self._name} '
title += f'Agent Error[/bold {_ERROR_COLOR}]'
return Panel(
content,
title=title,
@@ -223,10 +222,10 @@ class CLIVisualizer(ConversationVisualizerBase):
expand=True,
)
elif isinstance(event, PauseEvent):
title = f"[bold {_PAUSE_COLOR}]"
title = f'[bold {_PAUSE_COLOR}]'
if self._name:
title += f"{self._name} "
title += f"User Paused[/bold {_PAUSE_COLOR}]"
title += f'{self._name} '
title += f'User Paused[/bold {_PAUSE_COLOR}]'
return Panel(
content,
title=title,
@@ -235,10 +234,10 @@ class CLIVisualizer(ConversationVisualizerBase):
expand=True,
)
elif isinstance(event, Condensation):
title = f"[bold {_SYSTEM_COLOR}]"
title = f'[bold {_SYSTEM_COLOR}]'
if self._name:
title += f"{self._name} "
title += f"Condensation[/bold {_SYSTEM_COLOR}]"
title += f'{self._name} '
title += f'Condensation[/bold {_SYSTEM_COLOR}]'
return Panel(
content,
title=title,
@@ -248,14 +247,14 @@ class CLIVisualizer(ConversationVisualizerBase):
)
else:
# Fallback panel for unknown event types
title = f"[bold {_ERROR_COLOR}]"
title = f'[bold {_ERROR_COLOR}]'
if self._name:
title += f"{self._name} "
title += f"UNKNOWN Event: {event.__class__.__name__}[/bold {_ERROR_COLOR}]"
title += f'{self._name} '
title += f'UNKNOWN Event: {event.__class__.__name__}[/bold {_ERROR_COLOR}]'
return Panel(
content,
title=title,
subtitle=f"({event.source})",
subtitle=f'({event.source})',
border_style=_ERROR_COLOR,
padding=_PANEL_PADDING,
expand=True,
@@ -279,14 +278,14 @@ class CLIVisualizer(ConversationVisualizerBase):
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"
val, suffix = n / 1_000_000_000, 'B'
elif n >= 1_000_000:
val, suffix = n / 1_000_000, "M"
val, suffix = n / 1_000_000, 'M'
elif n >= 1_000:
val, suffix = n / 1_000, "K"
val, suffix = n / 1_000, 'K'
else:
return str(n)
return f"{val:.2f}".rstrip("0").rstrip(".") + suffix
return f'{val:.2f}'.rstrip('0').rstrip('.') + suffix
input_tokens = abbr(usage.prompt_tokens or 0)
output_tokens = abbr(usage.completion_tokens or 0)
@@ -294,19 +293,19 @@ class CLIVisualizer(ConversationVisualizerBase):
# 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"
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"
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]")
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]")
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)
return 'Tokens: ' + ''.join(parts)

View File

@@ -1,4 +1,5 @@
import html
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk.security.confirmation_policy import (

View File

@@ -1,9 +1,9 @@
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.sdk.llm import UNVERIFIED_MODELS_EXCLUDING_BEDROCK, VERIFIED_MODELS
from openhands_cli.tui.utils import StepCounter
from openhands_cli.user_actions.utils import (
NonEmptyValueValidator,
@@ -19,18 +19,14 @@ class SettingsType(Enum):
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)'
]
"\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':
@@ -126,11 +122,11 @@ def prompt_api_key(
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

View File

@@ -2,8 +2,10 @@
import os
from typing import Any
from openhands.tools.preset import get_default_agent
from openhands.sdk import LLM
from openhands.tools.preset import get_default_agent
def get_llm_metadata(
model_name: str,
@@ -58,12 +60,7 @@ def get_llm_metadata(
return metadata
def get_default_cli_agent(
llm: LLM
):
agent = get_default_agent(
llm=llm,
cli_mode=True
)
def get_default_cli_agent(llm: LLM):
agent = get_default_agent(llm=llm, cli_mode=True)
return agent

View File

@@ -89,7 +89,6 @@ omit = [ "tests/*", "**/test_*" ]
[tool.coverage.paths]
source = [
"openhands_cli/",
"openhands-cli/openhands_cli/",
]
[tool.mypy]

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env python3
from unittest.mock import MagicMock, patch, call
import pytest
from unittest.mock import MagicMock, call, patch
import pytest
from openhands_cli.runner import ConversationRunner
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm
CONV_ID = "test-conversation-id"
CONV_ID = 'test-conversation-id'
# ---------- Helpers ----------
@@ -34,7 +35,7 @@ def runner_enabled() -> ConversationRunner:
# ---------- Core toggle behavior (parametrized) ----------
@pytest.mark.parametrize(
"start_enabled, include_security_analyzer, expected_enabled, expected_policy_cls",
'start_enabled, include_security_analyzer, expected_enabled, expected_policy_cls',
[
# disabled -> enable
(False, True, True, AlwaysConfirm),
@@ -49,7 +50,9 @@ def test_toggle_confirmation_mode_transitions(
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:
with patch(
'openhands_cli.runner.setup_conversation', return_value=target_conv
) as mock_setup:
# Act
runner.toggle_confirmation_mode()
@@ -58,11 +61,15 @@ def test_toggle_confirmation_mode_transitions(
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)
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)
assert isinstance(
target_conv.set_confirmation_policy.call_args.args[0], expected_policy_cls
)
# ---------- Conversation ID is preserved across multiple toggles ----------
@@ -70,7 +77,7 @@ def test_maintains_conversation_id_across_toggles(runner_disabled: ConversationR
enabled_conv = make_conv(enabled=True)
disabled_conv = make_conv(enabled=False)
with patch("openhands_cli.runner.setup_conversation") as mock_setup:
with patch('openhands_cli.runner.setup_conversation') as mock_setup:
mock_setup.side_effect = [enabled_conv, disabled_conv]
# Toggle on, then off
@@ -88,12 +95,19 @@ def test_maintains_conversation_id_across_toggles(runner_disabled: ConversationR
# ---------- Idempotency under rapid alternating toggles ----------
def test_rapid_alternating_toggles_produce_expected_states(runner_disabled: ConversationRunner):
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]
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

View File

@@ -3,17 +3,16 @@
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
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output.defaults import DummyOutput
@patch("openhands_cli.setup.load_agent_specs")
@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
@@ -28,8 +27,8 @@ def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
mock_load_agent_specs.assert_called_once_with()
@patch("openhands_cli.setup.SettingsScreen")
@patch("openhands_cli.setup.load_agent_specs")
@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
):
@@ -41,7 +40,7 @@ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
# 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"),
MissingAgentSpec('Agent spec missing'),
mock_agent,
]
@@ -56,11 +55,11 @@ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
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")
@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,
@@ -77,7 +76,7 @@ def test_new_command_resets_confirmation_mode(
# Mock conversation - only one is created when /new is called
conv1 = MagicMock()
conv1.id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
mock_setup_conversation.return_value = conv1
# One runner instance for the conversation
@@ -100,7 +99,7 @@ def test_new_command_resets_confirmation_mode(
# Trigger /new
# First user message should trigger runner creation
# Then /exit (exit will be auto-accepted)
for ch in "/new\rhello\r/exit\r":
for ch in '/new\rhello\r/exit\r':
pipe.send_text(ch)
run_cli_entry(None)

View File

@@ -2,16 +2,17 @@
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from openhands_cli.user_actions import UserConfirmation
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."""
@@ -34,12 +35,19 @@ def mock_runner():
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:
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
@@ -60,7 +68,10 @@ def run_resume_command_test(commands, agent_status=None, expect_runner_created=T
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
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)
@@ -81,28 +92,32 @@ def run_resume_command_test(commands, agent_status=None, expect_runner_created=T
# ---------- Warning tests (parametrized) ----------
@pytest.mark.parametrize(
"commands,expected_warning,expect_runner_created",
'commands,expected_warning,expect_runner_created',
[
# No active conversation - /resume immediately
("/resume\r/exit\r", "No active conversation running", False),
('/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),
('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
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}"
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:
@@ -114,8 +129,9 @@ def test_resume_command_warnings(commands, expected_warning, expect_runner_creat
# ---------- Successful resume tests (parametrized) ----------
@pytest.mark.parametrize(
"agent_status",
'agent_status',
[
ConversationExecutionStatus.PAUSED,
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION,
@@ -123,7 +139,7 @@ def test_resume_command_warnings(commands, expected_warning, expect_runner_creat
)
def test_resume_command_successful_resume(agent_status):
"""Test /resume command successfully resumes paused/waiting conversations."""
commands = "hello\r/resume\r/exit\r"
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
@@ -140,8 +156,10 @@ def test_resume_command_successful_resume(agent_status):
# 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"
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"
assert second_call_args[0] is None, (
'Second call should have None message for resume'
)

View File

@@ -1,11 +1,11 @@
"""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
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output.defaults import DummyOutput
@patch('openhands_cli.agent_chat.exit_session_confirmation')
@@ -38,20 +38,23 @@ def test_settings_command_works_without_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
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":
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()
mock_settings_screen.display_settings.assert_called_once()

View File

@@ -1,17 +1,17 @@
"""Simplified tests for the /status command functionality."""
from datetime import datetime, timedelta
from uuid import uuid4
from unittest.mock import Mock, patch
from uuid import uuid4
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."""
@@ -32,47 +32,54 @@ def make_metrics(cost=None, usage=None) -> Metrics:
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:
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", "")
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:
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
assert hasattr(container, 'title')
assert 'Usage Metrics' in container.title
@pytest.mark.parametrize(
"delta,expected",
'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"),
(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'):
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])
@@ -83,12 +90,12 @@ def test_display_status_uptime(conversation, delta, expected):
@pytest.mark.parametrize(
"cost,usage,expecteds",
'cost,usage,expecteds',
[
# Empty/zero case
(None, None, ["$0.000000", "0", "0", "0", "0", "0"]),
(None, None, ['$0.000000', '0', '0', '0', '0', '0']),
# Only cost, usage=None
(0.05, None, ["$0.050000", "0", "0", "0", "0", "0"]),
(0.05, None, ['$0.050000', '0', '0', '0', '0', '0']),
# Full metrics
(
0.123456,
@@ -98,7 +105,7 @@ def test_display_status_uptime(conversation, delta, expected):
cache_read_tokens=200,
cache_write_tokens=100,
),
["$0.123456", "1,500", "800", "200", "100", "2,300"],
['$0.123456', '1,500', '800', '200', '100', '2,300'],
),
# Larger numbers (comprehensive)
(
@@ -109,13 +116,15 @@ def test_display_status_uptime(conversation, delta, expected):
cache_read_tokens=500,
cache_write_tokens=250,
),
["$1.234567", "5,000", "3,000", "500", "250", "8,000"],
['$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)
conversation.conversation_stats.get_combined_metrics.return_value = make_metrics(
cost, usage
)
pf, pc, text = call_display_status(conversation, session_start)

View File

@@ -1,32 +1,33 @@
"""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
from openhands_cli.user_actions.settings_action import prompt_api_key
from pydantic import SecretStr
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")
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=''):
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
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(), (
@@ -38,19 +39,20 @@ def test_api_key_preservation_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"
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):
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
escapable=True,
)
# Should return the new API key
assert result == new_api_key

View File

@@ -1,20 +1,20 @@
"""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 import 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(
@@ -40,34 +40,46 @@ def test_first_time_settings_creates_default_agent_and_conversation_with_securit
):
# 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"
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"
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'))
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}'"
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',
@@ -95,23 +107,33 @@ def test_first_time_settings_with_advanced_configuration():
),
):
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"
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"
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'))
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"
assert conversation.state.security_analyzer is not None, (
'Conversation should have a security analyzer'
)
assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', (
'Security analyzer should be LLMSecurityAnalyzer'
)

View File

@@ -0,0 +1,57 @@
from unittest.mock import patch
import pytest
from openhands_cli.agent_chat import run_cli_entry
@patch('openhands_cli.agent_chat.print_formatted_text')
@patch('openhands_cli.tui.settings.settings_screen.save_settings_confirmation')
@patch('openhands_cli.tui.settings.settings_screen.prompt_api_key')
@patch('openhands_cli.tui.settings.settings_screen.choose_llm_model')
@patch('openhands_cli.tui.settings.settings_screen.choose_llm_provider')
@patch('openhands_cli.tui.settings.settings_screen.settings_type_confirmation')
@patch('openhands_cli.tui.settings.store.AgentStore.load')
@pytest.mark.parametrize(
'interrupt_step', ['settings_type', 'provider', 'model', 'api_key', 'save']
)
def test_first_time_users_can_escape_settings_flow_and_exit_app(
mock_agentstore_load,
mock_type,
mock_provider,
mock_model,
mock_api_key,
mock_save,
mock_print,
interrupt_step,
):
"""Test that KeyboardInterrupt is handled at each step of basic settings."""
# Force first-time user: no saved agent
mock_agentstore_load.return_value = None
# Happy path defaults
mock_type.return_value = 'basic'
mock_provider.return_value = 'openai'
mock_model.return_value = 'gpt-4o-mini'
mock_api_key.return_value = 'sk-test'
mock_save.return_value = True
# Inject KeyboardInterrupt at the specified step
if interrupt_step == 'settings_type':
mock_type.side_effect = KeyboardInterrupt()
elif interrupt_step == 'provider':
mock_provider.side_effect = KeyboardInterrupt()
elif interrupt_step == 'model':
mock_model.side_effect = KeyboardInterrupt()
elif interrupt_step == 'api_key':
mock_api_key.side_effect = KeyboardInterrupt()
elif interrupt_step == 'save':
mock_save.side_effect = KeyboardInterrupt()
# Run
run_cli_entry()
# Assert graceful messaging
calls = [call.args[0] for call in mock_print.call_args_list]
assert any('Setup is required' in str(c) for c in calls)
assert any('Goodbye!' in str(c) for c in calls)

View File

@@ -3,34 +3,36 @@
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from openhands_cli.locations import AGENT_SETTINGS_PATH, MCP_CONFIG_FILE
from openhands_cli.tui.settings.store import AgentStore
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
from openhands.sdk import LLM, Agent
# ---------------------- 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})
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 = tmp_path / 'openhands'
root.mkdir()
monkeypatch.setattr("openhands_cli.tui.settings.store.PERSISTENCE_DIR", str(root))
monkeypatch.setattr('openhands_cli.tui.settings.store.PERSISTENCE_DIR', str(root))
return root
@@ -41,22 +43,20 @@ def agent_store() -> AgentStore:
# ---------------------- tests ----------------------
@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[])
@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={})
@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
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"),
llm=LLM(model='gpt-4', api_key=SecretStr('k'), usage_id='svc'),
tools=[],
mcp_config={
"mcpServers": {
"persistent_server": {"command": "python", "args": ["-m", "old_server"]}
'mcpServers': {
'persistent_server': {'command': 'python', 'args': ['-m', 'old_server']}
}
},
)
@@ -66,8 +66,8 @@ def test_load_overrides_persisted_mcp_with_mcp_json_file(
write_json(
persistence_dir / MCP_CONFIG_FILE,
{
"mcpServers": {
"file_server": {"command": "uvx", "args": ["mcp-server-fetch"]}
'mcpServers': {
'file_server': {'command': 'uvx', 'args': ['mcp-server-fetch']}
}
},
)
@@ -76,32 +76,29 @@ def test_load_overrides_persisted_mcp_with_mcp_json_file(
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",
'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={})
@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
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"),
llm=LLM(model='gpt-4', api_key=SecretStr('k'), usage_id='svc'),
tools=[],
mcp_config={
"mcpServers": {
"persistent_server": {"command": "python", "args": ["-m", "old_server"]}
'mcpServers': {
'persistent_server': {'command': 'python', 'args': ['-m', 'old_server']}
}
},
)

Some files were not shown because too many files have changed in this diff Show More