Compare commits

..

96 Commits

Author SHA1 Message Date
Ray Myers 52af260b2f Merge branch 'main' into ray/docker-scan 2025-09-24 16:39:16 -05:00
mamoodi f8f74858da Update parts of the OpenHands Cloud docs (#11107) 2025-09-24 16:27:52 -04:00
Ray Myers 848a884b04 chore - Track Python test coverage (#11072) 2025-09-24 15:27:34 -05:00
Hiep Le 88a58a1748 refactor(frontend): migration of jupyter-slice.ts to zustand (#11019) 2025-09-25 00:56:55 +07:00
Hiep Le f59ea69b70 refactor(frontend): migration of microagent-management-slice.ts to zustand (#11033) 2025-09-24 23:58:17 +07:00
dependabot[bot] 8f004a1f6d chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.86.0 to 5.90.1 in /frontend in the eslint group (#11101)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 20:53:20 +04:00
sp.wack 15b4690ebf feat(frontend): Animate conversation panels (#11099) 2025-09-24 16:41:19 +00:00
Hiep Le df1c5bbf85 refactor(frontend): migration of event-message-slice.ts to zustand (#11080) 2025-09-24 22:53:45 +07:00
Hiep Le 8adbb76bd7 refactor(frontend): migration of browser-slice.ts to zustand (#11081) 2025-09-24 22:52:48 +07:00
Hiep Le 0095672439 feat(frontend): keyboard shortcuts for file copy and paste (#11096) 2025-09-24 22:52:03 +07:00
Hiep Le 6a5d09660d fix(frontend): upgrade banner covers conversation panel in settings (#11094) 2025-09-24 21:40:16 +07:00
Hiep Le a94906e15c refactor(frontend): migration of security-analyzer-slice.ts to zustand (#11082) 2025-09-24 21:32:25 +07:00
Hiep Le 12dc256b5a refactor(frontend): git actions should be enabled at all times (#11063) 2025-09-24 21:31:51 +07:00
Hiep Le 11edf33b97 refactor(frontend): remove file-state-slice.ts file (#11061) 2025-09-24 21:31:11 +07:00
Hiep Le fce66e94e7 refactor(frontend): event message (#11001) 2025-09-24 21:30:55 +07:00
Hiep Le 5457392eae refactor(frontend): migration of conversation-slice.ts to zustand (#11032) 2025-09-24 21:30:37 +07:00
Ray Myers 1e7024b60a fix - Set claude sonnet output limit (#11098) 2025-09-24 13:31:20 +00:00
Hiep Le 3977d4fdd7 fix(frontend): insufficient spacing between last message and chat input (#11055) 2025-09-24 20:13:21 +07:00
Tejas Goyal 16004426a2 feat: Add configurable timeouts for MCP tool invocations (Good first issues: #10684) (#11029)
Co-authored-by: Tejas Goyal <tejas@Tejass-MacBook-Pro.local>
2025-09-24 16:43:54 +04:00
dependabot[bot] 73eb53a379 chore(deps): bump the version-all group across 1 directory with 21 updates (#11078)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 16:24:27 +04:00
Ray Myers e47548bc98 Turn off image scan on PRs 2025-09-23 20:56:07 -05:00
Ray Myers dbd282117c Add runtime flavor to trivy tool name 2025-09-23 20:38:43 -05:00
Ray Myers edf955f1b4 Add security-events permissions 2025-09-23 20:19:56 -05:00
Ray Myers 236228f7e5 Fix ref again 2025-09-23 20:07:56 -05:00
Ray Myers 6f4437cffa Add ref 2025-09-23 20:02:19 -05:00
Ray Myers 5a98aba912 Set sha 2025-09-23 19:56:41 -05:00
Ray Myers 602fd6c19d Make scans faster, increase timeout, add app image scan 2025-09-23 19:30:05 -05:00
Ray Myers d92f5dfcf3 Lowercase repo owner in enterprise image 2025-09-23 18:44:40 -05:00
Ray Myers 8f2e1424a4 Enable DOCKER_METADATA_PR_HEAD_SHA 2025-09-23 18:36:57 -05:00
Ray Myers a57ccef287 fix yaml syntax 2025-09-23 18:26:22 -05:00
Ray Myers e206545db4 FIx enterprise docker tag sha 2025-09-23 18:20:39 -05:00
Ray Myers 244752d75b Tag with sha not ref env 2025-09-23 18:08:30 -05:00
Ray Myers caa2516875 Upload docker scan findings to separate categories 2025-09-23 17:25:08 -05:00
Ray Myers 5f4f9e17ab Fix scan image tag 2025-09-23 17:14:54 -05:00
Ray Myers ba888c48ef chore - Add docker CVE scan for runtime and enterprise images 2025-09-23 16:42:33 -05:00
BenYao21 d3d70fcc60 issue #9388, this will fix the issue (#10450)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-09-22 16:56:53 -04:00
Xinyi He 7906eab6b1 Add inference generation of SWE-Perf Benchmark (#10246)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 20:35:30 +00:00
juanmichelini 547e1049f1 Multi swe gym (#10605)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 15:56:26 -04:00
mamoodi 818cc60b52 New label for not going stale (#11069) 2025-09-22 11:53:47 -04:00
Robert Brennan 431d2c1f43 security: upgrade setuptools to >=78.1.1 to address CVE-2025-47273 and CVE-2024-6345 (#11038)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: enyst <engel.nyst@gmail.com>
2025-09-22 04:05:45 +00:00
Engel Nyst 07f23641a3 build(deps): pin litellm to avoid build failure (#11054)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 03:54:37 +02:00
Hiep Le de84af5586 feat(frontend): display lock icon when confirmation mode is enabled (#11030) 2025-09-20 10:55:19 +07:00
Hiep Le b7765ba3f7 refactor(frontend): fix typecheck (#11037) 2025-09-19 13:43:00 -04:00
Hiep Le b89f2e51e4 refactor(frontend): migration of metrics-slice.ts to zustand (#11018) 2025-09-19 23:52:21 +07:00
mamoodi e09f93aa75 Release 0.57.0 (#10981)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-09-19 12:40:56 -04:00
Hiep Le 9f529b105a refactor(frontend): migration of command-slice.ts to zustand (#11003) 2025-09-19 23:33:59 +07:00
Graham Neubig 89e3d2a867 Improve OpenHands provider pricing documentation (#10974)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-20 00:22:44 +08:00
Hiep Le a7b9a4f291 refactor(frontend): migration of status-slice.ts to zustand (#11017) 2025-09-19 22:27:55 +07:00
Hiep Le 88cd16ae21 refactor(frontend): migration of initial-query-slice.ts to zustand (#11020) 2025-09-19 22:27:20 +07:00
Hiep Le a8a3e9e604 refactor(frontend): remove the code-slice.ts file (#11021) 2025-09-19 21:22:29 +07:00
Hiep Le 0061bcc0b0 refactor(frontend): custom chat input (#10984) 2025-09-19 21:06:18 +07:00
Hiep Le 9c9fa780b0 refactor(frontend): task tracking observation content (#11002) 2025-09-19 20:03:05 +07:00
Alona 569ac16163 Improve token refresh error logging (#11026) 2025-09-19 14:18:38 +07:00
Robert Brennan 46f7738f41 Update Python packages to latest versions (#11023)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 19:52:46 +00:00
Rohit Malhotra 3f3669dd34 Hotfix: rm model choice override (#11022) 2025-09-18 14:40:06 -04:00
sp.wack cd65645eea Hide Tavily search API key help text in SaaS mode (#11014)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 16:40:29 +00:00
Robert Brennan 8e88a7a277 fix: resolve critical and high CVEs in enterprise Docker image (#10987)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 11:25:33 -04:00
Hiep Le b393d52439 refactor(frontend): conversation main (#10985) 2025-09-18 20:23:13 +07:00
Hiep Le faeec48365 refactor(frontend): conversation card (#10986) 2025-09-18 20:22:59 +07:00
sp.wack 774caf0607 feat: refactor status indicators (#10983)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 22:32:55 +04:00
sp.wack 7222730df0 Fix SaaS callback URLs and pro pill positioning (#10998)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 16:56:02 +00:00
Hiep Le 910177fc57 refactor(frontend): system message modal (#10969) 2025-09-17 21:56:14 +07:00
Hiep Le ac9badbd20 refactor(frontend): metrics modal (#10968) 2025-09-17 21:55:25 +07:00
Ray Myers 02c299d88f Fix Slack resolver failing on AWAITING_USER_INPUT state (#10992)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 09:20:12 -05:00
mamoodi f65fbef649 Remove runtime settings (#10996) 2025-09-17 13:59:29 +00:00
Hiep Le 3c2acad28d refactor(frontend): microagents modal (#10970) 2025-09-16 22:32:23 +07:00
Boxuan Li 0f1780728e Update str_replace_editor tool to use dynamic workspace path from SANDBOX_VOLUMES (#10965)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 17:46:54 -07:00
sp.wack d3f3378a4c feat: Upgrade banner for unsubscribed SaaS users (#10890)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 23:04:44 +00:00
Engel Nyst 65f4164749 [Docs] Add environment variables reference table (#10926)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 18:31:44 +00:00
Hiep Le 3f984d878b refactor(frontend): move conversation APIs to a dedicated service handler (#10957) 2025-09-16 00:57:15 +07:00
Eliot Jones 10b871f4ab feat: Add Cygnal integration (#10898) 2025-09-15 09:57:03 -04:00
Hiep Le d664f516db refactor(frontend): conversation tab content component (#10956) 2025-09-15 20:56:38 +07:00
Hiep Le e74bbd81d1 fix(frontend): suppressing event display in the absence of user messages (#10955) 2025-09-15 20:56:16 +07:00
Hiep Le ab893f93f0 refactor(frontend): use-auto-resize hook (#10959) 2025-09-15 20:49:15 +07:00
Hiep Le 5aba498e77 refactor(frontend): move billing APIs to a dedicated service handler (#10958) 2025-09-15 20:37:07 +07:00
Hiep Le 1523555eea refactor(frontend): remove dead code (#10839) 2025-09-15 20:35:56 +07:00
Kaushik Ashodiya 30604c40fc fix: improve CLI help and version command performance (#10908)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-12 14:23:01 -04:00
Hiep Le 8dc46b7206 refactor(frontend): optimize pre-commit lint script (#10870)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-09-12 15:23:29 +00:00
Hiep Le 69498bebb4 refactor(frontend): new conversation component (#10937)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-12 22:15:26 +07:00
tksrmz 77ee9e25d9 fix(frontend): highlight preceding stars on hover in LikertScale (#10948) 2025-09-12 18:01:40 +04:00
Hiep Le 74753036bb refactor(frontend): move user APIs to a dedicated service handler (#10943) 2025-09-12 09:08:15 +07:00
Hiep Le 95d7c10608 refactor(frontend): move option APIs to a dedicated service handler (#10933) 2025-09-12 00:43:15 +07:00
Hiep Le c142cc27ff refactor(frontend): home header component (#10930) 2025-09-12 00:10:58 +07:00
Hiep Le 0e20fc206b refactor(frontend): move settings APIs to a dedicated service handler (#10941) 2025-09-11 23:39:23 +07:00
Hiep Le e21475a88e feat(frontend): persist drawer open/close state on page refresh (#10935)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-11 15:58:00 +00:00
Hiep Le 921fec0019 refactor(frontend): expand repository pill to full available width (#10936) 2025-09-11 22:37:44 +07:00
Hiep Le 049f839a62 refactor(frontend): move auth APIs to a dedicated service handler (#10932) 2025-09-11 22:31:41 +07:00
Hiep Le 0dde758e13 refactor(frontend): move microagent management API to a dedicated service handler (#10934) 2025-09-11 22:27:56 +07:00
Tim O'Farrell 8257ae70cc Additional logs to debug container working directories (#10902)
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
2025-09-11 11:06:19 -04:00
Ray Myers 4513bcc622 chore - MyPy check Enterprise with OpenHands (#10858)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-09-11 11:05:50 -04:00
Hiep Le b5b9a3f40b refactor(frontend): create waiting for runtime component (#10931) 2025-09-11 21:30:05 +07:00
Xingyao Wang 8ea1259943 Add GitHub workflow for MDX format checking and fix parsing error (#10924)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 23:04:54 +00:00
Ray Myers ddb2794adf fix - Tag enterprise with the same SHA as app image. (#10921) 2025-09-10 16:47:31 -05:00
sp.wack 79fdcad7ef Fix status indicator and chat input synchronization issue (#10914)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 20:39:14 +00:00
chuckbutkus 1de70b8ce4 Fix runtime init (#10909) 2025-09-10 19:28:12 +00:00
sp.wack 3baeecb27c meta(frontend): Improve UX (#9845)
Co-authored-by: Mislav Lukach <mislavlukach@gmail.com>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 18:12:52 +00:00
71 changed files with 403 additions and 9072 deletions
-58
View File
@@ -1,58 +0,0 @@
# Workflow that builds and tests the CLI binary executable
name: CLI - Build and Test Binary
# Run on pushes to main branch and all pull requests, but only when CLI files change
on:
push:
branches:
- main
paths:
- "openhands-cli/**"
pull_request:
paths:
- "openhands-cli/**"
# Cancel previous runs if a new commit is pushed
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
build-and-test-binary:
name: Build and test binary executable
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
- name: Install dependencies
working-directory: openhands-cli
run: |
uv sync
- name: Build binary executable
working-directory: openhands-cli
run: |
./build.sh --install-pyinstaller | tee output.log
echo "Full output:"
cat output.log
if grep -q "❌" output.log; then
echo "❌ Found failure marker in output"
exit 1
fi
echo "✅ Build & test finished without ❌ markers"
+77 -6
View File
@@ -59,6 +59,7 @@ jobs:
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -85,14 +86,35 @@ jobs:
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request'
uses: aquasecurity/trivy-action@master
# This only reports, does not fail the build on CVE.
with:
image-ref: ghcr.io/${{ env.REPO_OWNER }}/openhands:${{ env.RELEVANT_SHA }}
format: 'sarif'
output: 'trivy-results.sarif'
timeout: '10m'
scanners: 'vuln' # Only scan vulnerabilities, not secrets/config
severity: 'CRITICAL,HIGH,MEDIUM' # Skip LOW severity
- name: Upload Trivy results to GitHub Security tab
if: github.event_name != 'pull_request'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
category: 'security/container-openhands-${{ matrix.base_image.tag }}'
sha: "${{ env.RELEVANT_SHA }}"
ref: refs/heads/${{ github.event.pull_request.head.ref || github.ref_name }}
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Image
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
security-events: write
needs: define-matrix
strategy:
matrix:
@@ -166,12 +188,41 @@ jobs:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request'
uses: aquasecurity/trivy-action@master
# This only reports, does not fail the build on CVE.
with:
image-ref: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
format: 'sarif'
output: 'trivy-results-raw.sarif'
timeout: '10m'
scanners: 'vuln' # Only scan vulnerabilities, not secrets/config
severity: 'CRITICAL,HIGH,MEDIUM' # Skip LOW severity
- name: Customize SARIF with image flavor
if: github.event_name != 'pull_request'
run: |
# Modify the tool name to include the image flavor
jq --arg flavor "${{ matrix.base_image.tag }}" \
'.runs[0].tool.driver.name = "Trivy (" + $flavor + ")"' \
trivy-results-raw.sarif > trivy-results.sarif
echo "Modified tool name to: $(jq -r '.runs[0].tool.driver.name' trivy-results.sarif)"
- name: Upload Trivy results to GitHub Security tab
if: github.event_name != 'pull_request'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
category: 'security/container-runtime-${{ matrix.base_image.tag }}'
ghcr_build_enterprise:
name: Push Enterprise Image
name: Build Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
permissions:
contents: read
packages: write
security-events: write
needs: [define-matrix, ghcr_build_app]
# Do not build enterprise in forks
if: github.event.pull_request.head.repo.fork != true
@@ -211,14 +262,16 @@ jobs:
latest=auto
prefix=
suffix=
env:
DOCKER_METADATA_PR_HEAD_SHA: true
- name: Determine app image tag
shell: bash
run: |
# Duplicated with build.sh
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "${{github.event.pull_request.head.sha}}" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
echo "OPENHANDS_BUILD_VERSION=${sanitized_ref_name}" >> $GITHUB_ENV
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
with:
@@ -228,12 +281,30 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
OPENHANDS_VERSION=${{ env.OPENHANDS_BUILD_VERSION }}
platforms: linux/amd64
# Add build provenance
provenance: true
# Add build attestations for better security
sbom: true
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
if: github.event_name != 'pull_request'
# This only reports, does not fail the build on CVE.
with:
image-ref: "ghcr.io/${{ env.REPO_OWNER }}/enterprise-server:sha-${{ env.RELEVANT_SHA }}"
format: 'sarif'
output: 'trivy-results.sarif'
timeout: '10m'
scanners: 'vuln' # Only scan vulnerabilities, not secrets/config
severity: 'CRITICAL,HIGH,MEDIUM' # Skip LOW severity
- name: Upload Trivy results to GitHub Security tab
if: github.event_name != 'pull_request'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
category: 'security/container-enterprise-server'
enterprise-preview:
name: Enterprise preview
+1 -19
View File
@@ -37,7 +37,7 @@ jobs:
npm run make-i18n && tsc
npm run check-translation-completeness
# Run lint on the python code (excluding CLI and enterprise)
# Run lint on the python code
lint-python:
name: Lint python
runs-on: blacksmith-4vcpu-ubuntu-2204
@@ -73,24 +73,6 @@ jobs:
working-directory: ./enterprise
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
lint-cli-python:
name: Lint CLI python
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
- name: Install pre-commit
run: pip install pre-commit==3.7.0
- name: Run pre-commit hooks
working-directory: ./openhands-cli
run: pre-commit run --all-files --config ../dev_config/python/.pre-commit-config.yaml
# Check version consistency across documentation
check-version-consistency:
name: Check version consistency
+49 -32
View File
@@ -19,12 +19,16 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
matrix:
python-version: ["3.12"]
permissions:
# For coverage comment and python-coverage-comment-action branch
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
@@ -48,10 +52,21 @@ jobs:
- name: Build Environment
run: make build
- name: Run Unit Tests
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv ./tests/unit
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v4
with:
name: coverage-openhands
path: |
.coverage.${{ matrix.python_version }}
.coverage.runtime.${{ matrix.python_version }}
include-hidden-files: true
# Run specific Windows python tests
test-on-windows:
name: Python Tests on Windows
@@ -85,7 +100,7 @@ jobs:
DEBUG: "1"
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
matrix:
python-version: ["3.12"]
@@ -102,35 +117,37 @@ jobs:
working-directory: ./enterprise
run: poetry install --with dev,test
- name: Run Unit Tests
working-directory: ./enterprise
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./tests/unit
# Use base working directory for coverage paths to line up.
run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v4
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"
include-hidden-files: true
coverage-comment:
name: Coverage Comment
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: [test-on-linux, test-enterprise]
# Run CLI unit tests
test-cli-python:
name: CLI Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
pull-requests: write
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/checkout@v4
- uses: actions/download-artifact@v5
id: download
with:
fetch-depth: 0
pattern: coverage-*
merge-multiple: true
- name: Set up Python
uses: useblacksmith/setup-python@v6
- name: Coverage comment
id: coverage_comment
uses: py-cov-action/python-coverage-comment-action@v3
with:
python-version: 3.12
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
- name: Install dependencies
working-directory: ./openhands-cli
run: |
uv sync --group dev
- name: Run CLI unit tests
working-directory: ./openhands-cli
run: |
uv run pytest -v
GITHUB_TOKEN: ${{ github.token }}
MERGE_COVERAGE_FILES: true
+1 -2
View File
@@ -31,8 +31,7 @@ requirements.txt
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
# Note: openhands-cli.spec is intentionally tracked for CLI builds
# *.spec
*.spec
# Installer logs
pip-log.txt
+4 -4
View File
@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: check-yaml
args: ["--allow-multiple-documents"]
- id: debug-statements
@@ -28,12 +28,12 @@ repos:
entry: ruff check --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix, --unsafe-fixes]
exclude: ^(third_party/|enterprise/|openhands-cli/)
exclude: ^(third_party/|enterprise/)
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
exclude: ^(third_party/|enterprise/|openhands-cli/)
exclude: ^(third_party/|enterprise/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 212 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

+13 -12
View File
@@ -8,9 +8,21 @@ description: This guide walks you through the process of installing OpenHands Cl
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `Open Repository` section to select the appropriate repository and
branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
## IP Whitelisting
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow OpenHands to access your repositories:
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow
OpenHands to access your repositories:
### Core App IP
```
@@ -31,17 +43,6 @@ If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist t
34.60.55.59
```
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
+4 -7
View File
@@ -12,13 +12,10 @@ For the available API endpoints, refer to the
To use the OpenHands Cloud API, you'll need to generate an API key:
1. Log in to your [OpenHands Cloud](https://app.all-hands.dev) account.
2. Navigate to the [Settings page](https://app.all-hands.dev/settings).
3. Select the `API Keys` tab.
4. Click `Create API Key`.
5. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
6. Copy the generated API key and store it securely. It will only be shown once.
![API Key Generation](/static/img/api-key-generation.png)
2. Navigate to the [Settings > API Keys](https://app.all-hands.dev/settings/api-keys) page.
3. Click `Create API Key`.
4. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
5. Copy the generated API key and store it securely. It will only be shown once.
## API Usage
+25 -10
View File
@@ -8,24 +8,39 @@ description: The Cloud UI provides a web interface for interacting with OpenHand
The landing page is where you can:
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud),
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) or
[a Bitbucket repo](/usage/cloud/bitbucket-installation#working-with-bitbucket-repos-in-openhands-cloud) to start working on.
- Launch an empty conversation using `New Conversation`.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.
- See your `Recent Conversations`.
## Settings
The Settings page allows you to:
Settings are divided across tabs, with each tab focusing on a specific area of configuration.
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- [Install the OpenHands Slack app](/usage/cloud/slack-installation).
- Set application settings like your preferred language, notifications and other preferences.
- Add credits to your account.
- [Generate custom secrets](/usage/common-settings#secrets-management).
- [Create API keys to work with OpenHands programmatically](/usage/cloud/cloud-api).
- Change your email address.
- `User`
- Change your email address.
- `Integrations`
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- [Install the OpenHands Slack app](/usage/cloud/slack-installation).
- `Application`
- Set your preferred language, notifications and other preferences.
- Toggle task suggestions on GitHub.
- Toggle Solvability Analysis.
- Set a maximum budget per conversation.
- Configure the username and email that OpenHands uses for commits.
- `LLM` (Available for `Pro Users`)
- Choose to use another LLM or use different models from the OpenHands provider.
- `Billing`
- Add credits for using the OpenHands provider.
- Cancel your `Pro` subscription.
- `Secrets`
- [Generate custom secrets](/usage/common-settings#secrets-management).
- `API Keys`
- [Create API keys to work with OpenHands programmatically](/usage/cloud/cloud-api).
- `MCP`
- [Setup an MCP server](/usage/mcp)
## Key Features
+16 -9
View File
@@ -12,7 +12,7 @@ description: This guide walks you through the process of installing OpenHands Cl
You can grant OpenHands access to specific GitHub repositories:
1. Click on `Add GitHub repos` on the landing page.
1. Click on `+ Add GitHub Repos` in the repository selection dropdown.
2. Select your organization and choose the specific repositories to grant OpenHands access to.
<Accordion title="OpenHands permissions">
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
@@ -34,20 +34,22 @@ You can grant OpenHands access to specific GitHub repositories:
## Modifying Repository Access
You can modify GitHub repository access at any time by:
- Selecting `Add GitHub repos` on the landing page or
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Integrations` tab
- Selecting `+ Add GitHub Repos` in the repository selection dropdown or
- Visiting the `Settings > Integrations` page and selecting `Configure GitHub Repositories`
## Working With GitHub Repos in Openhands Cloud
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the `select a repo`
and `select a branch` dropdowns to select the appropriate repository and branch you'd like OpenHands to work on. Then
click on `Launch` to start the conversation!
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the
`Open Repository` section to select the appropriate repository and branch you'd like OpenHands to work on. Then click
on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
## Working on Github Issues and Pull Requests Using Openhands
## Working on GitHub Issues and Pull Requests Using Openhands
Giving GitHub repository access to OpenHands also allows you to work on GitHub issues and pull requests directly.
To allow OpenHands to work directly from GitHub directly, you must
[give OpenHands access to your repository](/usage/cloud/github-installation#modifying-repository-access). Once access is
given, you can use OpenHands by labeling the issue or by tagging `@openhands`.
### Working with Issues
@@ -64,7 +66,12 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
- Request updates
- Get code explanations
**Important Note**: The `@openhands` mention functionality in pull requests only works if the pull request is both *to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate permissions to access both repositories.
<Note>
The `@openhands` mention functionality in pull requests only works if the pull request is both
*to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate
permissions to access both repositories.
</Note>
## Next Steps
+8 -6
View File
@@ -14,16 +14,17 @@ Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have acc
## Working With GitLab Repos in Openhands Cloud
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
After signing in with a Gitlab account, use the `Open Repository` section to select the appropriate repository and
branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
![Connect Repo](/static/img/connect-repo.png)
## Using Tokens with Reduced Scopes
OpenHands requests an API-scoped token during OAuth authentication. By default, this token is provided to the agent.
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default
token assigned to the agent. While the high-permission API token is still requested and used for other components of
the application (e.g. opening merge requests), the agent will not have access to it.
## Working on GitLab Issues and Merge Requests Using Openhands
@@ -32,7 +33,8 @@ This feature works for personal projects and is available for group projects wit
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but
we are planning to improve this in a future release.
</Note>
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.
+3 -1
View File
@@ -13,7 +13,9 @@ description: This guide walks you through installing the OpenHands Slack app.
</iframe>
<Info>
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete. While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to validate critical information independently.
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete.
While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to
validate critical information independently.
</Info>
## Prerequisites
+117 -23
View File
@@ -766,7 +766,7 @@ version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
@@ -836,6 +836,7 @@ files = [
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
markers = {test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
[package.dependencies]
pycparser = "*"
@@ -1901,25 +1902,25 @@ files = [
[[package]]
name = "fastapi"
version = "0.116.1"
version = "0.117.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"},
{file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"},
{file = "fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552"},
{file = "fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.48.0"
starlette = ">=0.40.0,<0.49.0"
typing-extensions = ">=4.8.0"
[package.extras]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
[[package]]
name = "fastjsonschema"
@@ -2291,6 +2292,72 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto
test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""]
tqdm = ["tqdm"]
[[package]]
name = "gevent"
version = "25.9.1"
description = "Coroutine-based network library"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "gevent-25.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:856b990be5590e44c3a3dc6c8d48a40eaccbb42e99d2b791d11d1e7711a4297e"},
{file = "gevent-25.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fe1599d0b30e6093eb3213551751b24feeb43db79f07e89d98dd2f3330c9063e"},
{file = "gevent-25.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:f0d8b64057b4bf1529b9ef9bd2259495747fba93d1f836c77bfeaacfec373fd0"},
{file = "gevent-25.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56cbc820e3136ba52cd690bdf77e47a4c239964d5f80dc657c1068e0fe9521c"},
{file = "gevent-25.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5fa9ce5122c085983e33e0dc058f81f5264cebe746de5c401654ab96dddfca8"},
{file = "gevent-25.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:03c74fec58eda4b4edc043311fca8ba4f8744ad1632eb0a41d5ec25413581975"},
{file = "gevent-25.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8ae9f895e8651d10b0a8328a61c9c53da11ea51b666388aa99b0ce90f9fdc27"},
{file = "gevent-25.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5aff9e8342dc954adb9c9c524db56c2f3557999463445ba3d9cbe3dada7b7"},
{file = "gevent-25.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1cdf6db28f050ee103441caa8b0448ace545364f775059d5e2de089da975c457"},
{file = "gevent-25.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:812debe235a8295be3b2a63b136c2474241fa5c58af55e6a0f8cfc29d4936235"},
{file = "gevent-25.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b28b61ff9216a3d73fe8f35669eefcafa957f143ac534faf77e8a19eb9e6883a"},
{file = "gevent-25.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e4b6278b37373306fc6b1e5f0f1cf56339a1377f67c35972775143d8d7776ff"},
{file = "gevent-25.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d99f0cb2ce43c2e8305bf75bee61a8bde06619d21b9d0316ea190fc7a0620a56"},
{file = "gevent-25.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:72152517ecf548e2f838c61b4be76637d99279dbaa7e01b3924df040aa996586"},
{file = "gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86"},
{file = "gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692"},
{file = "gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2"},
{file = "gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74"},
{file = "gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51"},
{file = "gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5"},
{file = "gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f"},
{file = "gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3"},
{file = "gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed"},
{file = "gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245"},
{file = "gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82"},
{file = "gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48"},
{file = "gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7"},
{file = "gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47"},
{file = "gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117"},
{file = "gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa"},
{file = "gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1"},
{file = "gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356"},
{file = "gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8"},
{file = "gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e"},
{file = "gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c"},
{file = "gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f"},
{file = "gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6"},
{file = "gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7"},
{file = "gevent-25.9.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f18f80aef6b1f6907219affe15b36677904f7cfeed1f6a6bc198616e507ae2d7"},
{file = "gevent-25.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b274a53e818124a281540ebb4e7a2c524778f745b7a99b01bdecf0ca3ac0ddb0"},
{file = "gevent-25.9.1-cp39-cp39-win32.whl", hash = "sha256:c6c91f7e33c7f01237755884316110ee7ea076f5bdb9aa0982b6dc63243c0a38"},
{file = "gevent-25.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:012a44b0121f3d7c800740ff80351c897e85e76a7e4764690f35c5ad9ec17de5"},
{file = "gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd"},
]
[package.dependencies]
cffi = {version = ">=1.17.1", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
greenlet = {version = ">=3.2.2", markers = "platform_python_implementation == \"CPython\""}
"zope.event" = "*"
"zope.interface" = "*"
[package.extras]
dnspython = ["dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\""]
docs = ["furo", "repoze.sphinx.autointerface", "sphinx", "sphinxcontrib-programoutput", "zope.schema"]
monitor = ["psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""]
recommended = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""]
test = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "coverage (>=5.0) ; sys_platform != \"win32\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "objgraph", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\"", "requests"]
[[package]]
name = "gitdb"
version = "4.0.12"
@@ -2707,7 +2774,7 @@ version = "3.2.4"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"},
{file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"},
@@ -2764,6 +2831,7 @@ files = [
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
]
markers = {test = "platform_python_implementation == \"CPython\""}
[package.extras]
docs = ["Sphinx", "furo"]
@@ -5363,7 +5431,7 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-ai"
version = "0.55.0"
version = "0.57.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5397,7 +5465,7 @@ json-repair = "*"
jupyter_kernel_gateway = "*"
kubernetes = "^33.1.0"
libtmux = ">=0.37,<0.40"
litellm = "^1.74.3, !=1.64.4, !=1.67.*"
litellm = ">=1.74.3, <1.77.2, !=1.64.4, !=1.67.*"
memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
@@ -5406,6 +5474,7 @@ opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
pexpect = "*"
pillow = "^11.3.0"
poetry = "^2.1.2"
prompt-toolkit = "^3.0.50"
protobuf = "^5.0.0,<6.0.0"
@@ -5413,6 +5482,7 @@ psutil = "*"
pygithub = "^2.5.0"
pyjwt = "^2.9.0"
pylatexenc = "*"
pypdf = "^6.0.0"
PyPDF2 = "*"
python-docx = "*"
python-dotenv = "*"
@@ -5426,13 +5496,17 @@ pyyaml = "^6.0.2"
qtconsole = "^5.6.1"
rapidfuzz = "^3.9.0"
redis = ">=5.2,<7.0"
requests = "^2.32.5"
setuptools = ">=78.1.1"
shellingham = "^1.5.4"
sse-starlette = "^2.1.3"
sse-starlette = "^3.0.2"
starlette = "^0.48.0"
tenacity = ">=8.5,<10.0"
termcolor = "*"
toml = "*"
tornado = "*"
types-toml = "*"
urllib3 = "^2.5.0"
uvicorn = "*"
whatthepatch = "^1.0.6"
zope-interface = "7.2"
@@ -6471,11 +6545,12 @@ version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
markers = {test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
[[package]]
name = "pydantic"
@@ -8265,7 +8340,7 @@ version = "80.9.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
@@ -8631,14 +8706,14 @@ sqlcipher = ["sqlcipher3_binary"]
[[package]]
name = "sse-starlette"
version = "2.4.1"
version = "3.0.2"
description = "SSE plugin for Starlette"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a"},
{file = "sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926"},
{file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"},
{file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"},
]
[package.dependencies]
@@ -8646,7 +8721,7 @@ anyio = ">=4.7.0"
[package.extras]
daphne = ["daphne (>=4.2.0)"]
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio,examples] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"]
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"]
granian = ["granian (>=2.3.1)"]
uvicorn = ["uvicorn (>=0.34.0)"]
@@ -8702,14 +8777,14 @@ files = [
[[package]]
name = "starlette"
version = "0.47.3"
version = "0.48.0"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51"},
{file = "starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9"},
{file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"},
{file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"},
]
[package.dependencies]
@@ -9838,13 +9913,32 @@ enabler = ["pytest-enabler (>=2.2)"]
test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
type = ["pytest-mypy"]
[[package]]
name = "zope-event"
version = "6.0"
description = "Very basic event publishing system"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "zope_event-6.0-py3-none-any.whl", hash = "sha256:6f0922593407cc673e7d8766b492c519f91bdc99f3080fe43dcec0a800d682a3"},
{file = "zope_event-6.0.tar.gz", hash = "sha256:0ebac894fa7c5f8b7a89141c272133d8c1de6ddc75ea4b1f327f00d1f890df92"},
]
[package.dependencies]
setuptools = ">=75.8.2"
[package.extras]
docs = ["Sphinx"]
test = ["zope.testrunner (>=6.4)"]
[[package]]
name = "zope-interface"
version = "7.2"
description = "Interfaces for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2"},
{file = "zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a"},
@@ -10008,4 +10102,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "5771671ef2acc36e7b0931c73fa035ca1d329e8dac6827f7a349e1a569c3fd23"
content-hash = "8c460070dce6bdec5ee0ee7bc0c2246fcf2602d1e64a0867b4f5e3a0e334fe93"
+5
View File
@@ -63,6 +63,7 @@ openai = "*"
opencv-python = "*"
pandas = "*"
reportlab = "*"
gevent = ">=24.2.1,<26.0.0"
[tool.poetry-dynamic-versioning]
enable = true
@@ -85,3 +86,7 @@ lint.pydocstyle.convention = "google"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[tool.coverage.run]
relative_files = true
omit = [ "tests/*" ]
@@ -2,8 +2,8 @@ import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
import { jupyterReducer } from "#/state/jupyter-slice";
import { vi, describe, it, expect } from "vitest";
import { useJupyterStore } from "#/state/jupyter-store";
import { vi, describe, it, expect, beforeEach } from "vitest";
describe("JupyterEditor", () => {
const mockStore = configureStore({
@@ -15,19 +15,20 @@ describe("JupyterEditor", () => {
code: () => ({}),
cmd: () => ({}),
agent: () => ({}),
jupyter: jupyterReducer,
securityAnalyzer: () => ({}),
status: () => ({}),
},
preloadedState: {
jupyter: {
cells: Array(20).fill({
content: "Test cell content",
type: "input",
output: "Test output",
}),
},
},
});
beforeEach(() => {
// Reset the Zustand store before each test
useJupyterStore.setState({
cells: Array(20).fill({
content: "Test cell content",
type: "input",
imageUrls: undefined,
}),
});
});
it("should have a scrollable container", () => {
@@ -36,7 +37,7 @@ describe("JupyterEditor", () => {
<div style={{ height: "100vh" }}>
<JupyterEditor maxWidth={800} />
</div>
</Provider>
</Provider>,
);
const container = screen.getByTestId("jupyter-container");
+8 -4
View File
@@ -21,8 +21,12 @@ vi.mock("#/state/command-store", () => ({
},
}));
vi.mock("#/state/jupyter-slice", () => ({
appendJupyterInput: mockAppendJupyterInput,
vi.mock("#/state/jupyter-store", () => ({
useJupyterStore: {
getState: () => ({
appendJupyterInput: mockAppendJupyterInput,
}),
},
}));
vi.mock("#/state/metrics-slice", () => ({
@@ -81,8 +85,8 @@ describe("handleActionMessage", () => {
handleActionMessage(ipythonAction);
// Check that appendJupyterInput was called with the code
expect(mockDispatch).toHaveBeenCalledWith(
mockAppendJupyterInput("print('Hello from Jupyter!')"),
expect(mockAppendJupyterInput).toHaveBeenCalledWith(
"print('Hello from Jupyter!')",
);
expect(mockAppendInput).not.toHaveBeenCalled();
});
@@ -1,5 +1,5 @@
import React from "react";
import { Cell } from "#/state/jupyter-slice";
import { Cell } from "#/state/jupyter-store";
import { JupyterLine, parseCellContent } from "#/utils/parse-cell-content";
import { JupytrerCellInput } from "./jupyter-cell-input";
import { JupyterCellOutput } from "./jupyter-cell-output";
@@ -9,13 +9,14 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
import { useJupyterStore } from "#/state/jupyter-store";
interface JupyterEditorProps {
maxWidth: number;
}
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
const cells = useSelector((state: RootState) => state.jupyter?.cells ?? []);
const cells = useJupyterStore((state) => state.cells);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const jupyterRef = React.useRef<HTMLDivElement>(null);
+4 -3
View File
@@ -6,7 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useCommandStore } from "#/state/command-store";
import { useEffectOnce } from "#/hooks/use-effect-once";
import { clearJupyter } from "#/state/jupyter-slice";
import { useJupyterStore } from "#/state/jupyter-store";
import { useConversationStore } from "#/state/conversation-store";
import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
@@ -42,6 +42,7 @@ function AppContent() {
const dispatch = useDispatch();
const navigate = useNavigate();
const clearTerminal = useCommandStore((state) => state.clearTerminal);
const clearJupyter = useJupyterStore((state) => state.clearJupyter);
const queryClient = useQueryClient();
// Fetch batch feedback data when conversation is loaded
@@ -86,14 +87,14 @@ function AppContent() {
React.useEffect(() => {
clearTerminal();
dispatch(clearJupyter());
clearJupyter();
resetConversationState();
dispatch(setCurrentAgentState(AgentState.LOADING));
}, [conversationId, clearTerminal, resetConversationState]);
useEffectOnce(() => {
clearTerminal();
dispatch(clearJupyter());
clearJupyter();
resetConversationState();
dispatch(setCurrentAgentState(AgentState.LOADING));
});
+2 -3
View File
@@ -1,7 +1,6 @@
import { trackError } from "#/utils/error-handler";
import useMetricsStore from "#/stores/metrics-store";
import { useStatusStore } from "#/state/status-store";
import store from "#/store";
import ActionType from "#/types/action-type";
import {
ActionMessage,
@@ -9,8 +8,8 @@ import {
StatusMessage,
} from "#/types/message";
import { handleObservationMessage } from "./observations";
import { useJupyterStore } from "#/state/jupyter-store";
import { useCommandStore } from "#/state/command-store";
import { appendJupyterInput } from "#/state/jupyter-slice";
import { queryClient } from "#/query-client-config";
import {
ActionSecurityRisk,
@@ -37,7 +36,7 @@ export function handleActionMessage(message: ActionMessage) {
}
if (message.action === ActionType.RUN_IPYTHON) {
store.dispatch(appendJupyterInput(message.args.code));
useJupyterStore.getState().appendJupyterInput(message.args.code);
}
if ("args" in message && "security_risk" in message.args) {
+7 -9
View File
@@ -1,8 +1,8 @@
import { setCurrentAgentState } from "#/state/agent-slice";
import store from "#/store";
import { ObservationMessage } from "#/types/message";
import { useJupyterStore } from "#/state/jupyter-store";
import { useCommandStore } from "#/state/command-store";
import { appendJupyterOutput } from "#/state/jupyter-slice";
import ObservationType from "#/types/observation-type";
import { useBrowserStore } from "#/stores/browser-store";
@@ -23,14 +23,12 @@ export function handleObservationMessage(message: ObservationMessage) {
break;
}
case ObservationType.RUN_IPYTHON:
store.dispatch(
appendJupyterOutput({
content: message.content,
imageUrls: Array.isArray(message.extras?.image_urls)
? message.extras.image_urls
: undefined,
}),
);
useJupyterStore.getState().appendJupyterOutput({
content: message.content,
imageUrls: Array.isArray(message.extras?.image_urls)
? message.extras.image_urls
: undefined,
});
break;
case ObservationType.BROWSE:
case ObservationType.BROWSE_INTERACTIVE:
-37
View File
@@ -1,37 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
export type Cell = {
content: string;
type: "input" | "output";
imageUrls?: string[];
};
const initialCells: Cell[] = [];
export const jupyterSlice = createSlice({
name: "jupyter",
initialState: {
cells: initialCells,
},
reducers: {
appendJupyterInput: (state, action) => {
state.cells.push({ content: action.payload, type: "input" });
},
appendJupyterOutput: (state, action) => {
state.cells.push({
content: action.payload.content,
type: "output",
imageUrls: action.payload.imageUrls,
});
},
clearJupyter: (state) => {
state.cells = [];
},
},
});
export const { appendJupyterInput, appendJupyterOutput, clearJupyter } =
jupyterSlice.actions;
export const jupyterReducer = jupyterSlice.reducer;
export default jupyterReducer;
+40
View File
@@ -0,0 +1,40 @@
import { create } from "zustand";
export type Cell = {
content: string;
type: "input" | "output";
imageUrls?: string[];
};
interface JupyterState {
cells: Cell[];
appendJupyterInput: (content: string) => void;
appendJupyterOutput: (payload: {
content: string;
imageUrls?: string[];
}) => void;
clearJupyter: () => void;
}
export const useJupyterStore = create<JupyterState>((set) => ({
cells: [],
appendJupyterInput: (content: string) =>
set((state) => ({
cells: [...state.cells, { content, type: "input" }],
})),
appendJupyterOutput: (payload: { content: string; imageUrls?: string[] }) =>
set((state) => ({
cells: [
...state.cells,
{
content: payload.content,
type: "output",
imageUrls: payload.imageUrls,
},
],
})),
clearJupyter: () =>
set(() => ({
cells: [],
})),
}));
-2
View File
@@ -1,10 +1,8 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import agentReducer from "./state/agent-slice";
import { jupyterReducer } from "./state/jupyter-slice";
export const rootReducer = combineReducers({
agent: agentReducer,
jupyter: jupyterReducer,
});
const store = configureStore({
-52
View File
@@ -1,52 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
.env
.venv
env/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
.coverage.*
coverage.xml
*.cover
.hypothesis/
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
# Note: We keep our custom spec file in version control
# *.spec
-46
View File
@@ -1,46 +0,0 @@
.PHONY: help install install-dev test format clean run
# Default target
help:
@echo "OpenHands CLI - Available commands:"
@echo " install - Install the package"
@echo " install-dev - Install with development dependencies"
@echo " test - Run tests"
@echo " format - Format code with ruff"
@echo " clean - Clean build artifacts"
@echo " run - Run the CLI"
# Install the package
install:
uv sync
# Install with development dependencies
install-dev:
uv sync --group dev
# Run tests
test:
uv run pytest
# Format code
format:
uv run ruff format openhands_cli/
# Clean build artifacts
clean:
rm -rf .venv/
find . -type d -name "__pycache__" -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
# Run the CLI
run:
uv run openhands-cli
# Install UV if not present
install-uv:
@if ! command -v uv &> /dev/null; then \
echo "Installing UV..."; \
curl -LsSf https://astral.sh/uv/install.sh | sh; \
else \
echo "UV is already installed"; \
fi
-45
View File
@@ -1,45 +0,0 @@
# OpenHands CLI
A lightweight CLI/TUI to interact with the OpenHands agent (powered by agent-sdk). Build and run locally or as a single executable.
## Quickstart
- Prerequisites: Python 3.12+, curl
- Install uv (package manager):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
# Restart your shell so "uv" is on PATH, or follow the installer hint
```
### Run the CLI locally
```bash
# Install dependencies (incl. dev tools)
make install-dev
# Optional: install pre-commit hooks
make install-pre-commit-hooks
# Start the CLI
make run
# or
uv run openhands-cli
```
Tip: Set your model key (one of) so the agent can talk to an LLM:
```bash
export OPENAI_API_KEY=...
# or
export LITELLM_API_KEY=...
```
### Build a standalone executable
```bash
# Build (installs PyInstaller if needed)
./build.sh --install-pyinstaller
# The binary will be in dist/
./dist/openhands-cli # macOS/Linux
# dist/openhands-cli.exe # Windows
```
For advanced development (adding deps, updating the spec file, debugging builds), see Development.md.
-281
View File
@@ -1,281 +0,0 @@
#!/usr/bin/env python3
"""
Build script for OpenHands CLI using PyInstaller.
This script packages the OpenHands CLI into a standalone executable binary
using PyInstaller with the custom spec file.
"""
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from openhands_cli.locations import PERSISTENCE_DIR, WORK_DIR, AGENT_SETTINGS_PATH
from openhands.sdk.preset.default import get_default_agent
from openhands.sdk import LLM
import time
import select
dummy_agent = get_default_agent(
llm=LLM(model='dummy-model', api_key='dummy-key'),
working_dir=WORK_DIR,
persistence_dir=PERSISTENCE_DIR,
cli_mode=True
)
# =================================================
# SECTION: Build Binary
# =================================================
def clean_build_directories() -> None:
"""Clean up previous build artifacts."""
print('🧹 Cleaning up previous build artifacts...')
build_dirs = ['build', 'dist', '__pycache__']
for dir_name in build_dirs:
if os.path.exists(dir_name):
print(f' Removing {dir_name}/')
shutil.rmtree(dir_name)
# Clean up .pyc files
for root, _dirs, files in os.walk('.'):
for file in files:
if file.endswith('.pyc'):
os.remove(os.path.join(root, file))
print('✅ Cleanup complete!')
def check_pyinstaller() -> bool:
"""Check if PyInstaller is available."""
try:
subprocess.run(
['uv', 'run', 'pyinstaller', '--version'], check=True, capture_output=True
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
print(
'❌ PyInstaller is not available. Use --install-pyinstaller flag or install manually with:'
)
print(' uv add --dev pyinstaller')
return False
def build_executable(
spec_file: str = 'openhands-cli.spec',
clean: bool = True,
) -> bool:
"""Build the executable using PyInstaller."""
if clean:
clean_build_directories()
# Check if PyInstaller is available (installation is handled by build.sh)
if not check_pyinstaller():
return False
print(f'🔨 Building executable using {spec_file}...')
try:
# Run PyInstaller with uv
cmd = ['uv', 'run', 'pyinstaller', spec_file, '--clean']
print(f'Running: {" ".join(cmd)}')
subprocess.run(cmd, check=True, capture_output=True, text=True)
print('✅ Build completed successfully!')
# Check if the executable was created
dist_dir = Path('dist')
if dist_dir.exists():
executables = list(dist_dir.glob('*'))
if executables:
print('📁 Executable(s) created in dist/:')
for exe in executables:
size = exe.stat().st_size / (1024 * 1024) # Size in MB
print(f' - {exe.name} ({size:.1f} MB)')
else:
print('⚠️ No executables found in dist/ directory')
return True
except subprocess.CalledProcessError as e:
print(f'❌ Build failed: {e}')
if e.stdout:
print('STDOUT:', e.stdout)
if e.stderr:
print('STDERR:', e.stderr)
return False
# =================================================
# SECTION: Test and profile binary
# =================================================
WELCOME_MARKERS = ["welcome", "openhands cli", "type /help", "available commands", ">"]
def _is_welcome(line: str) -> bool:
s = line.strip().lower()
return any(marker in s for marker in WELCOME_MARKERS)
def test_executable() -> bool:
"""Test the built executable, measuring boot time and total test time."""
print('🧪 Testing the built executable...')
spec_path = os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH)
specs_path = Path(os.path.expanduser(spec_path))
if specs_path.exists():
print(f"⚠️ Using existing settings at {specs_path}")
else:
print(f"💾 Creating dummy settings at {specs_path}")
specs_path.parent.mkdir(parents=True, exist_ok=True)
specs_path.write_text(dummy_agent.model_dump_json())
exe_path = Path('dist/openhands-cli')
if not exe_path.exists():
exe_path = Path('dist/openhands-cli.exe')
if not exe_path.exists():
print('❌ Executable not found!')
return False
try:
if os.name != 'nt':
os.chmod(exe_path, 0o755)
boot_start = time.time()
proc = subprocess.Popen(
[str(exe_path)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env={**os.environ},
)
# --- Wait for welcome ---
deadline = boot_start + 30
saw_welcome = False
captured = []
while time.time() < deadline:
if proc.poll() is not None:
break
rlist, _, _ = select.select([proc.stdout], [], [], 0.2)
if not rlist:
continue
line = proc.stdout.readline()
if not line:
continue
captured.append(line)
if _is_welcome(line):
saw_welcome = True
break
if not saw_welcome:
print("❌ Did not detect welcome prompt")
try: proc.kill()
except Exception: pass
return False
boot_end = time.time()
print(f"⏱️ Boot to welcome: {boot_end - boot_start:.2f} seconds")
# --- Run /help then /exit ---
if proc.stdin is None:
print("❌ stdin unavailable")
proc.kill()
return False
proc.stdin.write("/help\n/exit\n")
proc.stdin.flush()
out, _ = proc.communicate(timeout=60)
total_end = time.time()
full_output = ''.join(captured) + (out or '')
print(f"⏱️ End-to-end test time: {total_end - boot_start:.2f} seconds")
if "available commands" in full_output.lower():
print("✅ Executable starts, welcome detected, and /help works")
return True
else:
print("❌ /help output not found")
print("Output preview:", full_output[-500:])
return False
except subprocess.TimeoutExpired:
print("❌ Executable test timed out")
try: proc.kill()
except Exception: pass
return False
except Exception as e:
print(f"❌ Error testing executable: {e}")
try: proc.kill()
except Exception: pass
return False
# =================================================
# SECTION: Main
# =================================================
def main() -> int:
"""Main function."""
parser = argparse.ArgumentParser(description='Build OpenHands CLI executable')
parser.add_argument(
'--spec', default='openhands-cli.spec', help='PyInstaller spec file to use'
)
parser.add_argument(
'--no-clean', action='store_true', help='Skip cleaning build directories'
)
parser.add_argument(
'--no-test', action='store_true', help='Skip testing the built executable'
)
parser.add_argument(
'--install-pyinstaller',
action='store_true',
help='Install PyInstaller using uv before building',
)
parser.add_argument(
'--no-build', action='store_true', help='Skip testing the built executable'
)
args = parser.parse_args()
print('🚀 OpenHands CLI Build Script')
print('=' * 40)
# Check if spec file exists
if not os.path.exists(args.spec):
print(f"❌ Spec file '{args.spec}' not found!")
return 1
# Build the executable
if not args.no_build and not build_executable(
args.spec, clean=not args.no_clean
):
return 1
# Test the executable
if not args.no_test:
if not test_executable():
print('❌ Executable test failed, build process failed')
return 1
print('\n🎉 Build process completed!')
print("📁 Check the 'dist/' directory for your executable")
return 0
if __name__ == '__main__':
sys.exit(main())
-48
View File
@@ -1,48 +0,0 @@
#!/bin/bash
#
# Shell script wrapper for building OpenHands CLI executable.
#
# This script provides a simple interface to build the OpenHands CLI
# using PyInstaller with uv package management.
#
set -e # Exit on any error
echo "🚀 OpenHands CLI Build Script"
echo "=============================="
# Check if uv is available
if ! command -v uv &> /dev/null; then
echo "❌ uv is required but not found! Please install uv first."
exit 1
fi
# Parse arguments to check for --install-pyinstaller
INSTALL_PYINSTALLER=false
PYTHON_ARGS=()
for arg in "$@"; do
case $arg in
--install-pyinstaller)
INSTALL_PYINSTALLER=true
PYTHON_ARGS+=("$arg")
;;
*)
PYTHON_ARGS+=("$arg")
;;
esac
done
# Install PyInstaller if requested
if [ "$INSTALL_PYINSTALLER" = true ]; then
echo "📦 Installing PyInstaller with uv..."
if uv add --dev pyinstaller; then
echo "✅ PyInstaller installed successfully with uv!"
else
echo "❌ Failed to install PyInstaller"
exit 1
fi
fi
# Run the Python build script using uv
uv run python build.py "${PYTHON_ARGS[@]}"
@@ -1,63 +0,0 @@
import atexit, os, sys, time
from collections import defaultdict
ENABLE = os.getenv("IMPORT_PROFILING", "0") not in ("", "0", "false", "False")
OUT = "dist/import_profiler.csv"
THRESHOLD_MS = float(os.getenv("IMPORT_PROFILING_THRESHOLD_MS", "0"))
if ENABLE:
timings = defaultdict(float) # module -> total seconds (first load only)
counts = defaultdict(int) # module -> number of first-loads (should be 1)
max_dur = defaultdict(float) # module -> max single load seconds
try:
import importlib._bootstrap as _bootstrap # type: ignore[attr-defined]
except Exception:
_bootstrap = None
start_time = time.perf_counter()
if _bootstrap is not None:
_orig_find_and_load = _bootstrap._find_and_load
def _timed_find_and_load(name, import_):
preloaded = name in sys.modules # cache hit?
t0 = time.perf_counter()
try:
return _orig_find_and_load(name, import_)
finally:
if not preloaded:
dt = time.perf_counter() - t0
timings[name] += dt
counts[name] += 1
if dt > max_dur[name]:
max_dur[name] = dt
_bootstrap._find_and_load = _timed_find_and_load
@atexit.register
def _dump_import_profile():
def ms(s): return f"{s*1000:.3f}"
items = [
(name, counts[name], timings[name], max_dur[name])
for name in timings
if timings[name]*1000 >= THRESHOLD_MS
]
items.sort(key=lambda x: x[2], reverse=True)
try:
with open(OUT, "w", encoding="utf-8") as f:
f.write("module,count,total_ms,max_ms\n")
for name, cnt, tot_s, max_s in items:
f.write(f"{name},{cnt},{ms(tot_s)},{ms(max_s)}\n")
# brief summary
if items:
w = max(len(n) for n, *_ in items[:25])
sys.stderr.write("\n=== Import Time Profile (first-load only) ===\n")
sys.stderr.write(f"{'module'.ljust(w)} count total_ms max_ms\n")
for name, cnt, tot_s, max_s in items[:25]:
sys.stderr.write(
f"{name.ljust(w)} {str(cnt).rjust(5)} {ms(tot_s).rjust(8)} {ms(max_s).rjust(7)}\n"
)
sys.stderr.write(f"\nImport profile written to: {OUT}\n")
except Exception as e:
sys.stderr.write(f"[import-profiler] failed to write profile: {e}\n")
-110
View File
@@ -1,110 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec file for OpenHands CLI.
This spec file configures PyInstaller to create a standalone executable
for the OpenHands CLI application.
"""
from pathlib import Path
import os
import sys
from PyInstaller.utils.hooks import (
collect_submodules,
collect_data_files,
copy_metadata
)
# Get the project root directory (current working directory when running PyInstaller)
project_root = Path.cwd()
a = Analysis(
['openhands_cli/simple_main.py'],
pathex=[str(project_root)],
binaries=[],
datas=[
# Include any data files that might be needed
# Add more data files here if needed in the future
*collect_data_files('tiktoken'),
*collect_data_files('tiktoken_ext'),
*collect_data_files('litellm'),
*collect_data_files('fastmcp'),
*collect_data_files('mcp'),
# Include Jinja prompt templates required by the agent SDK
*collect_data_files('openhands.sdk.agent', includes=['prompts/*.j2']),
# Include package metadata for importlib.metadata
*copy_metadata('fastmcp'),
],
hiddenimports=[
# Explicitly include modules that might not be detected automatically
*collect_submodules('openhands_cli'),
*collect_submodules('prompt_toolkit'),
# Include OpenHands SDK submodules explicitly to avoid resolution issues
*collect_submodules('openhands.sdk'),
*collect_submodules('openhands.tools'),
*collect_submodules('tiktoken'),
*collect_submodules('tiktoken_ext'),
*collect_submodules('litellm'),
*collect_submodules('fastmcp'),
# Include mcp but exclude CLI parts that require typer
'mcp.types',
'mcp.client',
'mcp.server',
'mcp.shared',
'openhands.tools.execute_bash',
'openhands.tools.str_replace_editor',
'openhands.tools.task_tracker',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
# runtime_hooks=[str(project_root / "hooks" / "rthook_profile_imports.py")],
excludes=[
# Exclude unnecessary modules to reduce binary size
'tkinter',
'matplotlib',
'numpy',
'scipy',
'pandas',
'IPython',
'jupyter',
'notebook',
# Exclude mcp CLI parts that cause issues
'mcp.cli',
'prompt_toolkit.contrib.ssh',
'fastmcp.cli',
'boto3',
'botocore',
'posthog',
'browser-use',
'openhands.tools.browser_use'
],
noarchive=False,
# IMPORTANT: do not use optimize=2 (-OO) because it strips docstrings used by PLY/bashlex grammar
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='openhands-cli',
debug=False,
bootloader_ignore_signals=False,
strip=True, # Strip debug symbols to reduce size
upx=True, # Use UPX compression if available
upx_exclude=[],
runtime_tmpdir=None,
console=True, # CLI application needs console
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None, # Add icon path here if you have one
)
-3
View File
@@ -1,3 +0,0 @@
"""OpenHands CLI package."""
__version__ = "0.1.0"
-174
View File
@@ -1,174 +0,0 @@
#!/usr/bin/env python3
"""
Agent chat functionality for OpenHands CLI.
Provides a conversation interface with an AI agent using OpenHands patterns.
"""
import logging
import uuid
from openhands.sdk import (
Message,
TextContent,
AgentContext,
)
from openhands.sdk.conversation.state import AgentExecutionStatus
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.runner import ConversationRunner
from openhands_cli.setup import setup_conversation, MissingAgentSpec
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from openhands_cli.tui.tui import (
CommandCompleter,
display_help,
display_welcome,
)
from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation
from openhands_cli.locations import WORK_DIR
logger = logging.getLogger(__name__)
def run_cli_entry() -> None:
"""Run the agent chat session using the agent SDK.
Raises:
AgentSetupError: If agent setup fails
KeyboardInterrupt: If user interrupts the session
EOFError: If EOF is encountered
"""
conversation = None
settings_screen = SettingsScreen()
while not conversation:
try:
conversation = setup_conversation()
except MissingAgentSpec:
settings_screen.handle_basic_settings(escapable=False)
# Generate session ID
session_id = str(uuid.uuid4())[:8]
display_welcome(session_id)
# Create prompt session with command completer
session = PromptSession(completer=CommandCompleter())
# Create conversation runner to handle state machine logic
runner = ConversationRunner(conversation)
# Main chat loop
while True:
try:
# Get user input
user_input = session.prompt(
HTML("<gold>> </gold>"),
multiline=False,
)
if not user_input.strip():
continue
# Handle commands
command = user_input.strip().lower()
message = Message(
role="user",
content=[TextContent(text=user_input)],
)
if command == "/exit":
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
break
elif command == "/settings":
settings_screen = SettingsScreen(conversation)
settings_screen.display_settings()
continue
elif command == "/clear":
print_formatted_text(
HTML("<yellow>Starting new conversation...</yellow>")
)
# Create a new conversation to clear context
try:
conversation = setup_conversation()
runner = ConversationRunner(conversation)
session_id = str(uuid.uuid4())[:8]
display_welcome(session_id)
print_formatted_text(
HTML("<green>✓ New conversation started</green>")
)
except Exception as e:
print_formatted_text(
HTML(f"<red>Error creating new conversation: {e}</red>")
)
continue
elif command == "/help":
display_help()
continue
elif command == "/status":
print_formatted_text(HTML(f"<grey>Session ID: {session_id}</grey>"))
print_formatted_text(HTML("<grey>Status: Active</grey>"))
confirmation_status = (
"enabled" if conversation.state.confirmation_mode else "disabled"
)
print_formatted_text(
HTML(f"<grey>Confirmation mode: {confirmation_status}</grey>")
)
continue
elif command == "/confirm":
runner.toggle_confirmation_mode()
new_status = (
"enabled" if runner.is_confirmation_mode_enabled else "disabled"
)
print_formatted_text(
HTML(f"<yellow>Confirmation mode {new_status}</yellow>")
)
continue
elif command == "/new":
print_formatted_text(
HTML("<yellow>Starting new conversation...</yellow>")
)
# Create a new conversation to clear context
try:
conversation = setup_conversation()
runner = ConversationRunner(conversation)
session_id = str(uuid.uuid4())[:8]
display_welcome(session_id)
print_formatted_text(
HTML("<green>✓ New conversation started</green>")
)
except Exception as e:
print_formatted_text(
HTML(f"<red>Error creating new conversation: {e}</red>")
)
continue
elif command == "/resume":
if not (
conversation.state.agent_status == AgentExecutionStatus.PAUSED
or conversation.state.agent_status
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
):
print_formatted_text(
HTML("<red>No paused conversation to resume...</red>")
)
continue
# Resume without new message
message = None
runner.process_message(message)
print() # Add spacing
except KeyboardInterrupt:
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
break
@@ -1,5 +0,0 @@
from openhands_cli.listeners.pause_listener import PauseListener
__all__ = [
"PauseListener",
]
@@ -1,82 +0,0 @@
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from openhands.sdk import Conversation
from prompt_toolkit import HTML, print_formatted_text
from prompt_toolkit.input import Input, create_input
from prompt_toolkit.keys import Keys
class PauseListener(threading.Thread):
"""Background key listener that triggers pause on Ctrl-P.
Starts and stops around agent run() loops to avoid interfering with user prompts.
"""
def __init__(
self,
on_pause: Callable,
input_source: Input | None = None, # used to pipe inputs for unit tests
):
super().__init__(daemon=True)
self.on_pause = on_pause
self._stop_event = threading.Event()
self._pause_event = threading.Event()
self._input = input_source or create_input()
def _detect_pause_key_presses(self) -> bool:
pause_detected = False
for key_press in self._input.read_keys():
pause_detected = pause_detected or key_press.key == Keys.ControlP
pause_detected = pause_detected or key_press.key == Keys.ControlC
pause_detected = pause_detected or key_press.key == Keys.ControlD
return pause_detected
def _execute_pause(self) -> None:
self._pause_event.set() # Mark pause event occurred
print_formatted_text(HTML(""))
print_formatted_text(
HTML("<gold>Pausing agent once step is completed...</gold>")
)
try:
self.on_pause()
except Exception:
pass
def run(self) -> None:
try:
with self._input.raw_mode():
# User hasn't paused and pause listener hasn't been shut down
while not (self.is_paused() or self.is_stopped()):
if self._detect_pause_key_presses():
self._execute_pause()
finally:
try:
self._input.close()
except Exception:
pass
def stop(self) -> None:
self._stop_event.set()
def is_stopped(self) -> bool:
return self._stop_event.is_set()
def is_paused(self) -> bool:
return self._pause_event.is_set()
@contextmanager
def pause_listener(
conversation: Conversation, input_source: Input | None = None
) -> Iterator[PauseListener]:
"""Ensure PauseListener always starts/stops cleanly."""
listener = PauseListener(on_pause=conversation.pause, input_source=input_source)
listener.start()
try:
yield listener
finally:
listener.stop()
-9
View File
@@ -1,9 +0,0 @@
import os
# Configuration directory for storing agent settings and CLI configuration
PERSISTENCE_DIR = os.path.expanduser("~/.openhands")
# Working directory for agent operations (current directory where CLI is run)
WORK_DIR = os.getcwd()
AGENT_SETTINGS_PATH = "agent_settings.json"
-29
View File
@@ -1,29 +0,0 @@
from prompt_toolkit.styles import Style, merge_styles
from prompt_toolkit.styles.base import BaseStyle
from prompt_toolkit.styles.defaults import default_ui_style
# Centralized helper for CLI styles so we can safely merge our custom colors
# with prompt_toolkit's default UI style. This preserves completion menu and
# fuzzy-match visibility across different terminal themes (e.g., Ubuntu).
COLOR_GOLD = "#FFD700"
COLOR_GREY = "#808080"
COLOR_AGENT_BLUE = "#4682B4" # Steel blue - readable on light/dark backgrounds
def get_cli_style() -> BaseStyle:
base = default_ui_style()
custom = Style.from_dict(
{
"gold": COLOR_GOLD,
"grey": COLOR_GREY,
"prompt": f"{COLOR_GOLD} bold",
# Ensure good contrast for fuzzy matches on the selected completion row
# across terminals/themes (e.g., Ubuntu GNOME, Alacritty, Kitty).
# See https://github.com/All-Hands-AI/OpenHands/issues/10330
"completion-menu.completion.current fuzzymatch.outside": "fg:#ffffff bg:#888888",
"selected": COLOR_GOLD,
"risk-high": "#FF0000 bold", # Red bold for HIGH risk
}
)
return merge_styles([base, custom])
-162
View File
@@ -1,162 +0,0 @@
from openhands.sdk import BaseConversation, Message
from openhands.sdk.security.confirmation_policy import (
AlwaysConfirm,
NeverConfirm,
ConfirmRisky,
ConfirmationPolicyBase,
)
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.sdk.event.utils import get_unmatched_actions
from prompt_toolkit import HTML, print_formatted_text
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
from openhands_cli.user_actions import ask_user_confirmation
from openhands_cli.user_actions.types import UserConfirmation
class ConversationRunner:
"""Handles the conversation state machine logic cleanly."""
def __init__(self, conversation: BaseConversation):
self.conversation = conversation
@property
def is_confirmation_mode_enabled(self):
return self.conversation.confirmation_policy_active
def toggle_confirmation_mode(self):
if self.is_confirmation_mode_enabled:
self.set_confirmation_policy(NeverConfirm())
else:
self.set_confirmation_policy(AlwaysConfirm())
def set_confirmation_policy(
self, confirmation_policy: ConfirmationPolicyBase
) -> None:
self.conversation.set_confirmation_policy(confirmation_policy)
def _start_listener(self) -> None:
self.listener = PauseListener(on_pause=self.conversation.pause)
self.listener.start()
def _print_run_status(self) -> None:
print_formatted_text("")
if self.conversation.state.agent_status == AgentExecutionStatus.PAUSED:
print_formatted_text(
HTML(
"<yellow>Resuming paused conversation...</yellow><grey> (Press Ctrl-P to pause)</grey>"
)
)
else:
print_formatted_text(
HTML(
"<yellow>Agent running...</yellow><grey> (Press Ctrl-P to pause)</grey>"
)
)
print_formatted_text("")
def process_message(self, message: Message | None) -> None:
"""Process a user message through the conversation.
Args:
message: The user message to process
"""
self._print_run_status()
# Send message to conversation
if message:
self.conversation.send_message(message)
if self.is_confirmation_mode_enabled:
self._run_with_confirmation()
else:
self._run_without_confirmation()
def _run_without_confirmation(self) -> None:
with pause_listener(self.conversation):
self.conversation.run()
def _run_with_confirmation(self) -> None:
# If agent was paused, resume with confirmation request
if (
self.conversation.state.agent_status
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
):
user_confirmation = self._handle_confirmation_request()
if user_confirmation == UserConfirmation.DEFER:
return
while True:
with pause_listener(self.conversation) as listener:
self.conversation.run()
if listener.is_paused():
break
# In confirmation mode, agent either finishes or waits for user confirmation
if self.conversation.state.agent_status == AgentExecutionStatus.FINISHED:
break
elif (
self.conversation.state.agent_status
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
):
user_confirmation = self._handle_confirmation_request()
if user_confirmation == UserConfirmation.DEFER:
return
else:
raise Exception("Infinite loop")
def _handle_confirmation_request(self) -> UserConfirmation:
"""Handle confirmation request from user.
Returns:
UserConfirmation indicating the user's choice
"""
pending_actions = get_unmatched_actions(self.conversation.state.events)
if not pending_actions:
return UserConfirmation.ACCEPT
result = ask_user_confirmation(
pending_actions,
isinstance(self.conversation.state.confirmation_policy, ConfirmRisky),
)
decision = result.decision
policy_change = result.policy_change
if decision == UserConfirmation.REJECT:
self.conversation.reject_pending_actions(
result.reason or "User rejected the actions"
)
return decision
if decision == UserConfirmation.DEFER:
self.conversation.pause()
return decision
if isinstance(policy_change, NeverConfirm):
print_formatted_text(
HTML(
"<yellow>Confirmation mode disabled. Agent will proceed without asking.</yellow>"
)
)
self.set_confirmation_policy(policy_change)
return decision
if isinstance(policy_change, ConfirmRisky):
print_formatted_text(
HTML(
"<yellow>Security-based confirmation enabled. "
"LOW/MEDIUM risk actions will auto-confirm, HIGH risk actions will ask for confirmation.</yellow>"
)
)
self.set_confirmation_policy(policy_change)
return decision
# Accept action without changing existing policies
assert decision == UserConfirmation.ACCEPT
return decision
-41
View File
@@ -1,41 +0,0 @@
from openhands.sdk import Conversation, BaseConversation
from openhands_cli.tui.settings.store import AgentStore
from prompt_toolkit import HTML, print_formatted_text
from openhands.tools.execute_bash import BashTool
from openhands.tools.str_replace_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands.sdk import register_tool
register_tool("BashTool", BashTool)
register_tool("FileEditorTool", FileEditorTool)
register_tool("TaskTrackerTool", TaskTrackerTool)
class MissingAgentSpec(Exception):
"""Raised when agent specification is not found or invalid."""
pass
def setup_conversation() -> BaseConversation:
"""
Setup the conversation with agent.
Raises:
MissingAgentSpec: If agent specification is not found or invalid.
"""
agent_store = AgentStore()
agent = agent_store.load()
if not agent:
raise MissingAgentSpec(
"Agent specification not found. Please configure your agent settings."
)
# Create conversation - agent context is now set in AgentStore.load()
conversation = Conversation(agent=agent)
print_formatted_text(
HTML(f"<green>✓ Agent initialized with model: {agent.llm.model}</green>")
)
return conversation
@@ -1,44 +0,0 @@
#!/usr/bin/env python3
"""
Simple main entry point for OpenHands CLI.
This is a simplified version that demonstrates the TUI functionality.
"""
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.agent_chat import run_cli_entry
def main() -> None:
"""Main entry point for the OpenHands CLI.
Raises:
ImportError: If agent chat dependencies are missing
Exception: On other error conditions
"""
try:
# Start agent chat directly by default
run_cli_entry()
except ImportError as e:
print_formatted_text(
HTML(f"<red>Error: Agent chat requires additional dependencies: {e}</red>")
)
print_formatted_text(
HTML("<yellow>Please ensure the agent SDK is properly installed.</yellow>")
)
raise
except KeyboardInterrupt:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
except EOFError:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
except Exception as e:
print_formatted_text(HTML(f"<red>Error starting agent chat: {e}</red>"))
import traceback
traceback.print_exc()
raise
if __name__ == "__main__":
main()
@@ -1,5 +0,0 @@
from openhands_cli.tui.tui import DEFAULT_STYLE
__all__ = [
"DEFAULT_STYLE",
]
@@ -1,188 +0,0 @@
import os
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
from openhands_cli.tui.settings.store import AgentStore
from openhands_cli.user_actions.settings_action import (
SettingsType,
choose_llm_model,
choose_llm_provider,
prompt_api_key,
save_settings_confirmation,
settings_type_confirmation,
prompt_custom_model,
prompt_base_url,
choose_memory_condensation,
)
from openhands_cli.tui.utils import StepCounter
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import Conversation, LLM, LocalFileStore
from openhands.sdk.preset.default import get_default_agent
from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.widgets import Frame, TextArea
from openhands_cli.pt_style import COLOR_GREY
class SettingsScreen:
def __init__(self, conversation: Conversation | None = None):
self.file_store = LocalFileStore(PERSISTENCE_DIR)
self.agent_store = AgentStore()
self.conversation = conversation
def display_settings(self) -> None:
agent_spec = self.agent_store.load()
if not agent_spec:
return
llm = agent_spec.llm
advanced_llm_settings = True if llm.base_url else False
# Prepare labels and values based on settings
labels_and_values = []
if not advanced_llm_settings:
# Attempt to determine provider, fallback if not directly available
provider = llm.model.split("/")[0] if "/" in llm.model else "Unknown"
labels_and_values.extend(
[
(" LLM Provider", str(provider)),
(" LLM Model", str(llm.model)),
]
)
else:
labels_and_values.extend(
[
(" Custom Model", llm.model),
(" Base URL", llm.base_url),
]
)
labels_and_values.extend(
[
(" API Key", "********" if llm.api_key else "Not Set"),
(
" Confirmation Mode",
"Enabled"
if self.conversation.confirmation_policy_active
else "Disabled",
),
(
" Memory Condensation",
"Enabled" if agent_spec.condenser else "Disabled",
),
(
" Configuration File",
os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH),
),
]
)
# Calculate max widths for alignment
# Ensure values are strings for len() calculation
str_labels_and_values = [
(label, str(value)) for label, value in labels_and_values
]
max_label_width = (
max(len(label) for label, _ in str_labels_and_values)
if str_labels_and_values
else 0
)
# Construct the summary text with aligned columns
settings_lines = [
f"{label + ':':<{max_label_width + 1}} {value:<}" # Changed value alignment to left (<)
for label, value in str_labels_and_values
]
settings_text = "\n".join(settings_lines)
container = Frame(
TextArea(
text=settings_text,
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title="Settings",
style=f"fg:{COLOR_GREY}",
)
print_container(container)
self.configure_settings()
def configure_settings(self):
try:
settings_type = settings_type_confirmation()
except KeyboardInterrupt:
return
if settings_type == SettingsType.BASIC:
self.handle_basic_settings()
elif settings_type == SettingsType.ADVANCED:
self.handle_advanced_settings()
def handle_basic_settings(self, escapable=True):
step_counter = StepCounter(3)
try:
provider = choose_llm_provider(step_counter, escapable=escapable)
llm_model = choose_llm_model(step_counter, provider, escapable=escapable)
api_key = prompt_api_key(
step_counter,
provider,
self.conversation.agent.llm.api_key if self.conversation else None,
escapable=escapable,
)
save_settings_confirmation()
except KeyboardInterrupt:
print_formatted_text(HTML("\n<red>Cancelled settings change.</red>"))
return
# Store the collected settings for persistence
self._save_llm_settings(f"{provider}/{llm_model}", api_key)
def handle_advanced_settings(self, escapable=True):
"""Handle advanced settings configuration with clean step-by-step flow."""
step_counter = StepCounter(4)
try:
custom_model = prompt_custom_model(step_counter)
base_url = prompt_base_url(step_counter)
api_key = prompt_api_key(
step_counter,
custom_model.split("/")[0] if len(custom_model.split("/")) > 1 else "",
self.conversation.agent.llm.api_key if self.conversation else None,
escapable=escapable,
)
memory_condensation = choose_memory_condensation(step_counter)
# Confirm save
save_settings_confirmation()
except KeyboardInterrupt:
print_formatted_text(HTML("\n<red>Cancelled settings change.</red>"))
return
# Store the collected settings for persistence
self._save_advanced_settings(
custom_model, base_url, api_key, memory_condensation
)
def _save_llm_settings(self, model, api_key, base_url: str | None = None) -> None:
llm = LLM(model=model, api_key=api_key, base_url=base_url, service_id="agent")
agent = self.agent_store.load()
if not agent:
agent = get_default_agent(llm=llm, working_dir=WORK_DIR, cli_mode=True)
agent = agent.model_copy(update={"llm": llm})
self.agent_store.save(agent)
def _save_advanced_settings(
self, custom_model: str, base_url: str, api_key: str, memory_condensation: bool
):
self._save_llm_settings(custom_model, api_key, base_url=base_url)
agent_spec = self.agent_store.load()
if not agent_spec:
return
if not memory_condensation:
agent_spec.model_copy(update={"condenser": None})
self.agent_store.save(agent_spec)
@@ -1,48 +0,0 @@
# openhands_cli/settings/store.py
from __future__ import annotations
import os
from openhands.sdk import LocalFileStore, Agent, AgentContext
from openhands.sdk.preset.default import get_default_tools
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
from prompt_toolkit import HTML, print_formatted_text
class AgentStore:
"""Single source of truth for persisting/retrieving AgentSpec."""
def __init__(self) -> None:
self.file_store = LocalFileStore(root=PERSISTENCE_DIR)
def load(self) -> Agent | None:
try:
str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
agent = Agent.model_validate_json(str_spec)
# Update tools with most recent working directory
updated_tools = get_default_tools(
working_dir=WORK_DIR,
persistence_dir=PERSISTENCE_DIR,
enable_browser=False,
)
# Create agent context with current working directory
agent_context = AgentContext(
system_message_suffix=f"You current working directory is: {WORK_DIR}",
)
agent = agent.model_copy(
update={"tools": updated_tools, "agent_context": agent_context}
)
return agent
except FileNotFoundError:
return None
except Exception:
print_formatted_text(
HTML("\n<red>Agent configuration file is corrupted!</red>")
)
return None
def save(self, agent: Agent) -> None:
serialized_spec = agent.model_dump_json(context={"expose_secrets": True})
self.file_store.write(AGENT_SETTINGS_PATH, serialized_spec)
-93
View File
@@ -1,93 +0,0 @@
from collections.abc import Generator
from prompt_toolkit import print_formatted_text
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.shortcuts import clear
from openhands_cli import __version__
from openhands_cli.pt_style import get_cli_style
DEFAULT_STYLE = get_cli_style()
# Available commands with descriptions
COMMANDS = {
"/exit": "Exit the application",
"/help": "Display available commands",
"/clear": "Start a new conversation from scratch",
"/status": "Display conversation details",
"/confirm": "Toggle confirmation mode on/off",
"/new": "Create a new conversation",
"/resume": "Resume a paused conversation",
"/settings": "Display and modify current settings",
}
class CommandCompleter(Completer):
"""Custom completer for commands with interactive dropdown."""
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Generator[Completion, None, None]:
text = document.text_before_cursor.lstrip()
if text.startswith("/"):
for command, description in COMMANDS.items():
if command.startswith(text):
yield Completion(
command,
start_position=-len(text),
display_meta=description,
style="bg:ansidarkgray fg:gold",
)
def display_banner(session_id: str) -> None:
print_formatted_text(
HTML(r"""<gold>
___ _ _ _
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|_|
</gold>"""),
style=DEFAULT_STYLE,
)
print_formatted_text(HTML(f"<grey>OpenHands CLI v{__version__}</grey>"))
print_formatted_text("")
print_formatted_text(HTML(f"<grey>Initialized conversation {session_id}</grey>"))
print_formatted_text("")
def display_help() -> None:
"""Display help information about available commands."""
print_formatted_text("")
print_formatted_text(HTML("<gold>🤖 OpenHands CLI Help</gold>"))
print_formatted_text(HTML("<grey>Available commands:</grey>"))
print_formatted_text("")
for command, description in COMMANDS.items():
print_formatted_text(HTML(f" <white>{command}</white> - {description}"))
print_formatted_text("")
print_formatted_text(HTML("<grey>Tips:</grey>"))
print_formatted_text(" • Type / and press Tab to see command suggestions")
print_formatted_text(" • Use arrow keys to navigate through suggestions")
print_formatted_text(" • Press Enter to select a command")
print_formatted_text("")
def display_welcome(session_id: str = "chat") -> None:
"""Display welcome message."""
clear()
display_banner(session_id)
print_formatted_text(HTML("<gold>Let's start building!</gold>"))
print_formatted_text(
HTML(
"<green>What do you want to build? <grey>Type /help for help</grey></green>"
)
)
print()
-14
View File
@@ -1,14 +0,0 @@
class StepCounter:
"""Automatically manages step numbering for settings flows."""
def __init__(self, total_steps: int):
self.current_step = 0
self.total_steps = total_steps
def next_step(self, prompt: str) -> str:
"""Get the next step prompt with automatic numbering."""
self.current_step += 1
return f"(Step {self.current_step}/{self.total_steps}) {prompt}"
def existing_step(self, prompt: str) -> str:
return f"(Step {self.current_step}/{self.total_steps}) {prompt}"
@@ -1,17 +0,0 @@
from openhands_cli.user_actions.agent_action import ask_user_confirmation
from openhands_cli.user_actions.exit_session import (
exit_session_confirmation,
)
from openhands_cli.user_actions.settings_action import (
choose_llm_provider,
settings_type_confirmation,
)
from openhands_cli.user_actions.types import UserConfirmation
__all__ = [
"ask_user_confirmation",
"exit_session_confirmation",
"UserConfirmation",
"settings_type_confirmation",
"choose_llm_provider",
]
@@ -1,94 +0,0 @@
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk.security.confirmation_policy import (
ConfirmRisky,
SecurityRisk,
NeverConfirm,
)
from openhands_cli.user_actions.types import UserConfirmation, ConfirmationResult
from openhands_cli.user_actions.utils import cli_confirm, cli_text_input
def ask_user_confirmation(
pending_actions: list, using_risk_based_policy: bool = False
) -> ConfirmationResult:
"""Ask user to confirm pending actions.
Args:
pending_actions: List of pending actions from the agent
Returns:
ConfirmationResult with decision, optional policy_change, and reason
"""
if not pending_actions:
return ConfirmationResult(decision=UserConfirmation.ACCEPT)
print_formatted_text(
HTML(
f"<yellow>🔍 Agent created {len(pending_actions)} action(s) and is waiting for confirmation:</yellow>"
)
)
for i, action in enumerate(pending_actions, 1):
tool_name = getattr(action, "tool_name", "[unknown tool]")
action_content = (
str(getattr(action, "action", ""))[:100].replace("\n", " ")
or "[unknown action]"
)
print_formatted_text(
HTML(f"<grey> {i}. {tool_name}: {action_content}...</grey>")
)
question = "Choose an option:"
options = [
"Yes, proceed",
"No, reject (w/o reason)",
"No, reject with reason",
"Always proceed (don't ask again)",
]
if not using_risk_based_policy:
options.append("Auto-confirm LOW/MEDIUM risk, ask for HIGH risk")
try:
index = cli_confirm(question, options, escapable=True)
except (EOFError, KeyboardInterrupt):
print_formatted_text(HTML("\n<red>No input received; pausing agent.</red>"))
return ConfirmationResult(decision=UserConfirmation.DEFER)
if index == 0:
return ConfirmationResult(decision=UserConfirmation.ACCEPT)
elif index == 1:
return ConfirmationResult(decision=UserConfirmation.REJECT)
elif index == 2:
try:
reason_result = cli_text_input(
"Please enter your reason for rejecting these actions: "
)
except Exception:
return ConfirmationResult(decision=UserConfirmation.DEFER)
# Support both string return and (reason, cancelled) tuple for tests
cancelled = False
if isinstance(reason_result, tuple) and len(reason_result) >= 1:
reason = reason_result[0] or ""
cancelled = bool(reason_result[1]) if len(reason_result) > 1 else False
else:
reason = str(reason_result or "").strip()
if cancelled:
return ConfirmationResult(decision=UserConfirmation.DEFER)
return ConfirmationResult(decision=UserConfirmation.REJECT, reason=reason)
elif index == 3:
return ConfirmationResult(
decision=UserConfirmation.ACCEPT, policy_change=NeverConfirm()
)
elif index == 4:
return ConfirmationResult(
decision=UserConfirmation.ACCEPT,
policy_change=ConfirmRisky(threshold=SecurityRisk.HIGH),
)
return ConfirmationResult(decision=UserConfirmation.REJECT)
@@ -1,18 +0,0 @@
from openhands_cli.user_actions.types import UserConfirmation
from openhands_cli.user_actions.utils import cli_confirm
def exit_session_confirmation() -> UserConfirmation:
"""
Ask user to confirm exiting session.
"""
question = "Terminate session?"
options = ["Yes, proceed", "No, dismiss"]
index = cli_confirm(question, options) # Blocking UI, not escapable
options_mapping = {
0: UserConfirmation.ACCEPT, # User accepts termination session
1: UserConfirmation.REJECT, # User does not terminate session
}
return options_mapping.get(index, UserConfirmation.REJECT)
@@ -1,167 +0,0 @@
from enum import Enum
from openhands_cli.tui.utils import StepCounter
from prompt_toolkit.completion import FuzzyWordCompleter
from pydantic import SecretStr
from openhands.sdk.llm import VERIFIED_MODELS, UNVERIFIED_MODELS_EXCLUDING_BEDROCK
from openhands_cli.user_actions.utils import cli_confirm, cli_text_input
from prompt_toolkit.validation import Validator, ValidationError
class NonEmptyValueValidator(Validator):
def validate(self, document):
text = document.text
if not text:
raise ValidationError(
message="API key cannot be empty. Please enter a valid API key."
)
class SettingsType(Enum):
BASIC = "basic"
ADVANCED = "advanced"
def settings_type_confirmation() -> SettingsType:
question = "Which settings would you like to modify?"
choices = [
"LLM (Basic)",
"LLM (Advanced)",
"Go back",
]
index = cli_confirm(question, choices)
if choices[index] == "Go back":
raise KeyboardInterrupt
options_map = {0: SettingsType.BASIC, 1: SettingsType.ADVANCED}
return options_map.get(index)
def choose_llm_provider(step_counter: StepCounter, escapable=True) -> str:
question = step_counter.next_step(
"Select LLM Provider (TAB for options, CTRL-c to cancel): "
)
options = (
list(VERIFIED_MODELS.keys()).copy()
+ list(UNVERIFIED_MODELS_EXCLUDING_BEDROCK.keys()).copy()
)
alternate_option = "Select another provider"
display_options = options[:4] + [alternate_option]
index = cli_confirm(question, display_options, escapable=escapable)
chosen_option = display_options[index]
if display_options[index] != alternate_option:
return chosen_option
question = step_counter.existing_step(
"Type LLM Provider (TAB to complete, CTRL-c to cancel): "
)
return cli_text_input(
question, escapable=True, completer=FuzzyWordCompleter(options, WORD=True)
)
def choose_llm_model(step_counter: StepCounter, provider: str, escapable=True) -> str:
"""Choose LLM model using spec-driven approach. Return (model, deferred)."""
models = VERIFIED_MODELS.get(
provider, []
) + UNVERIFIED_MODELS_EXCLUDING_BEDROCK.get(provider, [])
if provider == "openhands":
question = (
step_counter.next_step("Select Available OpenHands Model:\n")
+ "LLM usage is billed at the providers rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms"
)
else:
question = step_counter.next_step(
"Select LLM Model (TAB for options, CTRL-c to cancel): "
)
alternate_option = "Select another model"
display_options = models[:4] + [alternate_option]
index = cli_confirm(question, display_options, escapable=escapable)
chosen_option = display_options[index]
if chosen_option != alternate_option:
return chosen_option
question = step_counter.existing_step(
"Type model id (TAB to complete, CTRL-c to cancel): "
)
return cli_text_input(
question, escapable=True, completer=FuzzyWordCompleter(models, WORD=True)
)
def prompt_api_key(
step_counter: StepCounter,
provider: str,
existing_api_key: SecretStr | None = None,
escapable=True,
) -> str:
helper_text = (
"\nYou can find your OpenHands LLM API Key in the API Keys tab of OpenHands Cloud: "
"https://app.all-hands.dev/settings/api-keys\n"
if provider == "openhands"
else ""
)
if existing_api_key:
masked_key = existing_api_key.get_secret_value()[:3] + "***"
question = f"Enter API Key [{masked_key}] (CTRL-c to cancel, ENTER to keep current, type new to change): "
# For existing keys, allow empty input to keep current key
validator = None
else:
question = "Enter API Key (CTRL-c to cancel): "
# For new keys, require non-empty input
validator = NonEmptyValueValidator()
question = helper_text + step_counter.next_step(question)
return cli_text_input(
question, escapable=escapable, validator=validator, is_password=True
)
# Advanced settings functions
def prompt_custom_model(step_counter: StepCounter, escapable=True) -> str:
"""Prompt for custom model name."""
question = step_counter.next_step("Custom Model (CTRL-c to cancel): ")
return cli_text_input(question, escapable=escapable)
def prompt_base_url(step_counter: StepCounter, escapable=True) -> str:
"""Prompt for base URL."""
question = step_counter.next_step("Base URL (CTRL-c to cancel): ")
return cli_text_input(
question, escapable=escapable, validator=NonEmptyValueValidator()
)
def choose_memory_condensation(step_counter: StepCounter, escapable=True) -> bool:
"""Choose memory condensation setting."""
question = step_counter.next_step("Memory Condensation (CTRL-c to cancel): ")
choices = ["Enable", "Disable"]
index = cli_confirm(question, choices, escapable=escapable)
return index == 0 # True for Enable, False for Disable
def save_settings_confirmation() -> bool:
"""Prompt user to confirm saving settings."""
question = "Save new settings? (They will take effect after restart)"
discard = "No, discard"
options = ["Yes, save", discard]
index = cli_confirm(question, options)
if options[index] == discard:
raise KeyboardInterrupt
return options[index]
@@ -1,17 +0,0 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel
from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase
class UserConfirmation(Enum):
ACCEPT = "accept"
REJECT = "reject"
DEFER = "defer"
class ConfirmationResult(BaseModel):
decision: UserConfirmation
policy_change: Optional[ConfirmationPolicyBase] = None
reason: str = ""
@@ -1,147 +0,0 @@
from prompt_toolkit.application import Application
from prompt_toolkit.completion import Completer
from prompt_toolkit.input.base import Input
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.layout.containers import HSplit, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.output.base import Output
from prompt_toolkit.shortcuts import prompt
from prompt_toolkit.validation import Validator
from openhands_cli.tui import DEFAULT_STYLE
def build_keybindings(
choices: list[str], selected: list[int], escapable: bool
) -> KeyBindings:
"""Create keybindings for the confirm UI. Split for testability."""
kb = KeyBindings()
@kb.add("up")
def _handle_up(event: KeyPressEvent) -> None:
selected[0] = (selected[0] - 1) % len(choices)
@kb.add("down")
def _handle_down(event: KeyPressEvent) -> None:
selected[0] = (selected[0] + 1) % len(choices)
@kb.add("enter")
def _handle_enter(event: KeyPressEvent) -> None:
event.app.exit(result=selected[0])
if escapable:
@kb.add("c-c") # Ctrl+C
def _handle_hard_interrupt(event: KeyPressEvent) -> None:
event.app.exit(exception=KeyboardInterrupt())
@kb.add("c-p") # Ctrl+P
def _handle_pause_interrupt(event: KeyPressEvent) -> None:
event.app.exit(exception=KeyboardInterrupt())
@kb.add("escape") # Escape key
def _handle_escape(event: KeyPressEvent) -> None:
event.app.exit(exception=KeyboardInterrupt())
return kb
def build_layout(question: str, choices: list[str], selected_ref: list[int]) -> Layout:
"""Create the layout for the confirm UI. Split for testability."""
def get_choice_text() -> list[tuple[str, str]]:
lines: list[tuple[str, str]] = []
lines.append(("class:question", f"{question}\n\n"))
for i, choice in enumerate(choices):
is_selected = i == selected_ref[0]
prefix = "> " if is_selected else " "
style = "class:selected" if is_selected else "class:unselected"
lines.append((style, f"{prefix}{choice}\n"))
return lines
content_window = Window(
FormattedTextControl(get_choice_text),
always_hide_cursor=True,
height=Dimension(max=8),
)
return Layout(HSplit([content_window]))
def cli_confirm(
question: str = "Are you sure?",
choices: list[str] | None = None,
initial_selection: int = 0,
escapable: bool = False,
input: Input | None = None, # strictly for unit testing
output: Output | None = None, # strictly for unit testing
) -> int:
"""Display a confirmation prompt with the given question and choices.
Returns the index of the selected choice.
"""
if choices is None:
choices = ["Yes", "No"]
selected = [initial_selection] # Using list to allow modification in closure
kb = build_keybindings(choices, selected, escapable)
layout = build_layout(question, choices, selected)
app = Application(
layout=layout,
key_bindings=kb,
style=DEFAULT_STYLE,
full_screen=False,
input=input,
output=output,
)
return int(app.run(in_thread=True))
def cli_text_input(
question: str,
escapable: bool = True,
completer: Completer | None = None,
validator: Validator = None,
is_password: bool = False,
) -> str:
"""Prompt user to enter text input with optional validation.
Args:
question: The prompt question to display
escapable: Whether the user can escape with Ctrl+C or Ctrl+P
completer: Optional completer for tab completion
validator: Optional callable that takes a string and returns True if valid.
If validation fails, the callable should display error messages
and the user will be reprompted.
Returns:
The validated user input string (stripped of whitespace)
"""
kb = KeyBindings()
if escapable:
@kb.add("c-c")
def _(event: KeyPressEvent) -> None:
raise KeyboardInterrupt()
@kb.add("c-p")
def _(event: KeyPressEvent) -> None:
raise KeyboardInterrupt()
reason = str(
prompt(
question,
style=DEFAULT_STYLE,
key_bindings=kb,
completer=completer,
is_password=is_password,
validator=validator,
)
)
return reason.strip()
-86
View File
@@ -1,86 +0,0 @@
[build-system]
build-backend = "hatchling.build"
requires = [ "hatchling>=1.25" ]
[project]
name = "openhands-cli"
version = "0.1.0"
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
readme = "README.md"
license = { text = "MIT" }
authors = [ { name = "OpenHands Team", email = "contact@all-hands.dev" } ]
requires-python = ">=3.12"
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"openhands-sdk",
"openhands-tools",
"prompt-toolkit>=3",
"typer>=0.17.4",
]
# Dev-only tools with uv groups: `uv sync --group dev`
scripts.openhands-cli = "openhands_cli.simple_main:main"
[dependency-groups]
# Hatchling wheel target: include the package directory
dev = [
"black>=23",
"flake8>=6",
"isort>=5",
"mypy>=1",
"pre-commit>=4.3",
"pyinstaller>=6.15",
"pytest>=8.4.1",
"ruff>=0.11.8",
]
[tool.hatch.build.targets.wheel]
packages = [ "openhands_cli" ]
# uv source pins for internal packages
[tool.black]
line-length = 88
target-version = [ "py312" ]
[tool.ruff]
target-version = "py312"
line-length = 88
format.indent-style = "space"
format.quote-style = "double"
format.line-ending = "auto"
format.skip-magic-trailing-comma = false
lint.select = [
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"W", # pycodestyle warnings
]
lint.ignore = [
"B008", # calls in argument defaults
"C901", # too complex
"E501", # line too long (black handles)
]
[tool.isort]
profile = "black"
line_length = 88
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
ignore_missing_imports = true
[tool.uv.sources]
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab" }
-1
View File
@@ -1 +0,0 @@
"""Tests for OpenHands CLI."""
-44
View File
@@ -1,44 +0,0 @@
import pytest
from unittest.mock import patch
# Fixture: mock_verified_models - Simplified model data
@pytest.fixture
def mock_verified_models():
with (
patch("openhands_cli.user_actions.settings_action.VERIFIED_MODELS", {
"openai": ["gpt-4o", "gpt-4o-mini"],
"anthropic": ["claude-3-5-sonnet", "claude-3-5-haiku"],
}),
patch("openhands_cli.user_actions.settings_action.UNVERIFIED_MODELS_EXCLUDING_BEDROCK", {
"openai": ["gpt-custom"],
"anthropic": [],
"custom": ["my-model"],
}),
):
yield
# Fixture: mock_cli_interactions - Reusable CLI mock patterns
@pytest.fixture
def mock_cli_interactions():
class Mocks:
def __init__(self):
self.p_confirm = patch("openhands_cli.user_actions.settings_action.cli_confirm")
self.p_text = patch("openhands_cli.user_actions.settings_action.cli_text_input")
self.cli_confirm = None
self.cli_text_input = None
def start(self):
self.cli_confirm = self.p_confirm.start()
self.cli_text_input = self.p_text.start()
return self
def stop(self):
self.p_confirm.stop()
self.p_text.stop()
mocks = Mocks().start()
try:
yield mocks
finally:
mocks.stop()
@@ -1,439 +0,0 @@
#!/usr/bin/env python3
"""
Tests for confirmation mode functionality in OpenHands CLI.
"""
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from openhands.sdk import ActionBase
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm, ConfirmRisky, SecurityRisk
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output.defaults import DummyOutput
from openhands_cli.runner import ConversationRunner
from openhands_cli.setup import setup_conversation, MissingAgentSpec
from openhands_cli.user_actions import agent_action, ask_user_confirmation, utils
from openhands_cli.user_actions.types import UserConfirmation, ConfirmationResult
from tests.utils import _send_keys
class MockAction(ActionBase):
"""Mock action schema for testing."""
command: str
class TestConfirmationMode:
"""Test suite for confirmation mode functionality."""
def test_setup_conversation_creates_conversation(self) -> None:
"""Test that setup_conversation creates a conversation successfully."""
with patch.dict(os.environ, {'LLM_MODEL': 'test-model'}):
with (
patch('openhands_cli.setup.Conversation') as mock_conversation_class,
patch('openhands_cli.setup.AgentStore') as mock_agent_store_class,
patch('openhands_cli.setup.print_formatted_text') as mock_print,
patch('openhands_cli.setup.HTML') as mock_html,
):
# Mock AgentStore
mock_agent_store_instance = MagicMock()
mock_agent_instance = MagicMock()
mock_agent_instance.llm.model = 'test-model'
mock_agent_store_instance.load.return_value = mock_agent_instance
mock_agent_store_class.return_value = mock_agent_store_instance
# Mock Conversation constructor to return a mock conversation
mock_conversation_instance = MagicMock()
mock_conversation_class.return_value = mock_conversation_instance
result = setup_conversation()
# Verify conversation was created and returned
assert result == mock_conversation_instance
mock_agent_store_class.assert_called_once()
mock_agent_store_instance.load.assert_called_once()
mock_conversation_class.assert_called_once_with(agent=mock_agent_instance)
# Verify print_formatted_text was called
mock_print.assert_called_once()
def test_setup_conversation_raises_missing_agent_spec(self) -> None:
"""Test that setup_conversation raises MissingAgentSpec when agent is not found."""
with (
patch('openhands_cli.setup.AgentStore') as mock_agent_store_class,
):
# Mock AgentStore to return None (no agent found)
mock_agent_store_instance = MagicMock()
mock_agent_store_instance.load.return_value = None
mock_agent_store_class.return_value = mock_agent_store_instance
# Should raise MissingAgentSpec
with pytest.raises(MissingAgentSpec) as exc_info:
setup_conversation()
assert "Agent specification not found" in str(exc_info.value)
mock_agent_store_class.assert_called_once()
mock_agent_store_instance.load.assert_called_once()
def test_conversation_runner_set_confirmation_mode(self) -> None:
"""Test that ConversationRunner can set confirmation policy."""
mock_conversation = MagicMock()
mock_conversation.confirmation_policy_active = False
runner = ConversationRunner(mock_conversation)
# Test enabling confirmation mode
runner.set_confirmation_policy(AlwaysConfirm())
mock_conversation.set_confirmation_policy.assert_called_with(AlwaysConfirm())
# Test disabling confirmation mode
runner.set_confirmation_policy(NeverConfirm())
mock_conversation.set_confirmation_policy.assert_called_with(NeverConfirm())
def test_conversation_runner_initial_state(self) -> None:
"""Test that ConversationRunner starts with confirmation mode disabled."""
mock_conversation = MagicMock()
mock_conversation.confirmation_policy_active = False
runner = ConversationRunner(mock_conversation)
# Verify initial state
assert runner.is_confirmation_mode_enabled is False
def test_ask_user_confirmation_empty_actions(self) -> None:
"""Test that ask_user_confirmation returns ACCEPT for empty actions list."""
result = ask_user_confirmation([])
assert isinstance(result, ConfirmationResult)
assert result.decision == UserConfirmation.ACCEPT
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
assert result.policy_change is None
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_yes(self, mock_cli_confirm: Any) -> None:
"""Test that ask_user_confirmation returns ACCEPT when user selects yes."""
mock_cli_confirm.return_value = 0 # First option (Yes, proceed)
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'ls -la'
result = ask_user_confirmation([mock_action])
assert isinstance(result, ConfirmationResult)
assert result.decision == UserConfirmation.ACCEPT
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
assert result.policy_change is None
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_no(self, mock_cli_confirm: Any) -> None:
"""Test that ask_user_confirmation returns REJECT when user selects no."""
mock_cli_confirm.return_value = 1 # Second option (No, reject)
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'rm -rf /'
result = ask_user_confirmation([mock_action])
assert isinstance(result, ConfirmationResult)
assert result.decision == UserConfirmation.REJECT
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
assert result.policy_change is None
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_y_shorthand(self, mock_cli_confirm: Any) -> None:
"""Test that ask_user_confirmation accepts first option as yes."""
mock_cli_confirm.return_value = 0 # First option (Yes, proceed)
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo hello'
result = ask_user_confirmation([mock_action])
assert result.decision == UserConfirmation.ACCEPT
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_n_shorthand(self, mock_cli_confirm: Any) -> None:
"""Test that ask_user_confirmation accepts second option as no."""
mock_cli_confirm.return_value = 1 # Second option (No, reject)
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'dangerous command'
result = ask_user_confirmation([mock_action])
assert result.decision == UserConfirmation.REJECT
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_invalid_then_yes(
self, mock_cli_confirm: Any
) -> None:
"""Test that ask_user_confirmation handles selection and accepts yes."""
mock_cli_confirm.return_value = 0 # First option (Yes, proceed)
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'
result = ask_user_confirmation([mock_action])
assert result.decision == UserConfirmation.ACCEPT
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
assert mock_cli_confirm.call_count == 1
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_keyboard_interrupt(
self, mock_cli_confirm: Any
) -> None:
"""Test that ask_user_confirmation handles KeyboardInterrupt gracefully."""
mock_cli_confirm.side_effect = KeyboardInterrupt()
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'
result = ask_user_confirmation([mock_action])
assert result.decision == UserConfirmation.DEFER
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_eof_error(self, mock_cli_confirm: Any) -> None:
"""Test that ask_user_confirmation handles EOFError gracefully."""
mock_cli_confirm.side_effect = EOFError()
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'
result = ask_user_confirmation([mock_action])
assert result.decision == UserConfirmation.DEFER
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
def test_ask_user_confirmation_multiple_actions(self) -> None:
"""Test that ask_user_confirmation displays multiple actions correctly."""
with (
patch(
'openhands_cli.user_actions.agent_action.cli_confirm'
) as mock_cli_confirm,
patch(
'openhands_cli.user_actions.agent_action.print_formatted_text'
) as mock_print,
):
mock_cli_confirm.return_value = 0 # First option (Yes, proceed)
mock_action1 = MagicMock()
mock_action1.tool_name = 'bash'
mock_action1.action = 'ls -la'
mock_action2 = MagicMock()
mock_action2.tool_name = 'str_replace_editor'
mock_action2.action = 'create file.txt'
result = ask_user_confirmation([mock_action1, mock_action2])
assert isinstance(result, ConfirmationResult)
assert result.decision == UserConfirmation.ACCEPT
assert result.reason == ""
assert result.policy_change is None
# Verify that both actions were displayed
assert mock_print.call_count >= 3 # Header + 2 actions
@patch('openhands_cli.user_actions.agent_action.cli_text_input')
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_no_with_reason(
self, mock_cli_confirm: Any, mock_cli_text_input: Any
) -> None:
"""Test that ask_user_confirmation returns REJECT when user selects 'No (with reason)'."""
mock_cli_confirm.return_value = 2 # Third option (No, with reason)
mock_cli_text_input.return_value = ('This action is too risky', False)
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'rm -rf /'
result = ask_user_confirmation([mock_action])
assert isinstance(result, ConfirmationResult)
assert result.decision == UserConfirmation.REJECT
assert result.reason == 'This action is too risky'
assert result.policy_change is None
mock_cli_text_input.assert_called_once()
@patch('openhands_cli.user_actions.agent_action.cli_text_input')
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_no_with_reason_cancelled(
self, mock_cli_confirm: Any, mock_cli_text_input: Any
) -> None:
"""Test that ask_user_confirmation falls back to DEFER when reason input is cancelled."""
mock_cli_confirm.return_value = 2 # Third option (No, with reason)
mock_cli_text_input.return_value = ('', True) # User cancelled reason input
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'dangerous command'
result = ask_user_confirmation([mock_action])
assert result.decision == UserConfirmation.DEFER
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert result.policy_change is None
mock_cli_text_input.assert_called_once()
def test_user_confirmation_is_escapable_e2e(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""E2E: non-escapable should ignore Ctrl-C/Ctrl-P/Esc; only Enter returns."""
real_cli_confirm = utils.cli_confirm
with create_pipe_input() as pipe:
output = DummyOutput()
def wrapper(
question: str,
choices: list[str] | None = None,
initial_selection: int = 0,
escapable: bool = False,
**extra: object,
) -> int:
# keep original params; inject test IO
return real_cli_confirm(
question=question,
choices=choices,
initial_selection=initial_selection,
escapable=escapable,
input=pipe,
output=output,
)
# Patch the symbol the caller uses
monkeypatch.setattr(agent_action, 'cli_confirm', wrapper, raising=True)
with ThreadPoolExecutor(max_workers=1) as ex:
fut = ex.submit(
ask_user_confirmation, [MockAction(command='echo hello world')]
)
_send_keys(pipe, '\x03') # Ctrl-C (ignored)
result = fut.result(timeout=2.0)
assert isinstance(result, ConfirmationResult)
assert result.decision == UserConfirmation.DEFER # escaped confirmation view
assert result.reason == ""
assert result.policy_change is None
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_always_accept(self, mock_cli_confirm: Any) -> None:
"""Test that ask_user_confirmation returns ACCEPT with NeverConfirm policy when user selects fourth option."""
mock_cli_confirm.return_value = 3 # Fourth option (Always proceed)
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'
result = ask_user_confirmation([mock_action])
assert result.decision == UserConfirmation.ACCEPT
assert isinstance(result, ConfirmationResult)
assert result.reason == ""
assert isinstance(result.policy_change, NeverConfirm)
def test_conversation_runner_handles_always_accept(self) -> None:
"""Test that ConversationRunner disables confirmation mode when NeverConfirm policy is returned."""
mock_conversation = MagicMock()
mock_conversation.confirmation_policy_active = True
runner = ConversationRunner(mock_conversation)
# Enable confirmation mode first
runner.set_confirmation_policy(AlwaysConfirm())
assert runner.is_confirmation_mode_enabled is True
# Mock get_unmatched_actions to return some actions
with patch('openhands_cli.runner.get_unmatched_actions') as mock_get_actions:
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'
mock_get_actions.return_value = [mock_action]
# Mock ask_user_confirmation to return ACCEPT with NeverConfirm policy
with patch('openhands_cli.runner.ask_user_confirmation') as mock_ask:
mock_ask.return_value = ConfirmationResult(
decision=UserConfirmation.ACCEPT,
reason="",
policy_change=NeverConfirm()
)
# Mock print_formatted_text to avoid output during test
with patch('openhands_cli.runner.print_formatted_text'):
result = runner._handle_confirmation_request()
# Verify that confirmation mode was disabled
assert result == UserConfirmation.ACCEPT
# Should have called set_confirmation_policy with NeverConfirm
mock_conversation.set_confirmation_policy.assert_called_with(NeverConfirm())
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
def test_ask_user_confirmation_auto_confirm_safe(self, mock_cli_confirm: Any) -> None:
"""Test that ask_user_confirmation returns ACCEPT with policy_change when user selects fifth option."""
mock_cli_confirm.return_value = 4 # Fifth option (Auto-confirm LOW/MEDIUM, ask for HIGH)
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'
result = ask_user_confirmation([mock_action])
assert isinstance(result, ConfirmationResult)
assert result.decision == UserConfirmation.ACCEPT
assert result.reason == ""
assert result.policy_change is not None
assert isinstance(result.policy_change, ConfirmRisky)
assert result.policy_change.threshold == SecurityRisk.HIGH
def test_conversation_runner_handles_auto_confirm_safe(self) -> None:
"""Test that ConversationRunner sets ConfirmRisky policy when policy_change is provided."""
mock_conversation = MagicMock()
mock_conversation.confirmation_policy_active = True
runner = ConversationRunner(mock_conversation)
# Enable confirmation mode first
runner.set_confirmation_policy(AlwaysConfirm())
assert runner.is_confirmation_mode_enabled is True
# Mock get_unmatched_actions to return some actions
with patch('openhands_cli.runner.get_unmatched_actions') as mock_get_actions:
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'
mock_get_actions.return_value = [mock_action]
# Mock ask_user_confirmation to return ConfirmationResult with policy_change
with patch('openhands_cli.runner.ask_user_confirmation') as mock_ask:
expected_policy = ConfirmRisky(threshold=SecurityRisk.HIGH)
mock_ask.return_value = ConfirmationResult(
decision=UserConfirmation.ACCEPT,
reason="",
policy_change=expected_policy
)
# Mock print_formatted_text to avoid output during test
with patch('openhands_cli.runner.print_formatted_text'):
result = runner._handle_confirmation_request()
# Verify that security-based confirmation policy was set
assert result == UserConfirmation.ACCEPT
# Should set ConfirmRisky policy with HIGH threshold
mock_conversation.set_confirmation_policy.assert_called_with(expected_policy)
@@ -1,133 +0,0 @@
from typing import Any, Self
from unittest.mock import patch
import pytest
from openhands.sdk import Conversation, ConversationCallbackType
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.conversation import ConversationState
from openhands.sdk.llm import LLM
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm
from pydantic import ConfigDict, SecretStr, model_validator
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands_cli.runner import ConversationRunner
from openhands_cli.user_actions.types import UserConfirmation
class FakeLLM(LLM):
@model_validator(mode="after")
def _set_env_side_effects(self) -> Self:
return self
def default_config() -> dict[str, Any]:
return {
"model": "gpt-4o",
"api_key": SecretStr("test_key"),
"num_retries": 2,
"retry_min_wait": 1,
"retry_max_wait": 2,
}
class FakeAgent(AgentBase):
model_config = ConfigDict(frozen=False)
step_count: int = 0
finish_on_step: int | None = None
def init_state(
self, state: ConversationState, on_event: ConversationCallbackType
) -> None:
pass
def step(
self, state: ConversationState, on_event: ConversationCallbackType
) -> None:
self.step_count += 1
if self.step_count == self.finish_on_step:
state.agent_status = AgentExecutionStatus.FINISHED
@pytest.fixture()
def agent() -> FakeAgent:
llm = LLM(**default_config(), service_id="test-service")
return FakeAgent(llm=llm, tools=[])
class TestConversationRunner:
@pytest.mark.parametrize('agent_status', [AgentExecutionStatus.RUNNING, AgentExecutionStatus.PAUSED])
def test_non_confirmation_mode_runs_once(self, agent: FakeAgent, agent_status: AgentExecutionStatus) -> None:
"""
1. Confirmation mode is not on
2. Process message resumes paused conversation or continues running conversation
"""
convo = Conversation(agent)
convo.max_iteration_per_run = 1
convo.state.agent_status = agent_status
cr = ConversationRunner(convo)
cr.set_confirmation_policy(NeverConfirm())
cr.process_message(message=None)
assert agent.step_count == 1
assert convo.state.agent_status != AgentExecutionStatus.PAUSED
@pytest.mark.parametrize(
'confirmation, final_status, expected_run_calls',
[
# Case 1: Agent waiting for confirmation; user DEFERS -> early return, no run()
(UserConfirmation.DEFER, AgentExecutionStatus.WAITING_FOR_CONFIRMATION, 0),
# Case 2: Agent waiting for confirmation; user ACCEPTS -> run() once, break (finished=True)
(UserConfirmation.ACCEPT, AgentExecutionStatus.FINISHED, 1),
],
)
def test_confirmation_mode_waiting_and_user_decision_controls_run(
self,
agent: FakeAgent,
confirmation: UserConfirmation,
final_status: AgentExecutionStatus,
expected_run_calls: int,
) -> None:
"""
1. Agent may be paused but is waiting for consent on actions
2. If paused, we should have asked for confirmation on action
3. If not paused, we should still ask for confirmation on actions
4. If deferred no run call to agent should be made
5. If accepted, run call to agent should be made
"""
if final_status == AgentExecutionStatus.FINISHED:
agent.finish_on_step = 1
convo = Conversation(agent)
convo.state.agent_status = AgentExecutionStatus.WAITING_FOR_CONFIRMATION
cr = ConversationRunner(convo)
cr.set_confirmation_policy(AlwaysConfirm())
with patch.object(
cr, "_handle_confirmation_request", return_value=confirmation
) as mock_confirmation_request:
cr.process_message(message=None)
mock_confirmation_request.assert_called_once()
assert agent.step_count == expected_run_calls
assert convo.state.agent_status == final_status
def test_confirmation_mode_not_waiting__runs_once_when_finished_true(
self, agent: FakeAgent
) -> None:
"""
1. Agent was not waiting
2. Agent finished without any actions
3. Conversation should finished without asking user for instructions
"""
agent.finish_on_step = 1
convo = Conversation(agent)
convo.state.agent_status = AgentExecutionStatus.PAUSED
cr = ConversationRunner(convo)
cr.set_confirmation_policy(AlwaysConfirm())
with patch.object(cr, "_handle_confirmation_request") as _mock_h:
cr.process_message(message=None)
# No confirmation was needed up front; we still expect exactly one run.
assert agent.step_count == 1
_mock_h.assert_not_called()
@@ -1,77 +0,0 @@
"""Tests to demonstrate the fix for WORK_DIR and PERSISTENCE_DIR separation."""
import os
from unittest.mock import patch, MagicMock
from openhands.sdk import Agent, LLM, ToolSpec
from openhands_cli.locations import WORK_DIR, PERSISTENCE_DIR
from openhands_cli.tui.settings.store import AgentStore
from openhands.sdk.preset.default import get_default_tools
class TestDirectorySeparation:
"""Test that WORK_DIR and PERSISTENCE_DIR are properly separated."""
def test_work_dir_and_persistence_dir_are_different(self):
"""Test that WORK_DIR and PERSISTENCE_DIR are separate directories."""
# WORK_DIR should be the current working directory
assert WORK_DIR == os.getcwd()
# PERSISTENCE_DIR should be ~/.openhands
expected_config_dir = os.path.expanduser("~/.openhands")
assert PERSISTENCE_DIR == expected_config_dir
# They should be different
assert WORK_DIR != PERSISTENCE_DIR
def test_agent_store_uses_persistence_dir(self):
"""Test that AgentStore uses PERSISTENCE_DIR for file storage."""
agent_store = AgentStore()
assert agent_store.file_store.root == PERSISTENCE_DIR
class TestToolSpecFix:
"""Test that tool specs are replaced with default tools using current directory."""
def test_tools_replaced_with_default_tools_on_load(self):
"""Test that entire tools list is replaced with default tools when loading agent."""
# Create a mock agent with different tools and working directories
original_working_dir = "/some/other/path"
mock_agent = Agent(
llm=LLM(model="test/model", api_key="test-key", service_id="test-service"),
tools=[
ToolSpec(name="BashTool", params={"working_dir": original_working_dir}),
ToolSpec(name="FileEditorTool", params={"workspace_root": original_working_dir}),
ToolSpec(name="TaskTrackerTool", params={"save_dir": "value"}),
]
)
# Mock the file store to return our test agent
with patch('openhands_cli.tui.settings.store.LocalFileStore') as mock_file_store:
mock_store_instance = MagicMock()
mock_file_store.return_value = mock_store_instance
mock_store_instance.read.return_value = mock_agent.model_dump_json()
agent_store = AgentStore()
loaded_agent = agent_store.load()
# Verify the agent was loaded
assert loaded_agent is not None
# Verify that tools are replaced with default tools
assert len(loaded_agent.tools) == 3 # BashTool, FileEditorTool, TaskTrackerTool
tool_names = [tool.name for tool in loaded_agent.tools]
assert "BashTool" in tool_names
assert "FileEditorTool" in tool_names
assert "TaskTrackerTool" in tool_names
for tool_spec in loaded_agent.tools:
if tool_spec.name == "BashTool":
assert tool_spec.params["working_dir"] == WORK_DIR
assert tool_spec.params["working_dir"] != original_working_dir
elif tool_spec.name == "FileEditorTool":
assert tool_spec.params["workspace_root"] == WORK_DIR
assert tool_spec.params["workspace_root"] != original_working_dir
elif tool_spec.name == "TaskTrackerTool":
# TaskTrackerTool should use WORK_DIR/.openhands_tasks
assert tool_spec.params["save_dir"] == PERSISTENCE_DIR
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""
Tests for exit_session_confirmation functionality in OpenHands CLI.
"""
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import MagicMock, patch
import pytest
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output.defaults import DummyOutput
from openhands_cli.user_actions import (
exit_session,
exit_session_confirmation,
utils,
)
from openhands_cli.user_actions.types import UserConfirmation
from tests.utils import _send_keys
QUESTION = 'Terminate session?'
OPTIONS = ['Yes, proceed', 'No, dismiss']
@pytest.fixture()
def confirm_patch() -> Iterator[MagicMock]:
"""Patch cli_confirm once per test and yield the mock."""
with patch('openhands_cli.user_actions.exit_session.cli_confirm') as m:
yield m
def _assert_called_once_with_defaults(mock_cli_confirm: MagicMock) -> None:
"""Ensure the question/options are correct and 'escapable' is not enabled."""
mock_cli_confirm.assert_called_once()
args, kwargs = mock_cli_confirm.call_args
# Positional args
assert args == (QUESTION, OPTIONS)
# Should not opt into escapable mode
assert 'escapable' not in kwargs or kwargs['escapable'] is False
class TestExitSessionConfirmation:
"""Test suite for exit_session_confirmation functionality."""
@pytest.mark.parametrize(
'index,expected',
[
(0, UserConfirmation.ACCEPT), # Yes
(1, UserConfirmation.REJECT), # No
(999, UserConfirmation.REJECT), # Invalid => default reject
(-1, UserConfirmation.REJECT), # Negative => default reject
],
)
def test_index_mapping(
self, confirm_patch: MagicMock, index: int, expected: UserConfirmation
) -> None:
"""All index-to-result mappings, including invalid/negative, in one place."""
confirm_patch.return_value = index
result = exit_session_confirmation()
assert isinstance(result, UserConfirmation)
assert result == expected
_assert_called_once_with_defaults(confirm_patch)
def test_exit_session_confirmation_non_escapable_e2e(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""E2E: non-escapable should ignore Ctrl-C/Ctrl-P/Esc; only Enter returns."""
real_cli_confirm = utils.cli_confirm
with create_pipe_input() as pipe:
output = DummyOutput()
def wrapper(
question: str,
choices: list[str] | None = None,
initial_selection: int = 0,
escapable: bool = False,
**extra: object,
) -> int:
# keep original params; inject test IO
return real_cli_confirm(
question=question,
choices=choices,
initial_selection=initial_selection,
escapable=escapable,
input=pipe,
output=output,
)
# Patch the symbol the caller uses
monkeypatch.setattr(exit_session, 'cli_confirm', wrapper, raising=True)
with ThreadPoolExecutor(max_workers=1) as ex:
fut = ex.submit(exit_session_confirmation)
_send_keys(pipe, '\x03') # Ctrl-C (ignored)
_send_keys(pipe, '\x10') # Ctrl-P (ignored)
_send_keys(pipe, '\x1b') # Esc (ignored)
_send_keys(pipe, '\x1b[B') # Arrow Down to "No, dismiss"
_send_keys(pipe, '\r') # Enter
result = fut.result(timeout=2.0)
assert result == UserConfirmation.REJECT
-89
View File
@@ -1,89 +0,0 @@
"""Tests for main entry point functionality."""
from unittest.mock import MagicMock, patch
import pytest
from openhands_cli import simple_main
class TestMainEntryPoint:
"""Test the main entry point behavior."""
@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.PromptSession')
def test_main_starts_agent_chat_directly(
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
) -> None:
"""Test that main() starts agent chat directly when setup succeeds."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_setup_conversation.return_value = mock_conversation
# Mock prompt session to raise KeyboardInterrupt to exit the loop
mock_prompt_session.return_value.prompt.side_effect = KeyboardInterrupt()
# Should complete without raising an exception (graceful exit)
simple_main.main()
# Should call setup_conversation
mock_setup_conversation.assert_called_once()
@patch('openhands_cli.simple_main.run_cli_entry')
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() handles ImportError gracefully."""
mock_run_agent_chat.side_effect = ImportError('Missing dependency')
# Should raise ImportError (re-raised after handling)
with pytest.raises(ImportError) as exc_info:
simple_main.main()
assert str(exc_info.value) == 'Missing dependency'
@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.PromptSession')
def test_main_handles_keyboard_interrupt(
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
) -> None:
"""Test that main() handles KeyboardInterrupt gracefully."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_setup_conversation.return_value = mock_conversation
# Mock prompt session to raise KeyboardInterrupt
mock_prompt_session.return_value.prompt.side_effect = KeyboardInterrupt()
# Should complete without raising an exception (graceful exit)
simple_main.main()
@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.PromptSession')
def test_main_handles_eof_error(
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
) -> None:
"""Test that main() handles EOFError gracefully."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_setup_conversation.return_value = mock_conversation
# Mock prompt session to raise EOFError
mock_prompt_session.return_value.prompt.side_effect = EOFError()
# Should complete without raising an exception (graceful exit)
simple_main.main()
@patch('openhands_cli.simple_main.run_cli_entry')
def test_main_handles_general_exception(
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() handles general exceptions."""
mock_run_agent_chat.side_effect = Exception('Unexpected error')
# Should raise Exception (re-raised after handling)
with pytest.raises(Exception) as exc_info:
simple_main.main()
assert str(exc_info.value) == 'Unexpected error'
@@ -1,53 +0,0 @@
#!/usr/bin/env python3
"""
Tests for pause listener in OpenHands CLI.
"""
import time
from unittest.mock import MagicMock
from openhands.sdk import Conversation
from prompt_toolkit.input.defaults import create_pipe_input
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
class TestPauseListener:
"""Test suite for PauseListener class."""
def test_pause_listener_stop(self) -> None:
"""Test PauseListener stop functionality."""
mock_callback = MagicMock()
listener = PauseListener(on_pause=mock_callback)
listener.start()
# Initially not paused
assert not listener.is_paused()
assert listener.is_alive()
# Stop the listener
listener.stop()
# Listner was shutdown not paused
assert not listener.is_paused()
assert listener.is_stopped()
def test_pause_listener_context_manager(self) -> None:
"""Test pause_listener context manager."""
mock_conversation = MagicMock(spec=Conversation)
mock_conversation.pause = MagicMock()
with create_pipe_input() as pipe:
with pause_listener(mock_conversation, pipe) as listener:
assert isinstance(listener, PauseListener)
assert listener.on_pause == mock_conversation.pause
# Listener should be started (daemon thread)
assert listener.is_alive()
assert not listener.is_paused()
pipe.send_text('\x10') # Ctrl-P
time.sleep(0.1)
assert listener.is_paused()
assert listener.is_stopped()
assert not listener.is_alive()
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""
Core Settings Logic tests
"""
from typing import Any
from unittest.mock import MagicMock
import pytest
from prompt_toolkit.completion import FuzzyWordCompleter
from prompt_toolkit.validation import ValidationError
from pydantic import SecretStr
from openhands_cli.user_actions.settings_action import (
NonEmptyValueValidator,
SettingsType,
choose_llm_model,
choose_llm_provider,
prompt_api_key,
settings_type_confirmation,
)
# -------------------------------
# Settings type selection
# -------------------------------
def test_settings_type_selection(mock_cli_interactions: Any) -> None:
mocks = mock_cli_interactions
# Basic
mocks.cli_confirm.return_value = 0
assert settings_type_confirmation() == SettingsType.BASIC
# Cancel/Go back
mocks.cli_confirm.return_value = 2
with pytest.raises(KeyboardInterrupt):
settings_type_confirmation()
# -------------------------------
# Provider selection flows
# -------------------------------
def test_provider_selection_with_predefined_options(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
from openhands_cli.tui.utils import StepCounter
mocks = mock_cli_interactions
# first option among display_options is index 0
mocks.cli_confirm.return_value = 0
step_counter = StepCounter(1)
result = choose_llm_provider(step_counter)
assert result == 'openai'
def test_provider_selection_with_custom_input(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
from openhands_cli.tui.utils import StepCounter
mocks = mock_cli_interactions
# Due to overlapping provider keys between VERIFIED and UNVERIFIED in fixture,
# display_options contains 4 providers (with duplicates) + alternate at index 4
mocks.cli_confirm.return_value = 4
mocks.cli_text_input.return_value = "my-provider"
step_counter = StepCounter(1)
result = choose_llm_provider(step_counter)
assert result == "my-provider"
# Verify fuzzy completer passed
_, kwargs = mocks.cli_text_input.call_args
assert isinstance(kwargs["completer"], FuzzyWordCompleter)
# -------------------------------
# Model selection flows
# -------------------------------
def test_model_selection_flows(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
from openhands_cli.tui.utils import StepCounter
mocks = mock_cli_interactions
# Direct pick from predefined list
mocks.cli_confirm.return_value = 0
step_counter = StepCounter(1)
result = choose_llm_model(step_counter, "openai")
assert result in ["gpt-4o"]
# Choose custom model via input
mocks.cli_confirm.return_value = 4 # for provider with >=4 models this would be alt; in our data openai has 3 -> alt index is 3
mocks.cli_text_input.return_value = "custom-model"
# Adjust to actual alt index produced by code (len(models[:4]) yields 3 + 1 alt -> index 3)
mocks.cli_confirm.return_value = 3
step_counter2 = StepCounter(1)
result2 = choose_llm_model(step_counter2, "openai")
assert result2 == "custom-model"
# -------------------------------
# API key validation and prompting
# -------------------------------
def test_api_key_validation_and_prompting(mock_cli_interactions: Any) -> None:
# Validator standalone
validator = NonEmptyValueValidator()
doc = MagicMock(); doc.text = "sk-abc"
validator.validate(doc)
doc_empty = MagicMock(); doc_empty.text = ""
with pytest.raises(ValidationError):
validator.validate(doc_empty)
# Prompting for new key enforces validator
from openhands_cli.tui.utils import StepCounter
mocks = mock_cli_interactions
mocks.cli_text_input.return_value = "sk-new"
step_counter = StepCounter(1)
new_key = prompt_api_key(step_counter, 'provider')
assert new_key == "sk-new"
assert mocks.cli_text_input.call_args[1]["validator"] is not None
# Prompting with existing key shows mask and no validator
mocks.cli_text_input.reset_mock()
mocks.cli_text_input.return_value = "sk-updated"
existing = SecretStr("sk-existing-123")
step_counter2 = StepCounter(1)
updated = prompt_api_key(step_counter2, 'provider', existing)
assert updated == "sk-updated"
assert mocks.cli_text_input.call_args[1]["validator"] is None
assert "sk-***" in mocks.cli_text_input.call_args[0][0]
@@ -1,133 +0,0 @@
import json
from unittest.mock import MagicMock, patch
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from pathlib import Path
from openhands.sdk import LLM, Conversation, LocalFileStore
from openhands.sdk.preset.default import get_default_agent
from openhands_cli.tui.settings.store import AgentStore
from openhands_cli.user_actions.settings_action import SettingsType
from pydantic import SecretStr
import pytest
def read_json(path: Path) -> dict:
with open(path, "r") as f:
return json.load(f)
def make_screen_with_conversation(model="openai/gpt-4o-mini", api_key="sk-xyz"):
llm = LLM(model=model, api_key=SecretStr(api_key), service_id="test-service")
# Conversation(agent) signature may vary across versions; adapt if needed:
from openhands.sdk.agent import Agent
agent = Agent(llm=llm, tools=[])
conv = Conversation(agent)
return SettingsScreen(conversation=conv)
def seed_file(path: Path, model: str = "openai/gpt-4o-mini", api_key: str = "sk-old"):
store = AgentStore()
store.file_store = LocalFileStore(root=str(path))
agent = get_default_agent(
llm=LLM(model=model, api_key=SecretStr(api_key), service_id="test-service"),
working_dir=str(path)
)
store.save(agent)
def test_llm_settings_save_and_load(tmp_path: Path):
"""Test that the settings screen can save basic LLM settings."""
screen = SettingsScreen(conversation=None)
# Mock the spec store to verify settings are saved
with patch.object(screen.agent_store, 'save') as mock_save:
screen._save_llm_settings(
model="openai/gpt-4o-mini",
api_key="sk-test-123"
)
# Verify that save was called
mock_save.assert_called_once()
# Get the agent spec that was saved
saved_spec = mock_save.call_args[0][0]
assert saved_spec.llm.model == "openai/gpt-4o-mini"
assert saved_spec.llm.api_key.get_secret_value() == "sk-test-123"
def test_first_time_setup_workflow(tmp_path: Path):
"""Test that the basic settings workflow completes without errors."""
screen = SettingsScreen()
with (
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", return_value=SettingsType.BASIC),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", return_value="openai"),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", return_value="gpt-4o-mini"),
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", return_value="sk-first"),
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", return_value=True),
):
# The workflow should complete without errors
screen.configure_settings()
# Since the current implementation doesn't save to file, we just verify the workflow completed
assert True # If we get here, the workflow completed successfully
def test_update_existing_settings_workflow(tmp_path: Path):
"""Test that the settings update workflow completes without errors."""
settings_path = tmp_path / "agent_settings.json"
seed_file(settings_path, model="openai/gpt-4o-mini", api_key="sk-old")
screen = make_screen_with_conversation(model="openai/gpt-4o-mini", api_key="sk-old")
with (
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", return_value=SettingsType.BASIC),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", return_value="anthropic"),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", return_value="claude-3-5-sonnet"),
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", return_value="sk-updated"),
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", return_value=True),
):
# The workflow should complete without errors
screen.configure_settings()
# Since the current implementation doesn't save to file, we just verify the workflow completed
assert True # If we get here, the workflow completed successfully
@pytest.mark.parametrize(
"step_to_cancel",
["type", "provider", "model", "apikey", "save"],
)
def test_workflow_cancellation_at_each_step(tmp_path: Path, step_to_cancel: str):
screen = make_screen_with_conversation()
# Base happy-path patches
patches = {
"settings_type_confirmation": MagicMock(return_value=SettingsType.BASIC),
"choose_llm_provider": MagicMock(return_value="openai"),
"choose_llm_model": MagicMock(return_value="gpt-4o-mini"),
"prompt_api_key": MagicMock(return_value="sk-new"),
"save_settings_confirmation": MagicMock(return_value=True),
}
# Turn one step into a cancel
if step_to_cancel == "type":
patches["settings_type_confirmation"].side_effect = KeyboardInterrupt()
elif step_to_cancel == "provider":
patches["choose_llm_provider"].side_effect = KeyboardInterrupt()
elif step_to_cancel == "model":
patches["choose_llm_model"].side_effect = KeyboardInterrupt()
elif step_to_cancel == "apikey":
patches["prompt_api_key"].side_effect = KeyboardInterrupt()
elif step_to_cancel == "save":
patches["save_settings_confirmation"].side_effect = KeyboardInterrupt()
with (
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", patches["settings_type_confirmation"]),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", patches["choose_llm_provider"]),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", patches["choose_llm_model"]),
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", patches["prompt_api_key"]),
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", patches["save_settings_confirmation"]),
patch.object(screen.agent_store, 'save') as mock_save,
):
screen.configure_settings()
# No settings should be saved on cancel
mock_save.assert_not_called()
-99
View File
@@ -1,99 +0,0 @@
"""Tests for TUI functionality."""
from prompt_toolkit.completion import CompleteEvent
from prompt_toolkit.document import Document
from openhands_cli.tui.tui import COMMANDS, CommandCompleter
class TestCommandCompleter:
"""Test the CommandCompleter class."""
def test_command_completion_with_slash(self) -> None:
"""Test that commands are completed when starting with /."""
completer = CommandCompleter()
document = Document('/')
completions = list(completer.get_completions(document, CompleteEvent()))
# Should return all available commands
assert len(completions) == len(COMMANDS)
# Check that all commands are included
completion_texts = [c.text for c in completions]
for command in COMMANDS.keys():
assert command in completion_texts
def test_command_completion_partial_match(self) -> None:
"""Test that partial command matches work correctly."""
completer = CommandCompleter()
document = Document('/ex')
completions = list(completer.get_completions(document, CompleteEvent()))
# Should return only /exit
assert len(completions) == 1
assert completions[0].text == '/exit'
# display_meta is a FormattedText object, so we need to check its content
# Extract the text from FormattedText
meta_text = completions[0].display_meta
if hasattr(meta_text, '_formatted_text'):
# Extract text from FormattedText
text_content = ''.join([item[1] for item in meta_text._formatted_text])
else:
text_content = str(meta_text)
assert COMMANDS['/exit'] in text_content
def test_command_completion_no_slash(self) -> None:
"""Test that no completions are returned without /."""
completer = CommandCompleter()
document = Document('help')
completions = list(completer.get_completions(document, CompleteEvent()))
# Should return no completions
assert len(completions) == 0
def test_command_completion_no_match(self) -> None:
"""Test that no completions are returned for non-matching commands."""
completer = CommandCompleter()
document = Document('/nonexistent')
completions = list(completer.get_completions(document, CompleteEvent()))
# Should return no completions
assert len(completions) == 0
def test_command_completion_styling(self) -> None:
"""Test that completions have proper styling."""
completer = CommandCompleter()
document = Document('/help')
completions = list(completer.get_completions(document, CompleteEvent()))
assert len(completions) == 1
completion = completions[0]
assert completion.style == 'bg:ansidarkgray fg:gold'
assert completion.start_position == -5 # Length of "/help"
def test_commands_dict() -> None:
"""Test that COMMANDS dictionary contains expected commands."""
expected_commands = {
'/exit',
'/help',
'/clear',
'/status',
'/confirm',
'/new',
'/resume',
'/settings',
}
assert set(COMMANDS.keys()) == expected_commands
# Check that all commands have descriptions
for command, description in COMMANDS.items():
assert isinstance(command, str)
assert command.startswith('/')
assert isinstance(description, str)
assert len(description) > 0
def test_clear_command_description() -> None:
"""Test that /clear command has the correct description."""
assert COMMANDS['/clear'] == "Start a new conversation from scratch"
-9
View File
@@ -1,9 +0,0 @@
import time
from prompt_toolkit.input import PipeInput
def _send_keys(pipe: PipeInput, text: str, delay: float = 0.05) -> None:
"""Helper: small delay then send keys to avoid race with app.run()."""
time.sleep(delay)
pipe.send_text(text)
-5412
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -204,3 +204,5 @@ lint.pydocstyle.convention = "google"
[tool.coverage.run]
concurrency = [ "gevent" ]
relative_files = true
omit = [ "enterprise/tests/*", "**/test_*" ]