mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
5 Commits
fix-github
...
cleanup-ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0defdbf07d | ||
|
|
49e1165d7f | ||
|
|
07bc62c22e | ||
|
|
1d47d725f9 | ||
|
|
ff7ce15737 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -3,7 +3,7 @@
|
||||
|
||||
# Frontend code owners
|
||||
/frontend/ @amanape
|
||||
/openhands-ui/ @amanape
|
||||
/packages/ui/ @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -75,6 +75,6 @@ updates:
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
- "containers/*"
|
||||
- "deployment/docker/*"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
20
.github/workflows/ghcr-build.yml
vendored
20
.github/workflows/ghcr-build.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
20
.github/workflows/npm-publish-ui.yml
vendored
20
.github/workflows/npm-publish-ui.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/py-tests.yml
vendored
12
.github/workflows/py-tests.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/pypi-release.yml
vendored
4
.github/workflows/pypi-release.yml
vendored
@@ -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 }}
|
||||
|
||||
12
.github/workflows/ui-build.yml
vendored
12
.github/workflows/ui-build.yml
vendored
@@ -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
6
.gitignore
vendored
@@ -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
|
||||
|
||||
10
Makefile
10
Makefile
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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 .
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Exclude third-party runtime directory from linting
|
||||
exclude = ["third_party/", "enterprise/"]
|
||||
exclude = ["vendor/", "enterprise/"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
"""OpenHands package."""
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
try:
|
||||
__version__ = version("openhands")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
@@ -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 +0,0 @@
|
||||
1.2.17
|
||||
1
openhands-ui/vitest.shims.d.ts
vendored
1
openhands-ui/vitest.shims.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="@vitest/browser/providers/playwright" />
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -53,4 +53,3 @@ coverage.xml
|
||||
|
||||
# Generated artifacts
|
||||
build
|
||||
|
||||
@@ -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)).
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
8
packages/cli/openhands_cli/__init__.py
Normal file
8
packages/cli/openhands_cli/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""OpenHands package."""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
try:
|
||||
__version__ = version('openhands')
|
||||
except PackageNotFoundError:
|
||||
__version__ = '0.0.0'
|
||||
@@ -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
|
||||
):
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -1,4 +1,5 @@
|
||||
import html
|
||||
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands.sdk.security.confirmation_policy import (
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -89,7 +89,6 @@ omit = [ "tests/*", "**/test_*" ]
|
||||
[tool.coverage.paths]
|
||||
source = [
|
||||
"openhands_cli/",
|
||||
"openhands-cli/openhands_cli/",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
57
packages/cli/tests/settings/test_first_time_user_settings.py
Normal file
57
packages/cli/tests/settings/test_first_time_user_settings.py
Normal 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)
|
||||
@@ -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
Reference in New Issue
Block a user