Compare commits

...

24 Commits

Author SHA1 Message Date
rohitvinodmalhotra@gmail.com c34030b287 bump version 2025-11-11 15:54:37 -05:00
Rohit Malhotra 0a6b76ca2d CLI: bump agent-sdk (#11710)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-11 20:29:18 +00:00
Tim O'Farrell 8b6521de62 Fix for issue where conversation does not start (#11695) 2025-11-11 20:23:18 +00:00
mamoodi 11636edf15 Release 0.62.0 (#11706) 2025-11-11 14:57:13 -05:00
Hiep Le 915c180ba7 feat(frontend): disable change agent button while agent is running (#11691) 2025-11-12 00:46:12 +07:00
sp.wack cdd8aace86 refactor(frontend): migrate from direct posthog imports to usePostHog hook (#11703) 2025-11-11 15:48:56 +00:00
Hiep Le a2c312d108 feat(frontend): add plan preview component (#11676) 2025-11-11 21:59:23 +07:00
sp.wack 5ad3572810 chore(frontend): Remove user_activated PostHog capture event (#11704) 2025-11-11 14:35:04 +00:00
John Eismeier 967e9e1891 Propose fix some typos and ignore emacs backup files (#11701)
Signed-off-by: John E <jeis4wpi@outlook.com>
2025-11-11 09:20:42 -05:00
sp.wack f8a41d3ffe fix(frontend): Properly reflect default user analytics setting (#11702) 2025-11-11 18:19:37 +04:00
John-Mason P. Shackelford 6e9e7547e5 Add Documentation link to profile context menu (#11583)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-11 09:16:32 -05:00
Hiep Le 9b4f1c365b feat(frontend): add change agent button (#11675) 2025-11-11 20:28:48 +07:00
Engel Nyst f4dcc136d0 tests: remove Windows-only tests and clean up Windows conditionals (#11697) 2025-11-10 21:34:55 +01:00
Rohit Malhotra 36a8cbbfe4 Add GitHub CI workflow to check package versions (#11637)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-10 19:39:49 +00:00
Engel Nyst 83a3c2c5bf Add invisible AI-only guidance to Checklist: humans must fill (#11688) 2025-11-10 18:13:18 +00:00
Engel Nyst 63c9e6403f ci: remove flaky Windows Python tests workflow (#11694) 2025-11-10 12:43:48 -05:00
Hiep Le bff734070c feat(frontend): update data-placeholder when switching to plan mode (#11674) 2025-11-10 21:30:29 +04:00
mamoodi 5db6bffaf6 Add some notes to the README for things that are not officially suppo… (#11663) 2025-11-10 20:16:41 +04:00
Engel Nyst 14807ed273 ci: remove outdated integration runner (#11653) 2025-11-10 15:51:40 +01:00
Rohit Malhotra e0d26c1f4e CLI: custom visualizer (#11677) 2025-11-07 19:45:01 +00:00
Rohit Malhotra 27c8c330f4 CLI release 1.0.6 (#11672) 2025-11-07 14:10:04 -05:00
sp.wack 0c927b19d2 fix(frontend): agent loading condition update logic (#11673) 2025-11-07 18:04:27 +00:00
Hiep Le a660321d55 feat(frontend): display plan content within the planner tab (#11658) 2025-11-08 00:54:15 +07:00
Tim O'Farrell 0e94833d5b Now removing V1 sandboxes in the V0 endpoint (#11671) 2025-11-07 10:51:46 -07:00
88 changed files with 1571 additions and 2088 deletions
+1
View File
@@ -0,0 +1 @@
This way of running OpenHands is not officially supported. It is maintained by the community.
+1
View File
@@ -13,6 +13,7 @@
- [ ] Other (dependency update, docs, typo fixes, etc.)
## Checklist
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
- [ ] I have read and reviewed the code and I understand what the code is doing.
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
@@ -0,0 +1,65 @@
name: Check Package Versions
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check-package-versions:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check for any 'rev' fields in pyproject.toml
run: |
python - <<'PY'
import sys, tomllib, pathlib
path = pathlib.Path("pyproject.toml")
if not path.exists():
print("❌ ERROR: pyproject.toml not found")
sys.exit(1)
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except Exception as e:
print(f"❌ ERROR: Failed to parse pyproject.toml: {e}")
sys.exit(1)
poetry = data.get("tool", {}).get("poetry", {})
sections = {
"dependencies": poetry.get("dependencies", {}),
}
errors = []
print("🔍 Checking for any dependencies with 'rev' fields...\n")
for section_name, deps in sections.items():
if not isinstance(deps, dict):
continue
for pkg_name, cfg in deps.items():
if isinstance(cfg, dict) and "rev" in cfg:
msg = f" ✖ {pkg_name} in [{section_name}] uses rev='{cfg['rev']}' (NOT ALLOWED)"
print(msg)
errors.append(msg)
else:
print(f" • {pkg_name}: OK")
if errors:
print("\n❌ FAILED: Found dependencies using 'rev' fields:\n" + "\n".join(errors))
print("\nPlease use versioned releases instead, e.g.:")
print(' my-package = "1.0.0"')
sys.exit(1)
print("\n✅ SUCCESS: No 'rev' fields found. All dependencies are using proper versioned releases.")
PY
-199
View File
@@ -1,199 +0,0 @@
name: Run Integration Tests
on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual trigger'
required: true
default: ''
schedule:
- cron: '30 22 * * *' # Runs at 10:30pm UTC every day
env:
N_PROCESSES: 10 # Global configuration for number of parallel processes for evaluation
jobs:
run-integration-tests:
if: github.event.label.name == 'integration-test' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: "read"
id-token: "write"
pull-requests: "write"
issues: "write"
strategy:
matrix:
python-version: ["3.12"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Node.js
uses: useblacksmith/setup-node@v5
with:
node-version: '22.x'
- name: Comment on PR if 'integration-test' label is present
if: github.event_name == 'pull_request' && github.event.label.name == 'integration-test'
uses: KeisukeYamashita/create-comment@v1
with:
unique: false
comment: |
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime,evaluation
- name: Configure config.toml for testing with Haiku
env:
LLM_MODEL: "litellm_proxy/claude-3-5-haiku-20241022"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 10
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Build environment
run: make build
- name: Run integration test evaluation for Haiku
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'haiku_run'
# get integration tests report
REPORT_FILE_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/*haiku*_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE_HAIKU"
echo "INTEGRATION_TEST_REPORT_HAIKU<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_HAIKU >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Wait a little bit
run: sleep 10
- name: Configure config.toml for testing with DeepSeek
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 10
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DeepSeek
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'deepseek_run'
# get integration tests report
REPORT_FILE_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/deepseek*_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
- name: Wait a little bit (again)
run: sleep 5
- name: Configure config.toml for testing VisualBrowsingAgent (DeepSeek)
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 15
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for VisualBrowsingAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
# Find and export the visual browsing agent test results
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE_VISUALBROWSING_DEEPSEEK: $REPORT_FILE_VISUALBROWSING_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_VISUALBROWSING_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create archive of evaluation outputs
run: |
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
- name: Upload evaluation results as artifact
uses: actions/upload-artifact@v4
id: upload_results_artifact
with:
name: integration-test-outputs-${{ github.run_id }}-${{ github.run_attempt }}
path: integration_tests_*.tar.gz
- name: Get artifact URLs
run: |
echo "ARTIFACT_URL=${{ steps.upload_results_artifact.outputs.artifact-url }}" >> $GITHUB_ENV
- name: Set timestamp and trigger reason
run: |
echo "TIMESTAMP=$(date +'%Y-%m-%d-%H-%M')" >> $GITHUB_ENV
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "TRIGGER_REASON=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "TRIGGER_REASON=manual-${{ github.event.inputs.reason }}" >> $GITHUB_ENV
else
echo "TRIGGER_REASON=nightly-scheduled" >> $GITHUB_ENV
fi
- name: Comment with results and artifact link
id: create_comment
uses: KeisukeYamashita/create-comment@v1
with:
# if triggered by PR, use PR number, otherwise use 9745 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 9745 }}
unique: false
comment: |
Trigger by: ${{ github.event_name == 'pull_request' && format('Pull Request (integration-test label on PR #{0})', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && format('Manual Trigger: {0}', github.event.inputs.reason)) || 'Nightly Scheduled Run' }}
Commit: ${{ github.sha }}
**Integration Tests Report (Haiku)**
Haiku LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_HAIKU }}
---
**Integration Tests Report (DeepSeek)**
DeepSeek LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_DEEPSEEK }}
---
**Integration Tests Report VisualBrowsing (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}
---
Download testing outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})
+1 -31
View File
@@ -70,37 +70,7 @@ jobs:
.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
runs-on: windows-latest
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- name: Install pipx
run: pip install pipx
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/runtime//test_windows_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
DEBUG: "1"
- name: Run Windows runtime tests with LocalRuntime
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
TEST_RUNTIME: local
DEBUG: "1"
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
+3
View File
@@ -185,6 +185,9 @@ cython_debug/
.repomix
repomix-output.txt
# Emacs backup
*~
# evaluation
evaluation/evaluation_outputs
evaluation/outputs
+1 -1
View File
@@ -58,7 +58,7 @@ by implementing the [interface specified here](https://github.com/OpenHands/Open
#### Testing
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing test suites.
At the moment, we have two kinds of tests: [`unit`](./tests/unit) and [`integration`](./evaluation/integration_tests). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
## Sending Pull Requests to OpenHands
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.61-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -82,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.openhands.dev/openhands/runtime:0.61-nikolaik
docker pull docker.openhands.dev/openhands/runtime:0.62-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.62-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.openhands.dev/openhands/openhands:0.61
docker.openhands.dev/openhands/openhands:0.62
```
</details>
+1 -1
View File
@@ -1,7 +1,7 @@
# Develop in Docker
> [!WARNING]
> This is not officially supported and may not work.
> This way of running OpenHands is not officially supported. It is maintained by the community and may not work.
Install [Docker](https://docs.docker.com/engine/install/) on your host machine and run:
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.61-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.61-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+19 -34
View File
@@ -5820,13 +5820,15 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a5"
version = "1.1.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = []
develop = false
files = [
{file = "openhands_agent_server-1.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
{file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
]
[package.dependencies]
aiosqlite = ">=0.19"
@@ -5839,16 +5841,9 @@ uvicorn = ">=0.31.1"
websockets = ">=12"
wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-ai"
version = "0.0.0-post.5514+7c9e66194"
version = "0.0.0-post.5525+0b6631523"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5889,9 +5884,9 @@ memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-tools"}
openhands-agent-server = "1.1.0"
openhands-sdk = "1.1.0"
openhands-tools = "1.1.0"
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5947,13 +5942,15 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.0.0a5"
version = "1.1.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = []
develop = false
files = [
{file = "openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
{file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
]
[package.dependencies]
fastmcp = ">=2.11.3"
@@ -5969,22 +5966,17 @@ websockets = ">=12"
[package.extras]
boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.0.0a5"
version = "1.1.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = []
develop = false
files = [
{file = "openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
{file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
]
[package.dependencies]
bashlex = ">=0.18"
@@ -5996,13 +5988,6 @@ libtmux = ">=0.46.2"
openhands-sdk = "*"
pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-tools"
[[package]]
name = "openpyxl"
version = "3.1.5"
@@ -15,7 +15,7 @@ python evaluation/benchmarks/multi_swe_bench/scripts/data/data_change.py
## Docker image download
Please download the multi-swe-bench dokcer images from [here](https://github.com/multi-swe-bench/multi-swe-bench?tab=readme-ov-file#run-evaluation).
Please download the multi-swe-bench docker images from [here](https://github.com/multi-swe-bench/multi-swe-bench?tab=readme-ov-file#run-evaluation).
## Generate patch
@@ -47,7 +47,7 @@ For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=t
The results will be generated in evaluation/evaluation_outputs/outputs/XXX/CodeActAgent/YYY/output.jsonl, you can refer to the [example](examples/output.jsonl).
## Runing evaluation
## Running evaluation
First, install [multi-swe-bench](https://github.com/multi-swe-bench/multi-swe-bench).
-69
View File
@@ -1,69 +0,0 @@
# Integration tests
This directory implements integration tests that [was running in CI](https://github.com/OpenHands/OpenHands/tree/23d3becf1d6f5d07e592f7345750c314a826b4e9/tests/integration).
[PR 3985](https://github.com/OpenHands/OpenHands/pull/3985) introduce LLM-based editing, which requires access to LLM to perform edit. Hence, we remove integration tests from CI and intend to run them as nightly evaluation to ensure the quality of OpenHands softwares.
## To add new tests
Each test is a file named like `tXX_testname.py` where `XX` is a number.
Make sure to name the file for each test to start with `t` and ends with `.py`.
Each test should be structured as a subclass of [`BaseIntegrationTest`](./tests/base.py), where you need to implement `initialize_runtime` that setup the runtime enviornment before test, and `verify_result` that takes in a `Runtime` and history of `Event` and return a `TestResult`. See [t01_fix_simple_typo.py](./tests/t01_fix_simple_typo.py) and [t05_simple_browsing.py](./tests/t05_simple_browsing.py) for two representative examples.
```python
class TestResult(BaseModel):
success: bool
reason: str | None = None
class BaseIntegrationTest(ABC):
"""Base class for integration tests."""
INSTRUCTION: str
@classmethod
@abstractmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
"""Initialize the runtime for the test to run."""
pass
@classmethod
@abstractmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
"""Verify the result of the test.
This method will be called after the agent performs the task on the runtime.
"""
pass
```
## Setup Environment and LLM Configuration
Please follow instruction [here](../README.md#setup) to setup your local
development environment and LLM.
## Start the evaluation
```bash
./evaluation/integration_tests/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [eval-num-workers] [eval_ids]
```
- `model_config`, e.g. `eval_gpt4_1106_preview`, is the config group name for
your LLM settings, as defined in your `config.toml`.
- `git-version`, e.g. `HEAD`, is the git commit hash of the OpenHands version
you would like to evaluate. It could also be a release tag like `0.9.0`.
- `agent`, e.g. `CodeActAgent`, is the name of the agent for benchmarks,
defaulting to `CodeActAgent`.
- `eval_limit`, e.g. `10`, limits the evaluation to the first `eval_limit`
instances. By default, the script evaluates the entire Exercism test set
(133 issues). Note: in order to use `eval_limit`, you must also set `agent`.
- `eval-num-workers`: the number of workers to use for evaluation. Default: `1`.
- `eval_ids`, e.g. `"1,3,10"`, limits the evaluation to instances with the
given IDs (comma separated).
Example:
```bash
./evaluation/integration_tests/scripts/run_infer.sh llm.claude-35-sonnet-eval HEAD CodeActAgent
```
-251
View File
@@ -1,251 +0,0 @@
import asyncio
import importlib.util
import os
import pandas as pd
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
update_llm_config_for_completions_logging,
)
from evaluation.utils.shared import (
codeact_user_response as fake_user_response,
)
from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
OpenHandsConfig,
get_evaluation_parser,
get_llm_config_arg,
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_async_from_sync
FAKE_RESPONSES = {
'CodeActAgent': fake_user_response,
'VisualBrowsingAgent': fake_user_response,
}
def get_config(
metadata: EvalMetadata,
instance_id: str,
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.platform = 'linux/amd64'
config = get_openhands_config_for_eval(
metadata=metadata,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
)
config.debug = True
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config, metadata.eval_output_dir, instance_id
)
)
agent_config = AgentConfig(
enable_jupyter=True,
enable_browsing=True,
enable_llm_editor=False,
)
config.set_agent_config(agent_config)
return config
def process_instance(
instance: pd.Series,
metadata: EvalMetadata,
reset_logger: bool = True,
) -> EvalOutput:
config = get_config(metadata, instance.instance_id)
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
reset_logger_for_multiprocessing(logger, str(instance.instance_id), log_dir)
else:
logger.info(
f'\nStarting evaluation for instance {str(instance.instance_id)}.\n'
)
# =============================================
# import test instance
# =============================================
instance_id = instance.instance_id
spec = importlib.util.spec_from_file_location(instance_id, instance.file_path)
test_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(test_module)
assert hasattr(test_module, 'Test'), (
f'Test module {instance_id} does not have a Test class'
)
test_class: type[BaseIntegrationTest] = test_module.Test
assert issubclass(test_class, BaseIntegrationTest), (
f'Test class {instance_id} does not inherit from BaseIntegrationTest'
)
instruction = test_class.INSTRUCTION
# =============================================
# create sandbox and run the agent
# =============================================
runtime: Runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
try:
test_class.initialize_runtime(runtime)
# Here's how you can run the agent (similar to the `main` function) and get the final task state
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=MessageAction(content=instruction),
runtime=runtime,
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
)
)
if state is None:
raise ValueError('State should not be None.')
# # =============================================
# # result evaluation
# # =============================================
histories = state.history
# some basic check
logger.info(f'Total events in history: {len(histories)}')
assert len(histories) > 0, 'History should not be empty'
test_result: TestResult = test_class.verify_result(runtime, histories)
metrics = get_metrics(state)
finally:
runtime.close()
# Save the output
output = EvalOutput(
instance_id=str(instance.instance_id),
instance=instance.to_dict(),
instruction=instruction,
metadata=metadata,
history=[event_to_dict(event) for event in histories],
metrics=metrics,
error=state.last_error if state and state.last_error else None,
test_result=test_result.model_dump(),
)
return output
def load_integration_tests() -> pd.DataFrame:
"""Load tests from python files under ./tests"""
cur_dir = os.path.dirname(os.path.abspath(__file__))
test_dir = os.path.join(cur_dir, 'tests')
test_files = [
os.path.join(test_dir, f)
for f in os.listdir(test_dir)
if f.startswith('t') and f.endswith('.py')
]
df = pd.DataFrame(test_files, columns=['file_path'])
df['instance_id'] = df['file_path'].apply(
lambda x: os.path.basename(x).rstrip('.py')
)
return df
if __name__ == '__main__':
parser = get_evaluation_parser()
args, _ = parser.parse_known_args()
integration_tests = load_integration_tests()
llm_config = None
if args.llm_config:
llm_config = get_llm_config_arg(args.llm_config)
if llm_config is None:
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
metadata = make_metadata(
llm_config,
'integration_tests',
args.agent_cls,
args.max_iterations,
args.eval_note,
args.eval_output_dir,
)
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
# Parse dataset IDs if provided
eval_ids = None
if args.eval_ids:
eval_ids = str(args.eval_ids).split(',')
logger.info(f'\nUsing specific dataset IDs: {eval_ids}\n')
instances = prepare_dataset(
integration_tests,
output_file,
args.eval_n_limit,
eval_ids=eval_ids,
)
run_evaluation(
instances,
metadata,
output_file,
args.eval_num_workers,
process_instance,
)
df = pd.read_json(output_file, lines=True, orient='records')
# record success and reason
df['success'] = df['test_result'].apply(lambda x: x['success'])
df['reason'] = df['test_result'].apply(lambda x: x['reason'])
logger.info('-' * 100)
logger.info(
f'Success rate: {df["success"].mean():.2%} ({df["success"].sum()}/{len(df)})'
)
logger.info(
'\nEvaluation Results:'
+ '\n'
+ df[['instance_id', 'success', 'reason']].to_string(index=False)
)
logger.info('-' * 100)
# record cost for each instance, with 3 decimal places
# we sum up all the "costs" from the metrics array
df['cost'] = df['metrics'].apply(
lambda m: round(sum(c['cost'] for c in m['costs']), 3)
if m and 'costs' in m
else 0.0
)
# capture the top-level error if present, per instance
df['error_message'] = df.get('error', None)
logger.info(f'Total cost: USD {df["cost"].sum():.2f}')
report_file = os.path.join(metadata.eval_output_dir, 'report.md')
with open(report_file, 'w') as f:
f.write(
f'Success rate: {df["success"].mean():.2%}'
f' ({df["success"].sum()}/{len(df)})\n'
)
f.write(f'\nTotal cost: USD {df["cost"].sum():.2f}\n')
f.write(
df[
['instance_id', 'success', 'reason', 'cost', 'error_message']
].to_markdown(index=False)
)
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
MODEL_CONFIG=$1
COMMIT_HASH=$2
AGENT=$3
EVAL_LIMIT=$4
MAX_ITERATIONS=$5
NUM_WORKERS=$6
EVAL_IDS=$7
if [ -z "$NUM_WORKERS" ]; then
NUM_WORKERS=1
echo "Number of workers not specified, use default $NUM_WORKERS"
fi
checkout_eval_branch
if [ -z "$AGENT" ]; then
echo "Agent not specified, use default CodeActAgent"
AGENT="CodeActAgent"
fi
get_openhands_version
echo "AGENT: $AGENT"
echo "OPENHANDS_VERSION: $OPENHANDS_VERSION"
echo "MODEL_CONFIG: $MODEL_CONFIG"
EVAL_NOTE=$OPENHANDS_VERSION
# Default to NOT use unit tests.
if [ -z "$USE_UNIT_TESTS" ]; then
export USE_UNIT_TESTS=false
fi
echo "USE_UNIT_TESTS: $USE_UNIT_TESTS"
# If use unit tests, set EVAL_NOTE to the commit hash
if [ "$USE_UNIT_TESTS" = true ]; then
EVAL_NOTE=$EVAL_NOTE-w-test
fi
# export PYTHONPATH=evaluation/integration_tests:\$PYTHONPATH
COMMAND="poetry run python evaluation/integration_tests/run_infer.py \
--agent-cls $AGENT \
--llm-config $MODEL_CONFIG \
--max-iterations ${MAX_ITERATIONS:-10} \
--eval-num-workers $NUM_WORKERS \
--eval-note $EVAL_NOTE"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
if [ -n "$EVAL_IDS" ]; then
echo "EVAL_IDS: $EVAL_IDS"
COMMAND="$COMMAND --eval-ids $EVAL_IDS"
fi
# Run the command
eval $COMMAND
@@ -1,32 +0,0 @@
from abc import ABC, abstractmethod
from pydantic import BaseModel
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class TestResult(BaseModel):
success: bool
reason: str | None = None
class BaseIntegrationTest(ABC):
"""Base class for integration tests."""
INSTRUCTION: str
@classmethod
@abstractmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
"""Initialize the runtime for the test to run."""
pass
@classmethod
@abstractmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
"""Verify the result of the test.
This method will be called after the agent performs the task on the runtime.
"""
pass
@@ -1,39 +0,0 @@
import os
import tempfile
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from openhands.events.action import CmdRunAction
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = 'Fix typos in bad.txt.'
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
# create a file with a typo in /workspace/bad.txt
with tempfile.TemporaryDirectory() as temp_dir:
temp_file_path = os.path.join(temp_dir, 'bad.txt')
with open(temp_file_path, 'w') as f:
f.write('This is a stupid typoo.\nReally?\nNo mor typos!\nEnjoy!')
# Copy the file to the desired location
runtime.copy_to(temp_file_path, '/workspace')
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
# check if the file /workspace/bad.txt has been fixed
action = CmdRunAction(command='cat /workspace/bad.txt')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False, reason=f'Failed to run command: {obs.content}'
)
# check if the file /workspace/bad.txt has been fixed
if (
obs.content.strip().replace('\r\n', '\n')
== 'This is a stupid typo.\nReally?\nNo more typos!\nEnjoy!'
):
return TestResult(success=True)
return TestResult(success=False, reason=f'File not fixed: {obs.content}')
@@ -1,40 +0,0 @@
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import assert_and_raise
from openhands.events.action import CmdRunAction
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = "Write a shell script '/workspace/hello.sh' that prints 'hello'."
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
action = CmdRunAction(command='mkdir -p /workspace')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
# check if the file /workspace/hello.sh exists
action = CmdRunAction(command='cat /workspace/hello.sh')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/hello.sh: {obs.content}.',
)
# execute the script
action = CmdRunAction(command='bash /workspace/hello.sh')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to execute /workspace/hello.sh: {obs.content}.',
)
if obs.content.strip() != 'hello':
return TestResult(
success=False, reason=f'Script did not print "hello": {obs.content}.'
)
return TestResult(success=True)
@@ -1,43 +0,0 @@
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import assert_and_raise
from openhands.events.action import CmdRunAction
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = "Use Jupyter IPython to write a text file containing 'hello world' to '/workspace/test.txt'."
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
action = CmdRunAction(command='mkdir -p /workspace')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
# check if the file /workspace/hello.sh exists
action = CmdRunAction(command='cat /workspace/test.txt')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/test.txt: {obs.content}.',
)
# execute the script
action = CmdRunAction(command='cat /workspace/test.txt')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/test.txt: {obs.content}.',
)
if 'hello world' not in obs.content.strip():
return TestResult(
success=False,
reason=f'File did not contain "hello world": {obs.content}.',
)
return TestResult(success=True)
@@ -1,57 +0,0 @@
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import assert_and_raise
from openhands.events.action import CmdRunAction
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = 'Write a git commit message for the current staging area and commit the changes.'
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
action = CmdRunAction(command='mkdir -p /workspace')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# git init
action = CmdRunAction(command='git init')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# create file
action = CmdRunAction(command='echo \'print("hello world")\' > hello.py')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# git add
cmd_str = 'git add hello.py'
action = CmdRunAction(command=cmd_str)
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
# check if the file /workspace/hello.py exists
action = CmdRunAction(command='cat /workspace/hello.py')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/hello.py: {obs.content}.',
)
# check if the staging area is empty
action = CmdRunAction(command='git status')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False, reason=f'Failed to git status: {obs.content}.'
)
if 'nothing to commit, working tree clean' in obs.content.strip():
return TestResult(success=True)
return TestResult(
success=False,
reason=f'Failed to check for "nothing to commit, working tree clean": {obs.content}.',
)
@@ -1,145 +0,0 @@
import os
import tempfile
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import assert_and_raise
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
from openhands.events.event import Event
from openhands.events.observation import AgentDelegateObservation
from openhands.runtime.base import Runtime
HTML_FILE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Ultimate Answer</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(to right, #1e3c72, #2a5298);
color: #fff;
font-family: 'Arial', sans-serif;
text-align: center;
}
.container {
text-align: center;
padding: 20px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}
h1 {
font-size: 36px;
margin-bottom: 20px;
}
p {
font-size: 18px;
margin-bottom: 30px;
}
#showButton {
padding: 10px 20px;
font-size: 16px;
color: #1e3c72;
background: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.3s ease;
}
#showButton:hover {
background: #f0f0f0;
}
#result {
margin-top: 20px;
font-size: 24px;
}
</style>
</head>
<body>
<div class="container">
<h1>The Ultimate Answer</h1>
<p>Click the button to reveal the answer to life, the universe, and everything.</p>
<button id="showButton">Click me</button>
<div id="result"></div>
</div>
<script>
document.getElementById('showButton').addEventListener('click', function() {
document.getElementById('result').innerText = 'The answer is OpenHands is all you need!';
});
</script>
</body>
</html>
"""
class Test(BaseIntegrationTest):
INSTRUCTION = 'Browse localhost:8000, and tell me the ultimate answer to life.'
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
action = CmdRunAction(command='mkdir -p /workspace')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
action = CmdRunAction(command='mkdir -p /tmp/server')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# create a file with a typo in /workspace/bad.txt
with tempfile.TemporaryDirectory() as temp_dir:
temp_file_path = os.path.join(temp_dir, 'index.html')
with open(temp_file_path, 'w') as f:
f.write(HTML_FILE)
# Copy the file to the desired location
runtime.copy_to(temp_file_path, '/tmp/server')
# create README.md
action = CmdRunAction(
command='cd /tmp/server && nohup python3 -m http.server 8000 &'
)
obs = runtime.run_action(action)
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
from openhands.core.logger import openhands_logger as logger
# check if the "The answer is OpenHands is all you need!" is in any message
message_actions = [
event
for event in histories
if isinstance(
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
)
]
logger.debug(f'Total message-like events: {len(message_actions)}')
for event in message_actions:
try:
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
elif isinstance(event, MessageAction):
content = event.content
else:
logger.warning(f'Unexpected event type: {type(event)}')
continue
if 'OpenHands is all you need!' in content:
return TestResult(success=True)
except Exception as e:
logger.error(f'Error processing event: {e}')
logger.debug(
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
)
return TestResult(
success=False,
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
)
@@ -1,58 +0,0 @@
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from openhands.events.action import AgentFinishAction, MessageAction
from openhands.events.event import Event
from openhands.events.observation import AgentDelegateObservation
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = 'Look at https://github.com/OpenHands/OpenHands/pull/8, and tell me what is happening there and what did @asadm suggest.'
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
pass
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
from openhands.core.logger import openhands_logger as logger
# check if the license information is in any message
message_actions = [
event
for event in histories
if isinstance(
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
)
]
logger.info(f'Total message-like events: {len(message_actions)}')
for event in message_actions:
try:
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
if event.thought:
content += f'\n\n{event.thought}'
elif isinstance(event, MessageAction):
content = event.content
else:
logger.warning(f'Unexpected event type: {type(event)}')
continue
if (
'non-commercial' in content
or 'MIT' in content
or 'Apache 2.0' in content
):
return TestResult(success=True)
except Exception as e:
logger.error(f'Error processing event: {e}')
logger.debug(
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
)
return TestResult(
success=False,
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
)
@@ -1,73 +0,0 @@
import hashlib
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from openhands.events.action import (
AgentFinishAction,
FileWriteAction,
MessageAction,
)
from openhands.events.event import Event
from openhands.events.observation import AgentDelegateObservation
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = 'Execute the python script /workspace/python_script.py with input "John" and "25" and tell me the secret number.'
SECRET_NUMBER = int(hashlib.sha256(str(25).encode()).hexdigest()[:8], 16) % 1000
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
from openhands.core.logger import openhands_logger as logger
action = FileWriteAction(
path='/workspace/python_script.py',
content=(
'name = input("Enter your name: "); age = input("Enter your age: "); '
'import hashlib; secret = int(hashlib.sha256(str(age).encode()).hexdigest()[:8], 16) % 1000; '
'print(f"Hello {name}, you are {age} years old. Tell you a secret number: {secret}")'
),
)
logger.info(action, extra={'msg_type': 'ACTION'})
observation = runtime.run_action(action)
logger.info(observation, extra={'msg_type': 'OBSERVATION'})
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
from openhands.core.logger import openhands_logger as logger
# check if the license information is in any message
message_actions = [
event
for event in histories
if isinstance(
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
)
]
logger.info(f'Total message-like events: {len(message_actions)}')
for event in message_actions:
try:
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
if event.thought:
content += f'\n\n{event.thought}'
elif isinstance(event, MessageAction):
content = event.content
else:
logger.warning(f'Unexpected event type: {type(event)}')
continue
if str(cls.SECRET_NUMBER) in content:
return TestResult(success=True)
except Exception as e:
logger.error(f'Error processing event: {e}')
logger.debug(
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
)
return TestResult(
success=False,
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
)
@@ -33,9 +33,24 @@ describe("AccountSettingsContextMenu", () => {
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
});
it("should render Documentation link with correct attributes", () => {
renderWithRouter(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a");
expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev");
expect(documentationLink).toHaveAttribute("target", "_blank");
expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should call onLogout when the logout option is clicked", async () => {
renderWithRouter(
<AccountSettingsContextMenu
@@ -30,7 +30,7 @@ describe("ImagePreview", () => {
expect(onRemoveMock).toHaveBeenCalledOnce();
});
it("shoud not display the close button when onRemove is not provided", () => {
it("should not display the close button when onRemove is not provided", () => {
render(<ImagePreview src="https://example.com/image.jpg" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
+12 -6
View File
@@ -1,10 +1,9 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AcceptTOS from "#/routes/accept-tos";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { openHands } from "#/api/open-hands-axios";
// Mock the react-router hooks
@@ -44,9 +43,13 @@ const createWrapper = () => {
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return Wrapper;
};
describe("AcceptTOS", () => {
@@ -106,7 +109,10 @@ describe("AcceptTOS", () => {
// Wait for the mutation to complete
await new Promise(process.nextTick);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(
expect.anything(),
true,
);
expect(openHands.post).toHaveBeenCalledWith("/api/accept_tos", {
redirect_url: "/dashboard",
});
@@ -46,6 +46,21 @@ describe("Content", () => {
});
});
it("should render analytics toggle as enabled when server returns null (opt-in by default)", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: null,
});
renderAppSettingsScreen();
await waitFor(() => {
const analytics = screen.getByTestId("enable-analytics-switch");
expect(analytics).toBeChecked();
});
});
it("should render the language options", async () => {
renderAppSettingsScreen();
@@ -163,7 +178,10 @@ describe("Form submission", () => {
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(
expect.anything(),
true,
),
);
});
@@ -188,7 +206,10 @@ describe("Form submission", () => {
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(
expect.anything(),
false,
),
);
});
@@ -32,6 +32,7 @@ describe("Error Handler", () => {
const error = {
message: "Test error",
source: "test",
posthog,
};
trackError(error);
@@ -52,6 +53,7 @@ describe("Error Handler", () => {
extra: "info",
details: { foo: "bar" },
},
posthog,
};
trackError(error);
@@ -73,6 +75,7 @@ describe("Error Handler", () => {
const error = {
message: "Toast error",
source: "toast-test",
posthog,
};
showErrorToast(error);
@@ -94,6 +97,7 @@ describe("Error Handler", () => {
message: "Toast error",
source: "toast-test",
metadata: { context: "testing" },
posthog,
};
showErrorToast(error);
@@ -113,6 +117,7 @@ describe("Error Handler", () => {
message: "Agent error",
source: "agent-status",
metadata: { id: "error.agent" },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -127,6 +132,7 @@ describe("Error Handler", () => {
message: "Server error",
source: "server",
metadata: { error_code: 500, details: "Internal error" },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -145,6 +151,7 @@ describe("Error Handler", () => {
message: error.message,
source: "feedback",
metadata: { conversationId: "123", error },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -164,6 +171,7 @@ describe("Error Handler", () => {
message: "Chat error",
source: "chat-test",
msgId: "123",
posthog,
};
showChatError(error);
@@ -13,14 +13,14 @@ describe("handleCaptureConsent", () => {
});
it("should opt out of of capturing", () => {
handleCaptureConsent(false);
handleCaptureConsent(posthog, false);
expect(optOutSpy).toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
});
it("should opt in to capturing if the user consents", () => {
handleCaptureConsent(true);
handleCaptureConsent(posthog, true);
expect(optInSpy).toHaveBeenCalled();
expect(optOutSpy).not.toHaveBeenCalled();
@@ -28,7 +28,7 @@ describe("handleCaptureConsent", () => {
it("should not opt in to capturing if the user is already opted in", () => {
hasOptedInSpy.mockReturnValueOnce(true);
handleCaptureConsent(true);
handleCaptureConsent(posthog, true);
expect(optInSpy).not.toHaveBeenCalled();
expect(optOutSpy).not.toHaveBeenCalled();
@@ -36,7 +36,7 @@ describe("handleCaptureConsent", () => {
it("should not opt out of capturing if the user is already opted out", () => {
hasOptedOutSpy.mockReturnValueOnce(true);
handleCaptureConsent(false);
handleCaptureConsent(posthog, false);
expect(optOutSpy).not.toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
+32 -28
View File
@@ -1,17 +1,18 @@
{
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.0",
"dependencies": {
"@heroui/react": "^2.8.4",
"@heroui/use-infinite-scroll": "^2.2.11",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@posthog/react": "^1.4.0",
"@react-router/node": "^7.9.3",
"@react-router/serve": "^7.9.3",
"@react-types/shared": "^3.32.0",
@@ -38,7 +39,7 @@
"jose": "^6.1.0",
"lucide-react": "^0.544.0",
"monaco-editor": "^0.53.0",
"posthog-js": "^1.268.8",
"posthog-js": "^1.290.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -3511,9 +3512,29 @@
"license": "MIT"
},
"node_modules/@posthog/core": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.2.tgz",
"integrity": "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg=="
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.5.2.tgz",
"integrity": "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6"
}
},
"node_modules/@posthog/react": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.4.0.tgz",
"integrity": "sha512-xzPeZ753fQ0deZzdgY/0YavZvNpmdaxUzLYJYu5XjONNcZ8PwJnNLEK+7D/Cj8UM4Q8nWI7QC5mjum0uLWa4FA==",
"license": "MIT",
"peerDependencies": {
"@types/react": ">=16.8.0",
"posthog-js": ">=1.257.2",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@react-aria/breadcrumbs": {
"version": "3.5.28",
@@ -8183,7 +8204,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -8198,7 +8218,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -11403,7 +11422,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -14073,7 +14091,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -14264,27 +14281,16 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.268.8",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.268.8.tgz",
"integrity": "sha512-BJiKK4MlUvs7ybnQcy1KkwAz+SZkE/wRLotetIoank5kbqZs8FLbeyozFvmmgx4aoMmaVymYBSmYphYjYQeidw==",
"version": "1.290.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.290.0.tgz",
"integrity": "sha512-zavBwZkf+3JeiSDVE7ZDXBfzva/iOljicdhdJH+cZoqp0LsxjKxjnNhGOd3KpAhw0wqdwjhd7Lp1aJuI7DXyaw==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@posthog/core": "1.2.2",
"@posthog/core": "1.5.2",
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
}
},
"node_modules/posthog-js/node_modules/web-vitals": {
@@ -15547,7 +15553,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -15560,7 +15565,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.0",
"private": true,
"type": "module",
"engines": {
@@ -11,6 +11,7 @@
"@heroui/use-infinite-scroll": "^2.2.11",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@posthog/react": "^1.4.0",
"@react-router/node": "^7.9.3",
"@react-router/serve": "^7.9.3",
"@react-types/shared": "^3.32.0",
@@ -37,7 +38,7 @@
"jose": "^6.1.0",
"lucide-react": "^0.544.0",
"monaco-editor": "^0.53.0",
"posthog-js": "^1.268.8",
"posthog-js": "^1.290.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { usePostHog } from "posthog-js/react";
import {
BaseModalTitle,
BaseModalDescription,
@@ -17,6 +18,7 @@ interface AnalyticsConsentFormModalProps {
export function AnalyticsConsentFormModal({
onClose,
}: AnalyticsConsentFormModalProps) {
const posthog = usePostHog();
const { t } = useTranslation();
const { mutate: saveUserSettings } = useSaveSettings();
@@ -29,7 +31,7 @@ export function AnalyticsConsentFormModal({
{ user_consents_to_analytics: analytics },
{
onSuccess: () => {
handleCaptureConsent(analytics);
handleCaptureConsent(posthog, analytics);
onClose();
},
},
@@ -0,0 +1,109 @@
import React, { useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import CodeTagIcon from "#/icons/code-tag.svg?react";
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { useConversationStore } from "#/state/conversation-store";
import { ChangeAgentContextMenu } from "./change-agent-context-menu";
import { cn } from "#/utils/utils";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
export function ChangeAgentButton() {
const { t } = useTranslation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const conversationMode = useConversationStore(
(state) => state.conversationMode,
);
const setConversationMode = useConversationStore(
(state) => state.setConversationMode,
);
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const { curAgentState } = useAgentState();
const isAgentRunning = curAgentState === AgentState.RUNNING;
// Close context menu when agent starts running
useEffect(() => {
if (isAgentRunning && contextMenuOpen) {
setContextMenuOpen(false);
}
}, [isAgentRunning, contextMenuOpen]);
const handleButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setContextMenuOpen(!contextMenuOpen);
};
const handleCodeClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setConversationMode("code");
};
const handlePlanClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setConversationMode("plan");
};
const isExecutionAgent = conversationMode === "code";
const buttonLabel = useMemo(() => {
if (isExecutionAgent) {
return t(I18nKey.COMMON$CODE);
}
return t(I18nKey.COMMON$PLAN);
}, [isExecutionAgent, t]);
const buttonIcon = useMemo(() => {
if (isExecutionAgent) {
return <CodeTagIcon width={18} height={18} color="#737373" />;
}
return <LessonPlanIcon width={18} height={18} color="#ffffff" />;
}, [isExecutionAgent]);
if (!shouldUsePlanningAgent) {
return null;
}
return (
<div className="relative">
<button
type="button"
onClick={handleButtonClick}
disabled={isAgentRunning}
className={cn(
"flex items-center border border-[#4B505F] rounded-[100px] transition-opacity",
!isExecutionAgent && "border-[#597FF4] bg-[#4A67BD]",
isAgentRunning
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:opacity-80",
)}
>
<div className="flex items-center gap-1 pl-1.5">
{buttonIcon}
<Typography.Text className="text-white text-2.75 not-italic font-normal leading-5">
{buttonLabel}
</Typography.Text>
</div>
<ChevronDownSmallIcon width={24} height={24} color="#ffffff" />
</button>
{contextMenuOpen && (
<ChangeAgentContextMenu
onClose={() => setContextMenuOpen(false)}
onCodeClick={handleCodeClick}
onPlanClick={handlePlanClick}
/>
)}
</div>
);
}
@@ -0,0 +1,81 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import CodeTagIcon from "#/icons/code-tag.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { CONTEXT_MENU_ICON_TEXT_CLASSNAME } from "#/utils/constants";
const contextMenuListItemClassName = cn(
"cursor-pointer p-0 h-auto hover:bg-transparent",
CONTEXT_MENU_ICON_TEXT_CLASSNAME,
);
const contextMenuIconTextClassName =
"gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]";
interface ChangeAgentContextMenuProps {
onClose: () => void;
onCodeClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onPlanClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
export function ChangeAgentContextMenu({
onClose,
onCodeClick,
onPlanClick,
}: ChangeAgentContextMenuProps) {
const { t } = useTranslation();
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
const handleCodeClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onCodeClick?.(event);
onClose();
};
const handlePlanClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onPlanClick?.(event);
onClose();
};
return (
<ContextMenu
ref={menuRef}
testId="change-agent-context-menu"
position="top"
alignment="left"
className="min-h-fit min-w-[195px] mb-2"
>
<ContextMenuListItem
testId="code-option"
onClick={handleCodeClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
icon={CodeTagIcon}
text={t(I18nKey.COMMON$CODE)}
className={contextMenuIconTextClassName}
/>
</ContextMenuListItem>
<ContextMenuListItem
testId="plan-option"
onClick={handlePlanClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
icon={LessonPlanIcon}
text={t(I18nKey.COMMON$PLAN)}
className={contextMenuIconTextClassName}
/>
</ContextMenuListItem>
</ContextMenu>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
@@ -60,6 +60,7 @@ function getEntryPoint(
}
export function ChatInterface() {
const posthog = usePostHog();
const { setMessageToSend } = useConversationStore();
const { data: conversation } = useActiveConversation();
const { errorMessage } = useErrorMessageStore();
@@ -8,6 +8,7 @@ import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { AgentState } from "#/types/agent-state";
import { useV1PauseConversation } from "#/hooks/mutation/use-v1-pause-conversation";
import { useV1ResumeConversation } from "#/hooks/mutation/use-v1-resume-conversation";
import { ChangeAgentButton } from "../change-agent-button";
interface ChatInputActionsProps {
disabled: boolean;
@@ -56,7 +57,10 @@ export function ChatInputActions({
return (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<div className="flex items-center gap-4">
<Tools />
<ChangeAgentButton />
</div>
</div>
<AgentStatus
className="ml-2 md:ml-3"
@@ -1,5 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useConversationStore } from "#/state/conversation-store";
interface ChatInputFieldProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
@@ -20,6 +22,12 @@ export function ChatInputField({
}: ChatInputFieldProps) {
const { t } = useTranslation();
const conversationMode = useConversationStore(
(state) => state.conversationMode,
);
const isPlanMode = conversationMode === "plan";
return (
<div
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
@@ -30,7 +38,11 @@ export function ChatInputField({
ref={chatInputRef}
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
contentEditable
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
data-placeholder={
isPlanMode
? t(I18nKey.COMMON$LET_S_WORK_ON_A_PLAN)
: t(I18nKey.SUGGESTIONS$WHAT_TO_BUILD)
}
data-testid="chat-input"
onInput={onInput}
onPaste={onPaste}
@@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next";
import { ArrowUpRight } from "lucide-react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
interface PlanPreviewProps {
title?: string;
description?: string;
onViewClick?: () => void;
onBuildClick?: () => void;
}
// TODO: Remove the hardcoded values and use the plan content from the conversation store
/* eslint-disable i18next/no-literal-string */
export function PlanPreview({
title = "Improve Developer Onboarding and Examples",
description = "Based on the analysis of Browser-Use's current documentation and examples, this plan addresses gaps in developer onboarding by creating a progressive learning path, troubleshooting resources, and practical examples that address real-world scenarios (like the LM Studio/local LLM integration issues encountered...",
onViewClick,
onBuildClick,
}: PlanPreviewProps) {
const { t } = useTranslation();
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
if (!shouldUsePlanningAgent) {
return null;
}
return (
<div className="bg-[#25272d] border border-[#597FF4] rounded-[12px] w-full mb-4 mt-2">
{/* Header */}
<div className="border-b border-[#525252] flex h-[41px] items-center px-2 gap-1">
<LessonPlanIcon width={18} height={18} color="#9299aa" />
<Typography.Text className="font-medium text-[11px] text-white tracking-[0.11px] leading-4">
{t(I18nKey.COMMON$PLAN_MD)}
</Typography.Text>
<div className="flex-1" />
<button
type="button"
onClick={onViewClick}
className="flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<Typography.Text className="font-medium text-[11px] text-white tracking-[0.11px] leading-4">
{t(I18nKey.COMMON$VIEW)}
</Typography.Text>
<ArrowUpRight className="text-white" size={18} />
</button>
</div>
{/* Content */}
<div className="flex flex-col gap-[10px] p-4">
<h3 className="font-bold text-[19px] text-white leading-[29px]">
{title}
</h3>
<p className="text-[15px] text-white leading-[29px]">
{description}
<Typography.Text className="text-[#4a67bd] cursor-pointer hover:underline ml-1">
{t(I18nKey.COMMON$READ_MORE)}
</Typography.Text>
</p>
</div>
{/* Footer */}
<div className="border-t border-[#525252] flex h-[54px] items-center justify-start px-4">
<button
type="button"
onClick={onBuildClick}
className="bg-white flex items-center justify-center h-[26px] px-2 rounded-[4px] w-[93px] hover:opacity-90 transition-opacity cursor-pointer"
>
<Typography.Text className="font-medium text-[14px] text-black leading-5">
{t(I18nKey.COMMON$BUILD)}{" "}
<Typography.Text className="font-medium text-black">
</Typography.Text>
</Typography.Text>
</button>
</div>
</div>
);
}
@@ -8,6 +8,7 @@ import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import LogOutIcon from "#/icons/log-out.svg?react";
import DocumentIcon from "#/icons/document.svg?react";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
interface AccountSettingsContextMenuProps {
@@ -58,6 +59,21 @@ export function AccountSettingsContextMenu({
<Divider />
<a
href="https://docs.openhands.dev"
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none"
>
<ContextMenuListItem
onClick={onClose}
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
>
<DocumentIcon width={16} height={16} />
<span className="text-white text-sm">{t(I18nKey.SIDEBAR$DOCS)}</span>
</ContextMenuListItem>
</a>
<ContextMenuListItem
onClick={onLogout}
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
@@ -70,8 +70,7 @@ export function AgentStatus({
// Update global state when agent loading condition changes
useEffect(() => {
if (shouldShownAgentLoading)
setShouldShownAgentLoading(shouldShownAgentLoading);
setShouldShownAgentLoading(!!shouldShownAgentLoading);
}, [shouldShownAgentLoading, setShouldShownAgentLoading]);
return (
@@ -1,5 +1,5 @@
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { cn } from "#/utils/utils";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import ConversationService from "#/api/conversation-service/conversation-service.api";
@@ -44,6 +44,7 @@ export function ConversationCard({
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const posthog = usePostHog();
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const onTitleSave = (newTitle: string) => {
@@ -0,0 +1,80 @@
import React from "react";
import { ExtraProps } from "react-markdown";
// Custom component to render <h1> in markdown
export function h1({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h1 className="text-[32px] text-white font-bold leading-8 mb-4 mt-6 first:mt-0">
{children}
</h1>
);
}
// Custom component to render <h2> in markdown
export function h2({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h2 className="text-xl font-semibold leading-6 -tracking-[0.02em] text-white mb-3 mt-5 first:mt-0">
{children}
</h2>
);
}
// Custom component to render <h3> in markdown
export function h3({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h3 className="text-lg font-semibold text-white mb-2 mt-4 first:mt-0">
{children}
</h3>
);
}
// Custom component to render <h4> in markdown
export function h4({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h4 className="text-base font-semibold text-white mb-2 mt-4 first:mt-0">
{children}
</h4>
);
}
// Custom component to render <h5> in markdown
export function h5({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h5 className="text-sm font-semibold text-white mb-2 mt-3 first:mt-0">
{children}
</h5>
);
}
// Custom component to render <h6> in markdown
export function h6({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h6 className="text-sm font-medium text-gray-300 mb-2 mt-3 first:mt-0">
{children}
</h6>
);
}
@@ -1,7 +1,7 @@
import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { I18nKey } from "#/i18n/declaration";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { DangerModal } from "../confirmation-modals/danger-modal";
@@ -22,6 +22,7 @@ interface SettingsFormProps {
}
export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
const posthog = usePostHog();
const { mutate: saveUserSettings } = useSaveSettings();
const location = useLocation();
@@ -49,6 +49,10 @@ const getExecuteBashObservationContent = (
let { output } = observation;
if (!output) {
output = "";
}
if (output.length > MAX_CONTENT_LENGTH) {
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
}
@@ -136,6 +140,7 @@ const getTaskTrackerObservationContent = (
if (
"content" in observation &&
observation.content &&
typeof observation.content === "string" &&
observation.content.trim()
) {
content += `\n\n**Result:** ${observation.content.trim()}`;
+11 -3
View File
@@ -1,6 +1,7 @@
import React from "react";
import { io, Socket } from "socket.io-client";
import { useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import EventLogger from "#/utils/event-logger";
import { handleAssistantMessage } from "#/services/actions";
import { showChatError, trackError } from "#/utils/error-handler";
@@ -100,7 +101,10 @@ interface ErrorArgData {
msg_id: string;
}
export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
export function updateStatusWhenErrorMessagePresent(
data: ErrorArg | unknown,
posthog?: ReturnType<typeof usePostHog>,
) {
const isObject = (val: unknown): val is object =>
!!val && typeof val === "object";
const isString = (val: unknown): val is string => typeof val === "string";
@@ -123,6 +127,7 @@ export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
source: "websocket",
metadata,
msgId,
posthog,
});
}
}
@@ -131,6 +136,7 @@ export function WsClientProvider({
conversationId,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const posthog = usePostHog();
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
const { addEvent, clearEvents } = useEventStore();
@@ -178,6 +184,7 @@ export function WsClientProvider({
message: errorMessage,
source: "chat",
metadata: { msgId: event.id },
posthog,
});
setErrorMessage(errorMessage);
@@ -193,6 +200,7 @@ export function WsClientProvider({
message: event.message,
source: "chat",
metadata: { msgId: event.id },
posthog,
});
} else {
removeErrorMessage();
@@ -260,14 +268,14 @@ export function WsClientProvider({
sio.io.opts.query = sio.io.opts.query || {};
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data);
updateStatusWhenErrorMessagePresent(data, posthog);
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
}
function handleError(data: unknown) {
// set status
setWebSocketStatus("DISCONNECTED");
updateStatusWhenErrorMessagePresent(data);
updateStatusWhenErrorMessagePresent(data, posthog);
setErrorMessage(
hasValidMessageProperty(data)
+21 -13
View File
@@ -8,17 +8,18 @@
import { HydratedRouter } from "react-router/dom";
import React, { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import "./i18n";
import { QueryClientProvider } from "@tanstack/react-query";
import OptionService from "./api/option-service/option-service.api";
import { displayErrorToast } from "./utils/custom-toast-handlers";
import { queryClient } from "./query-client-config";
function PosthogInit() {
function PostHogWrapper({ children }: { children: React.ReactNode }) {
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
null,
);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
(async () => {
@@ -27,20 +28,27 @@ function PosthogInit() {
setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
} catch {
displayErrorToast("Error fetching PostHog client key");
} finally {
setIsLoading(false);
}
})();
}, []);
React.useEffect(() => {
if (posthogClientKey) {
posthog.init(posthogClientKey, {
if (isLoading || !posthogClientKey) {
return children;
}
return (
<PostHogProvider
apiKey={posthogClientKey}
options={{
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only",
});
}
}, [posthogClientKey]);
return null;
}}
>
{children}
</PostHogProvider>
);
}
async function prepareApp() {
@@ -62,10 +70,10 @@ prepareApp().then(() =>
document,
<StrictMode>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
<PostHogWrapper>
<HydratedRouter />
</PostHogWrapper>
</QueryClientProvider>
<div id="modal-portal-exit" />
</StrictMode>,
);
}),
+2 -1
View File
@@ -1,10 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import AuthService from "#/api/auth-service/auth-service.api";
import { useConfig } from "../query/use-config";
import { clearLoginData } from "#/utils/local-storage";
export const useLogout = () => {
const posthog = usePostHog();
const queryClient = useQueryClient();
const { data: config } = useConfig();
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/settings-service/settings-service.api";
import { PostSettings } from "#/types/settings";
@@ -41,6 +41,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
};
export const useSaveSettings = () => {
const posthog = usePostHog();
const queryClient = useQueryClient();
const { data: currentSettings } = useSettings();
+2 -1
View File
@@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useConfig } from "./use-config";
import UserService from "#/api/user-service/user-service.api";
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
export const useGitUser = () => {
const posthog = usePostHog();
const { data: config } = useConfig();
// Use the shared hook to determine if we should fetch user data
-8
View File
@@ -1,6 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import SettingsService from "#/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
@@ -61,12 +59,6 @@ export const useSettings = () => {
},
});
React.useEffect(() => {
if (query.isFetched && query.data?.LLM_API_KEY_SET) {
posthog.capture("user_activated");
}
}, [query.data?.LLM_API_KEY_SET, query.isFetched]);
// We want to return the defaults if the settings aren't found so the user can still see the
// options to make their initial save. We don't set the defaults in `initialData` above because
// that would prepopulate the data to the cache and mess with expectations. Read more:
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useParams, useNavigate } from "react-router";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import useMetricsStore from "#/stores/metrics-store";
@@ -29,6 +29,7 @@ export function useConversationNameContextMenu({
showOptions = false,
onContextMenuToggle,
}: UseConversationNameContextMenuProps) {
const posthog = usePostHog();
const { t } = useTranslation();
const { conversationId: currentConversationId } = useParams();
const navigate = useNavigate();
@@ -1,8 +1,10 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { useSaveSettings } from "./mutation/use-save-settings";
export const useMigrateUserConsent = () => {
const posthog = usePostHog();
const { mutate: saveUserSettings } = useSaveSettings();
/**
@@ -15,11 +17,11 @@ export const useMigrateUserConsent = () => {
if (userAnalyticsConsent) {
args?.handleAnalyticsWasPresentInLocalStorage();
await saveUserSettings(
saveUserSettings(
{ user_consents_to_analytics: userAnalyticsConsent === "true" },
{
onSuccess: () => {
handleCaptureConsent(userAnalyticsConsent === "true");
handleCaptureConsent(posthog, userAnalyticsConsent === "true");
},
},
);
@@ -27,7 +29,7 @@ export const useMigrateUserConsent = () => {
localStorage.removeItem("analytics-consent");
}
},
[],
[posthog, saveUserSettings],
);
return { migrateUserConsent };
+1 -1
View File
@@ -22,7 +22,7 @@ const renderCommand = (
return;
}
const trimmedContent = content.replaceAll("\n", "\r\n").trim();
const trimmedContent = (content || "").replaceAll("\n", "\r\n").trim();
// Only write if there's actual content to avoid empty newlines
if (trimmedContent) {
terminal.writeln(parseTerminalOutput(trimmedContent));
+2 -1
View File
@@ -1,4 +1,4 @@
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useConfig } from "./query/use-config";
import { useSettings } from "./query/use-settings";
import { Provider } from "#/types/settings";
@@ -8,6 +8,7 @@ import { Provider } from "#/types/settings";
* from available hooks (config, settings, etc.)
*/
export const useTracking = () => {
const posthog = usePostHog();
const { data: config } = useConfig();
const { data: settings } = useSettings();
+7 -1
View File
@@ -471,12 +471,12 @@ export enum I18nKey {
PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB = "PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB",
PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED = "PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED",
PROJECT_MENU_DETAILS$AGO_LABEL = "PROJECT_MENU_DETAILS$AGO_LABEL",
STATUS$ERROR = "STATUS$ERROR",
STATUS$ERROR_LLM_AUTHENTICATION = "STATUS$ERROR_LLM_AUTHENTICATION",
STATUS$ERROR_LLM_SERVICE_UNAVAILABLE = "STATUS$ERROR_LLM_SERVICE_UNAVAILABLE",
STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR = "STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR",
STATUS$ERROR_LLM_OUT_OF_CREDITS = "STATUS$ERROR_LLM_OUT_OF_CREDITS",
STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION = "STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION",
STATUS$ERROR = "STATUS$ERROR",
STATUS$ERROR_RUNTIME_DISCONNECTED = "STATUS$ERROR_RUNTIME_DISCONNECTED",
STATUS$ERROR_MEMORY = "STATUS$ERROR_MEMORY",
STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR = "STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR",
@@ -937,4 +937,10 @@ export enum I18nKey {
AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION",
COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS",
COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
COMMON$PLAN_MD = "COMMON$PLAN_MD",
COMMON$READ_MORE = "COMMON$READ_MORE",
COMMON$BUILD = "COMMON$BUILD",
COMMON$ASK = "COMMON$ASK",
COMMON$PLAN = "COMMON$PLAN",
COMMON$LET_S_WORK_ON_A_PLAN = "COMMON$LET_S_WORK_ON_A_PLAN",
}
+96
View File
@@ -14990,5 +14990,101 @@
"tr": "Bir plan oluştur",
"de": "Einen Plan erstellen",
"uk": "Створити план"
},
"COMMON$PLAN_MD": {
"en": "Plan.md",
"ja": "Plan.md",
"zh-CN": "Plan.md",
"zh-TW": "Plan.md",
"ko-KR": "Plan.md",
"no": "Plan.md",
"it": "Plan.md",
"pt": "Plan.md",
"es": "Plan.md",
"ar": "Plan.md",
"fr": "Plan.md",
"tr": "Plan.md",
"de": "Plan.md",
"uk": "Plan.md"
},
"COMMON$READ_MORE": {
"en": "Read more",
"ja": "続きを読む",
"zh-CN": "阅读更多",
"zh-TW": "閱讀更多",
"ko-KR": "더 읽기",
"no": "Les mer",
"it": "Leggi di più",
"pt": "Leia mais",
"es": "Leer más",
"ar": "اقرأ المزيد",
"fr": "En savoir plus",
"tr": "Devamını oku",
"de": "Mehr lesen",
"uk": "Читати далі"
},
"COMMON$BUILD": {
"en": "Build",
"ja": "ビルド",
"zh-CN": "构建",
"zh-TW": "建構",
"ko-KR": "빌드",
"no": "Bygg",
"it": "Compila",
"pt": "Construir",
"es": "Compilar",
"ar": "بناء",
"fr": "Construire",
"tr": "Derle",
"de": "Erstellen",
"uk": "Зібрати"
},
"COMMON$ASK": {
"en": "Ask",
"ja": "質問する",
"zh-CN": "提问",
"zh-TW": "詢問",
"ko-KR": "질문",
"no": "Spør",
"it": "Chiedi",
"pt": "Perguntar",
"es": "Preguntar",
"ar": "اسأل",
"fr": "Demander",
"tr": "Sor",
"de": "Fragen",
"uk": "Запитати"
},
"COMMON$PLAN": {
"en": "Plan",
"ja": "計画",
"zh-CN": "计划",
"zh-TW": "計劃",
"ko-KR": "계획",
"no": "Plan",
"it": "Piano",
"pt": "Plano",
"es": "Plan",
"ar": "خطة",
"fr": "Planifier",
"tr": "Plan",
"de": "Plan",
"uk": "План"
},
"COMMON$LET_S_WORK_ON_A_PLAN": {
"en": "Lets work on a plan",
"ja": "プランに取り組みましょう",
"zh-CN": "让我们制定一个计划吧",
"zh-TW": "讓我們來制定計劃吧",
"ko-KR": "계획을 세워봅시다",
"no": "La oss lage en plan",
"it": "Lavoriamo su un piano",
"pt": "Vamos trabalhar em um plano",
"es": "Trabajemos en un plan",
"ar": "لنضع خطة معًا",
"fr": "Travaillons sur un plan",
"tr": "Bir plan üzerinde çalışalım",
"de": "Lassen Sie uns an einem Plan arbeiten",
"uk": "Давайте розробимо план"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M7.062 8.367L3.0915 12.336L7.062 16.305L6 17.367L1.5 12.867V11.805L6 7.305L7.062 8.367ZM17.562 7.305L16.5 8.367L20.4705 12.336L16.5 16.305L17.562 17.367L22.062 12.867V11.805L17.562 7.305ZM7.362 19.5L8.703 20.172L16.203 5.172L14.862 4.5L7.362 19.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

+1
View File
@@ -25,6 +25,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<ScrollRestoration />
<Scripts />
<Toaster />
<div id="modal-portal-exit" />
</body>
</html>
);
+3 -1
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
import { useMutation } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { I18nKey } from "#/i18n/declaration";
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
import { TOSCheckbox } from "#/components/features/waitlist/tos-checkbox";
@@ -11,6 +12,7 @@ import { openHands } from "#/api/open-hands-axios";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
export default function AcceptTOS() {
const posthog = usePostHog();
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -23,7 +25,7 @@ export default function AcceptTOS() {
const { mutate: acceptTOS, isPending: isSubmitting } = useMutation({
mutationFn: async () => {
// Set consent for analytics
handleCaptureConsent(true);
handleCaptureConsent(posthog, true);
// Call the API to record TOS acceptance in the database
return openHands.post("/api/accept_tos", {
+6 -3
View File
@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { usePostHog } from "posthog-js/react";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSettings } from "#/hooks/query/use-settings";
import { AvailableLanguages } from "#/i18n";
@@ -20,6 +21,7 @@ import { useConfig } from "#/hooks/query/use-config";
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
function AppSettingsScreen() {
const posthog = usePostHog();
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
@@ -93,7 +95,7 @@ function AppSettingsScreen() {
},
{
onSuccess: () => {
handleCaptureConsent(enableAnalytics);
handleCaptureConsent(posthog, enableAnalytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
@@ -125,7 +127,8 @@ function AppSettingsScreen() {
};
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
const currentAnalytics = !!settings?.USER_CONSENTS_TO_ANALYTICS;
// Treat null as true since analytics is opt-in by default
const currentAnalytics = settings?.USER_CONSENTS_TO_ANALYTICS ?? true;
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
};
@@ -197,7 +200,7 @@ function AppSettingsScreen() {
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
defaultIsToggled={settings.USER_CONSENTS_TO_ANALYTICS ?? true}
onToggle={checkIfAnalyticsSwitchHasChanged}
>
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
+42 -3
View File
@@ -1,13 +1,52 @@
import { useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { I18nKey } from "#/i18n/declaration";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { useConversationStore } from "#/state/conversation-store";
import { code } from "#/components/features/markdown/code";
import { ul, ol } from "#/components/features/markdown/list";
import { paragraph } from "#/components/features/markdown/paragraph";
import { anchor } from "#/components/features/markdown/anchor";
import {
h1,
h2,
h3,
h4,
h5,
h6,
} from "#/components/features/markdown/headings";
function PlannerTab() {
const { t } = useTranslation();
const setConversationMode = useConversationStore(
(state) => state.setConversationMode,
);
const { planContent, setConversationMode } = useConversationStore();
if (planContent) {
return (
<div className="flex flex-col w-full h-full p-4 overflow-auto">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
h1,
h2,
h3,
h4,
h5,
h6,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{planContent}
</Markdown>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center w-full h-full p-10">
+1
View File
@@ -72,6 +72,7 @@ export function handleStatusMessage(message: StatusMessage) {
message: message.message,
source: "chat",
metadata: { msgId: message.id },
posthog: undefined, // Service file - can't use hooks
});
}
}
+86
View File
@@ -28,6 +28,7 @@ interface ConversationState {
submittedMessage: string | null;
shouldHideSuggestions: boolean; // New state to hide suggestions when input expands
hasRightPanelToggled: boolean;
planContent: string | null;
conversationMode: ConversationMode;
}
@@ -78,6 +79,91 @@ export const useConversationStore = create<ConversationStore>()(
submittedMessage: null,
shouldHideSuggestions: false,
hasRightPanelToggled: true,
planContent: `
# Improve Developer Onboarding and Examples
## Overview
Based on the analysis of Browser-Use's current documentation and examples, this plan addresses gaps in developer onboarding by creating a progressive learning path, troubleshooting resources, and practical examples that address real-world scenarios (like the LM Studio/local LLM integration issues encountered).
## Current State Analysis
**Strengths:**
- Good quickstart documentation in \`docs/quickstart.mdx\`
- Extensive examples across multiple categories (60+ example files)
- Well-structured docs with multiple LLM provider examples
- Active community support via Discord
**Gaps Identified:**
- No progressive tutorial series that builds complexity gradually
- Limited troubleshooting documentation for common issues
- Sparse comments in example files explaining what's happening
- Local LLM setup (Ollama/LM Studio) not prominently featured
- No "first 10 minutes" success path
- Missing visual/conceptual architecture guides for beginners
- Error messages don't always point to solutions
## Proposed Improvements
### 1. Create Interactive Tutorial Series (\`examples/tutorials/\`)
**New folder structure:**
\`\`\`
examples/tutorials/
├── README.md # Tutorial overview and prerequisites
├── 00_hello_world.py # Absolute minimal example
├── 01_your_first_search.py # Basic search with detailed comments
├── 02_understanding_actions.py # How actions work
├── 03_data_extraction_basics.py # Extract data step-by-step
├── 04_error_handling.py # Common errors and solutions
├── 05_custom_tools_intro.py # First custom tool
├── 06_local_llm_setup.py # Ollama/LM Studio complete guide
└── 07_debugging_tips.py # Debugging strategies
\`\`\`
**Key Features:**
- Each file 5080 lines max
- Extensive inline comments explaining every concept
- Clear learning objectives at the top of each file
- "What you'll learn" and "Prerequisites" sections
- Common pitfalls highlighted
- Expected output shown in comments
### 2. Troubleshooting Guide (\`docs/troubleshooting.mdx\`)
**Sections:**
- Installation issues (Chromium, dependencies, virtual environments)
- LLM provider connection errors (API keys, timeouts, rate limits)
- Local LLM setup (Ollama vs LM Studio, model compatibility)
- Browser automation issues (element not found, timeout errors)
- Common error messages with solutions
- Performance optimization tips
- When to ask for help (Discord/GitHub)
**Format:**
**Error: "LLM call timed out after 60 seconds"**
**What it means:**
The model took too long to respond
**Common causes:**
1. Model is too slow for the task
2. LM Studio/Ollama not responding properly
3. Complex page overwhelming the model
**Solutions:**
- Use flash_mode for faster execution
- Try a faster model (Gemini Flash, GPT-4 Turbo Mini)
- Simplify the task
- Check model server logs`,
conversationMode: "code",
// Actions
+14 -4
View File
@@ -1,4 +1,4 @@
import posthog from "posthog-js";
import type { PostHog } from "posthog-js";
import { handleStatusMessage } from "#/services/actions";
import { displayErrorToast } from "./custom-toast-handlers";
@@ -7,9 +7,17 @@ interface ErrorDetails {
source?: string;
metadata?: Record<string, unknown>;
msgId?: string;
posthog?: PostHog;
}
export function trackError({ message, source, metadata = {} }: ErrorDetails) {
export function trackError({
message,
source,
metadata = {},
posthog,
}: ErrorDetails) {
if (!posthog) return;
const error = new Error(message);
posthog.captureException(error, {
error_source: source || "unknown",
@@ -21,8 +29,9 @@ export function showErrorToast({
message,
source,
metadata = {},
posthog,
}: ErrorDetails) {
trackError({ message, source, metadata });
trackError({ message, source, metadata, posthog });
displayErrorToast(message);
}
@@ -31,8 +40,9 @@ export function showChatError({
source,
metadata = {},
msgId,
posthog,
}: ErrorDetails) {
trackError({ message, source, metadata });
trackError({ message, source, metadata, posthog });
handleStatusMessage({
type: "error",
message,
+8 -2
View File
@@ -1,10 +1,16 @@
import posthog from "posthog-js";
import type { PostHog } from "posthog-js";
/**
* Handle user consent for tracking
* @param posthog PostHog instance (from usePostHog hook)
* @param consent Whether the user consents to tracking
*/
export const handleCaptureConsent = (consent: boolean) => {
export const handleCaptureConsent = (
posthog: PostHog | undefined,
consent: boolean,
) => {
if (!posthog) return;
if (consent && !posthog.has_opted_in_capturing()) {
posthog.opt_in_capturing();
}
+10 -7
View File
@@ -1,5 +1,7 @@
import uuid
from openhands.sdk.conversation import visualizer
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace
@@ -9,7 +11,7 @@ from openhands.sdk.security.confirmation_policy import (
AlwaysConfirm,
)
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from openhands_cli.tui.visualizer import CLIVisualizer
# register tools
from openhands.tools.terminal import TerminalTool
@@ -73,11 +75,7 @@ def setup_conversation(
agent = load_agent_specs(str(conversation_id))
if not include_security_analyzer:
# Remove security analyzer from agent spec
agent = agent.model_copy(
update={"security_analyzer": None}
)
# Create conversation - agent context is now set in AgentStore.load()
conversation: BaseConversation = Conversation(
@@ -86,9 +84,14 @@ def setup_conversation(
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id,
visualizer=CLIVisualizer
)
if include_security_analyzer:
# Security analyzer is set though conversation API now
if not include_security_analyzer:
conversation.set_security_analyzer(None)
else:
conversation.set_security_analyzer(LLMSecurityAnalyzer())
conversation.set_confirmation_policy(AlwaysConfirm())
print_formatted_text(
@@ -38,6 +38,16 @@ class AgentStore:
str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
agent = Agent.model_validate_json(str_spec)
# Temporary to remove security analyzer from agent specs
# Security analyzer is set via conversation API now
# Doing this so that deprecation warning is thrown only the first time running CLI
if agent.security_analyzer:
agent = agent.model_copy(
update={"security_analyzer": None}
)
self.save(agent)
# Update tools with most recent working directory
updated_tools = get_default_tools(enable_browser=False)
@@ -0,0 +1,312 @@
import re
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from openhands.sdk.conversation.visualizer.base import (
ConversationVisualizerBase,
)
from openhands.sdk.event import (
ActionEvent,
AgentErrorEvent,
MessageEvent,
ObservationEvent,
PauseEvent,
SystemPromptEvent,
UserRejectObservation,
)
from openhands.sdk.event.base import Event
from openhands.sdk.event.condenser import Condensation
# These are external inputs
_OBSERVATION_COLOR = "yellow"
_MESSAGE_USER_COLOR = "gold3"
_PAUSE_COLOR = "bright_yellow"
# These are internal system stuff
_SYSTEM_COLOR = "magenta"
_THOUGHT_COLOR = "bright_black"
_ERROR_COLOR = "red"
# These are agent actions
_ACTION_COLOR = "blue"
_MESSAGE_ASSISTANT_COLOR = _ACTION_COLOR
DEFAULT_HIGHLIGHT_REGEX = {
r"^Reasoning:": f"bold {_THOUGHT_COLOR}",
r"^Thought:": f"bold {_THOUGHT_COLOR}",
r"^Action:": f"bold {_ACTION_COLOR}",
r"^Arguments:": f"bold {_ACTION_COLOR}",
r"^Tool:": f"bold {_OBSERVATION_COLOR}",
r"^Result:": f"bold {_OBSERVATION_COLOR}",
r"^Rejection Reason:": f"bold {_ERROR_COLOR}",
# Markdown-style
r"\*\*(.*?)\*\*": "bold",
r"\*(.*?)\*": "italic",
}
_PANEL_PADDING = (1, 1)
class CLIVisualizer(ConversationVisualizerBase):
"""Handles visualization of conversation events with Rich formatting.
Provides Rich-formatted output with panels and complete content display.
"""
_console: Console
_skip_user_messages: bool
_highlight_patterns: dict[str, str]
def __init__(
self,
name: str | None = None,
highlight_regex: dict[str, str] | None = DEFAULT_HIGHLIGHT_REGEX,
skip_user_messages: bool = False,
):
"""Initialize the visualizer.
Args:
name: Optional name to prefix in panel titles to identify
which agent/conversation is speaking.
highlight_regex: Dictionary mapping regex patterns to Rich color styles
for highlighting keywords in the visualizer.
For example: {"Reasoning:": "bold blue",
"Thought:": "bold green"}
skip_user_messages: If True, skip displaying user messages. Useful for
scenarios where user input is not relevant to show.
"""
super().__init__(
name=name,
)
self._console = Console()
self._skip_user_messages = skip_user_messages
self._highlight_patterns = highlight_regex or {}
def on_event(self, event: Event) -> None:
"""Main event handler that displays events with Rich formatting."""
panel = self._create_event_panel(event)
if panel:
self._console.print(panel)
self._console.print() # Add spacing between events
def _apply_highlighting(self, text: Text) -> Text:
"""Apply regex-based highlighting to text content.
Args:
text: The Rich Text object to highlight
Returns:
A new Text object with highlighting applied
"""
if not self._highlight_patterns:
return text
# Create a copy to avoid modifying the original
highlighted = text.copy()
# Apply each pattern using Rich's built-in highlight_regex method
for pattern, style in self._highlight_patterns.items():
pattern_compiled = re.compile(pattern, re.MULTILINE)
highlighted.highlight_regex(pattern_compiled, style)
return highlighted
def _create_event_panel(self, event: Event) -> Panel | None:
"""Create a Rich Panel for the event with appropriate styling."""
# Use the event's visualize property for content
content = event.visualize
if not content.plain.strip():
return None
# Apply highlighting if configured
if self._highlight_patterns:
content = self._apply_highlighting(content)
# Don't emit system prompt in CLI
if isinstance(event, SystemPromptEvent):
title = f"[bold {_SYSTEM_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"System Prompt[/bold {_SYSTEM_COLOR}]"
return None
elif isinstance(event, ActionEvent):
# Check if action is None (non-executable)
title = f"[bold {_ACTION_COLOR}]"
if self._name:
title += f"{self._name} "
if event.action is None:
title += f"Agent Action (Not Executed)[/bold {_ACTION_COLOR}]"
else:
title += f"Agent Action[/bold {_ACTION_COLOR}]"
return Panel(
content,
title=title,
subtitle=self._format_metrics_subtitle(),
border_style=_ACTION_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, ObservationEvent):
title = f"[bold {_OBSERVATION_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"Observation[/bold {_OBSERVATION_COLOR}]"
return Panel(
content,
title=title,
border_style=_OBSERVATION_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, UserRejectObservation):
title = f"[bold {_ERROR_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"User Rejected Action[/bold {_ERROR_COLOR}]"
return Panel(
content,
title=title,
border_style=_ERROR_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, MessageEvent):
if (
self._skip_user_messages
and event.llm_message
and event.llm_message.role == "user"
):
return
assert event.llm_message is not None
# Role-based styling
role_colors = {
"user": _MESSAGE_USER_COLOR,
"assistant": _MESSAGE_ASSISTANT_COLOR,
}
role_color = role_colors.get(event.llm_message.role, "white")
# "User Message To [Name] Agent" for user
# "Message from [Name] Agent" for agent
agent_name = f"{self._name} " if self._name else ""
if event.llm_message.role == "user":
title_text = (
f"[bold {role_color}]User Message to "
f"{agent_name}Agent[/bold {role_color}]"
)
else:
title_text = (
f"[bold {role_color}]Message from "
f"{agent_name}Agent[/bold {role_color}]"
)
return Panel(
content,
title=title_text,
subtitle=self._format_metrics_subtitle(),
border_style=role_color,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, AgentErrorEvent):
title = f"[bold {_ERROR_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"Agent Error[/bold {_ERROR_COLOR}]"
return Panel(
content,
title=title,
subtitle=self._format_metrics_subtitle(),
border_style=_ERROR_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, PauseEvent):
title = f"[bold {_PAUSE_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"User Paused[/bold {_PAUSE_COLOR}]"
return Panel(
content,
title=title,
border_style=_PAUSE_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, Condensation):
title = f"[bold {_SYSTEM_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"Condensation[/bold {_SYSTEM_COLOR}]"
return Panel(
content,
title=title,
subtitle=self._format_metrics_subtitle(),
border_style=_SYSTEM_COLOR,
expand=True,
)
else:
# Fallback panel for unknown event types
title = f"[bold {_ERROR_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"UNKNOWN Event: {event.__class__.__name__}[/bold {_ERROR_COLOR}]"
return Panel(
content,
title=title,
subtitle=f"({event.source})",
border_style=_ERROR_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
def _format_metrics_subtitle(self) -> str | None:
"""Format LLM metrics as a visually appealing subtitle string with icons,
colors, and k/m abbreviations using conversation stats."""
stats = self.conversation_stats
if not stats:
return None
combined_metrics = stats.get_combined_metrics()
if not combined_metrics or not combined_metrics.accumulated_token_usage:
return None
usage = combined_metrics.accumulated_token_usage
cost = combined_metrics.accumulated_cost or 0.0
# helper: 1234 -> "1.2K", 1200000 -> "1.2M"
def abbr(n: int | float) -> str:
n = int(n or 0)
if n >= 1_000_000_000:
val, suffix = n / 1_000_000_000, "B"
elif n >= 1_000_000:
val, suffix = n / 1_000_000, "M"
elif n >= 1_000:
val, suffix = n / 1_000, "K"
else:
return str(n)
return f"{val:.2f}".rstrip("0").rstrip(".") + suffix
input_tokens = abbr(usage.prompt_tokens or 0)
output_tokens = abbr(usage.completion_tokens or 0)
# Cache hit rate (prompt + cache)
prompt = usage.prompt_tokens or 0
cache_read = usage.cache_read_tokens or 0
cache_rate = f"{(cache_read / prompt * 100):.2f}%" if prompt > 0 else "N/A"
reasoning_tokens = usage.reasoning_tokens or 0
# Cost
cost_str = f"{cost:.4f}" if cost > 0 else "0.00"
# Build with fixed color scheme
parts: list[str] = []
parts.append(f"[cyan]↑ input {input_tokens}[/cyan]")
parts.append(f"[magenta]cache hit {cache_rate}[/magenta]")
if reasoning_tokens > 0:
parts.append(f"[yellow] reasoning {abbr(reasoning_tokens)}[/yellow]")
parts.append(f"[blue]↓ output {output_tokens}[/blue]")
parts.append(f"[green]$ {cost_str}[/green]")
return "Tokens: " + "".join(parts)
-7
View File
@@ -2,7 +2,6 @@
import os
from typing import Any
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.tools.preset import get_default_agent
from openhands.sdk import LLM
@@ -67,10 +66,4 @@ def get_default_cli_agent(
cli_mode=True
)
agent = agent.model_copy(
update={
'security_analyzer': LLMSecurityAnalyzer()
}
)
return agent
+5 -5
View File
@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
[project]
name = "openhands"
version = "1.0.5"
version = "1.0.7"
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
readme = "README.md"
license = { text = "MIT" }
@@ -18,8 +18,8 @@ classifiers = [
# Using Git URLs for dependencies so installs from PyPI pull from GitHub
# TODO: pin package versions once agent-sdk has published PyPI packages
dependencies = [
"openhands-sdk==1",
"openhands-tools==1",
"openhands-sdk==1.1",
"openhands-tools==1.1",
"prompt-toolkit>=3",
"typer>=0.17.4",
]
@@ -102,5 +102,5 @@ ignore_missing_imports = true
# UNCOMMENT TO USE EXACT COMMIT FROM AGENT-SDK
# [tool.uv.sources]
# openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "aaa0066ee078688e015fcad590393fe6771c10a1" }
# openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "aaa0066ee078688e015fcad590393fe6771c10a1" }
# openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "7b695dc519084e75c482b34473e714845d6cef92" }
# openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "7b695dc519084e75c482b34473e714845d6cef92" }
@@ -1,15 +1,16 @@
"""Test that first-time settings screen usage creates a default agent with security analyzer."""
"""Test that first-time settings screen usage creates a default agent and conversation with security analyzer."""
from unittest.mock import patch
import pytest
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from openhands_cli.user_actions.settings_action import SettingsType
from openhands.sdk import LLM
from openhands.sdk import LLM, Conversation, Workspace
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from pydantic import SecretStr
def test_first_time_settings_creates_default_agent_with_security_analyzer():
"""Test that using the settings screen for the first time creates a default agent with a non-None security analyzer."""
def test_first_time_settings_creates_default_agent_and_conversation_with_security_analyzer():
"""Test that using the settings screen for the first time creates a default agent and conversation with security analyzer."""
# Create a settings screen instance (no conversation initially)
screen = SettingsScreen(conversation=None)
@@ -50,17 +51,20 @@ def test_first_time_settings_creates_default_agent_with_security_analyzer():
assert saved_agent.llm.model == 'openai/gpt-4o-mini', f"Expected model 'openai/gpt-4o-mini', got '{saved_agent.llm.model}'"
assert saved_agent.llm.api_key.get_secret_value() == 'sk-test-key-123', "API key should match the provided value"
# Verify that the agent has a security analyzer and it's not None
assert hasattr(saved_agent, 'security_analyzer'), "Agent should have a security_analyzer attribute"
assert saved_agent.security_analyzer is not None, "Security analyzer should not be None"
# Test that a conversation can be created with the agent and security analyzer can be set
conversation = Conversation(agent=saved_agent, workspace=Workspace(working_dir='/tmp'))
# Verify the security analyzer has the expected type/kind
assert hasattr(saved_agent.security_analyzer, 'kind'), "Security analyzer should have a 'kind' attribute"
assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{saved_agent.security_analyzer.kind}'"
# Set security analyzer using the new API
security_analyzer = LLMSecurityAnalyzer()
conversation.set_security_analyzer(security_analyzer)
# Verify that the security analyzer was set correctly
assert conversation.state.security_analyzer is not None, "Conversation should have a security analyzer"
assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{conversation.state.security_analyzer.kind}'"
def test_first_time_settings_with_advanced_configuration():
"""Test that advanced settings also create a default agent with security analyzer."""
"""Test that advanced settings also create a default agent and conversation with security analyzer."""
screen = SettingsScreen(conversation=None)
@@ -94,11 +98,20 @@ def test_first_time_settings_with_advanced_configuration():
saved_agent = screen.agent_store.load()
# Verify agent creation and security analyzer
# Verify agent creation
assert saved_agent is not None, "Agent should be created with advanced settings"
assert saved_agent.security_analyzer is not None, "Security analyzer should not be None in advanced settings"
assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer"
# Verify advanced settings were applied
assert saved_agent.llm.model == 'anthropic/claude-3-5-sonnet', "Custom model should be set"
assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set"
assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set"
# Test that a conversation can be created with the agent and security analyzer can be set
conversation = Conversation(agent=saved_agent, workspace=Workspace(working_dir='/tmp'))
# Set security analyzer using the new API
security_analyzer = LLMSecurityAnalyzer()
conversation.set_security_analyzer(security_analyzer)
# Verify that the security analyzer was set correctly
assert conversation.state.security_analyzer is not None, "Conversation should have a security analyzer"
assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer"
@@ -45,6 +45,7 @@ class TestConfirmationMode:
patch('openhands_cli.setup.print_formatted_text') as mock_print,
patch('openhands_cli.setup.HTML'),
patch('openhands_cli.setup.uuid') as mock_uuid,
patch('openhands_cli.setup.CLIVisualizer') as mock_visualizer,
):
# Mock dependencies
mock_conversation_id = MagicMock()
@@ -72,6 +73,7 @@ class TestConfirmationMode:
workspace=ANY,
persistence_dir=ANY,
conversation_id=mock_conversation_id,
visualizer=mock_visualizer
)
def test_setup_conversation_raises_missing_agent_spec(self) -> None:
@@ -108,15 +108,15 @@ class TestConversationRunner:
3. If not paused, we should still ask for confirmation on actions
4. If deferred no run call to agent should be made
5. If accepted, run call to agent should be made
"""
if final_status == ConversationExecutionStatus.FINISHED:
agent.finish_on_step = 1
# Add a mock security analyzer to enable confirmation mode
agent.security_analyzer = MagicMock()
convo = Conversation(agent)
# Set security analyzer using the new API to enable confirmation mode
convo.set_security_analyzer(MagicMock())
convo.state.execution_status = (
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
)
@@ -127,6 +127,7 @@ class TestConversationRunner:
cr, '_handle_confirmation_request', return_value=confirmation
) as mock_confirmation_request:
cr.process_message(message=None)
mock_confirmation_request.assert_called_once()
assert agent.step_count == expected_run_calls
assert convo.state.execution_status == final_status
@@ -0,0 +1,238 @@
"""Tests for the conversation visualizer and event visualization."""
import json
from rich.text import Text
from openhands_cli.tui.visualizer import (
CLIVisualizer,
)
from openhands.sdk.event import (
ActionEvent,
SystemPromptEvent,
UserRejectObservation,
)
from openhands.sdk.llm import (
MessageToolCall,
TextContent,
)
from openhands.sdk.tool import Action
class VisualizerMockAction(Action):
"""Mock action for testing."""
command: str = "test command"
working_dir: str = "/tmp"
class VisualizerCustomAction(Action):
"""Custom action with overridden visualize method."""
task_list: list[dict] = []
@property
def visualize(self) -> Text:
"""Custom visualization for task tracker."""
content = Text()
content.append("Task Tracker Action\n", style="bold")
content.append(f"Tasks: {len(self.task_list)}")
for i, task in enumerate(self.task_list):
content.append(f"\n {i + 1}. {task.get('title', 'Untitled')}")
return content
def create_tool_call(
call_id: str, function_name: str, arguments: dict
) -> MessageToolCall:
"""Helper to create a MessageToolCall."""
return MessageToolCall(
id=call_id,
name=function_name,
arguments=json.dumps(arguments),
origin="completion",
)
def test_conversation_visualizer_initialization():
"""Test DefaultConversationVisualizer can be initialized."""
visualizer = CLIVisualizer()
assert visualizer is not None
assert hasattr(visualizer, "on_event")
assert hasattr(visualizer, "_create_event_panel")
def test_visualizer_event_panel_creation():
"""Test that visualizer creates panels for different event types."""
conv_viz = CLIVisualizer()
# Test with a simple action event
action = VisualizerMockAction(command="test")
tool_call = create_tool_call("call_1", "test", {})
action_event = ActionEvent(
thought=[TextContent(text="Testing")],
action=action,
tool_name="test",
tool_call_id="call_1",
tool_call=tool_call,
llm_response_id="response_1",
)
panel = conv_viz._create_event_panel(action_event)
assert panel is not None
assert hasattr(panel, "renderable")
def test_visualizer_action_event_with_none_action_panel():
"""ActionEvent with action=None should render as 'Agent Action (Not Executed)'."""
visualizer = CLIVisualizer()
tc = create_tool_call("call_ne_1", "missing_fn", {})
action_event = ActionEvent(
thought=[TextContent(text="...")],
tool_call=tc,
tool_name=tc.name,
tool_call_id=tc.id,
llm_response_id="resp_viz_1",
action=None,
)
panel = visualizer._create_event_panel(action_event)
assert panel is not None
# Ensure it doesn't fall back to UNKNOWN
assert "UNKNOWN Event" not in str(panel.title)
# And uses the 'Agent Action (Not Executed)' title
assert "Agent Action (Not Executed)" in str(panel.title)
def test_visualizer_user_reject_observation_panel():
"""UserRejectObservation should render a dedicated panel."""
visualizer = CLIVisualizer()
event = UserRejectObservation(
tool_name="demo_tool",
tool_call_id="fc_call_1",
action_id="action_1",
rejection_reason="User rejected the proposed action.",
)
panel = visualizer._create_event_panel(event)
assert panel is not None
title = str(panel.title)
assert "UNKNOWN Event" not in title
assert "User Rejected Action" in title
# ensure the reason is part of the renderable text
renderable = panel.renderable
assert isinstance(renderable, Text)
assert "User rejected the proposed action." in renderable.plain
def test_metrics_formatting():
"""Test metrics subtitle formatting."""
from unittest.mock import MagicMock
from openhands.sdk.conversation.conversation_stats import ConversationStats
from openhands.sdk.llm.utils.metrics import Metrics
# Create conversation stats with metrics
conversation_stats = ConversationStats()
# Create metrics and add to conversation stats
metrics = Metrics(model_name="test-model")
metrics.add_cost(0.0234)
metrics.add_token_usage(
prompt_tokens=1500,
completion_tokens=500,
cache_read_tokens=300,
cache_write_tokens=0,
reasoning_tokens=200,
context_window=8000,
response_id="test_response",
)
# Add metrics to conversation stats
conversation_stats.usage_to_metrics["test_usage"] = metrics
# Create visualizer and initialize with mock state
visualizer = CLIVisualizer()
mock_state = MagicMock()
mock_state.stats = conversation_stats
visualizer.initialize(mock_state)
# Test the metrics subtitle formatting
subtitle = visualizer._format_metrics_subtitle()
assert subtitle is not None
assert "1.5K" in subtitle # Input tokens abbreviated (trailing zeros removed)
assert "500" in subtitle # Output tokens
assert "20.00%" in subtitle # Cache hit rate
assert "200" in subtitle # Reasoning tokens
assert "0.0234" in subtitle # Cost
def test_metrics_abbreviation_formatting():
"""Test number abbreviation with various edge cases."""
from unittest.mock import MagicMock
from openhands.sdk.conversation.conversation_stats import ConversationStats
from openhands.sdk.llm.utils.metrics import Metrics
test_cases = [
# (input_tokens, expected_abbr)
(999, "999"), # Below threshold
(1000, "1K"), # Exact K boundary, trailing zeros removed
(1500, "1.5K"), # K with one decimal, trailing zero removed
(89080, "89.08K"), # K with two decimals (regression test for bug)
(89000, "89K"), # K with trailing zeros removed
(1000000, "1M"), # Exact M boundary
(1234567, "1.23M"), # M with decimals
(1000000000, "1B"), # Exact B boundary
]
for tokens, expected in test_cases:
stats = ConversationStats()
metrics = Metrics(model_name="test-model")
metrics.add_token_usage(
prompt_tokens=tokens,
completion_tokens=100,
cache_read_tokens=0,
cache_write_tokens=0,
reasoning_tokens=0,
context_window=8000,
response_id="test",
)
stats.usage_to_metrics["test"] = metrics
visualizer = CLIVisualizer()
mock_state = MagicMock()
mock_state.stats = stats
visualizer.initialize(mock_state)
subtitle = visualizer._format_metrics_subtitle()
assert subtitle is not None, f"Failed for {tokens}"
assert expected in subtitle, (
f"Expected '{expected}' in subtitle for {tokens}, got: {subtitle}"
)
def test_event_base_fallback_visualize():
"""Test that Event provides fallback visualization."""
from openhands.sdk.event.base import Event
from openhands.sdk.event.types import SourceType
class UnknownEvent(Event):
source: SourceType = "agent"
event = UnknownEvent()
conv_viz = CLIVisualizer()
panel = conv_viz._create_event_panel(event)
assert "UNKNOWN Event" in str(panel.title)
def test_visualizer_does_not_render_system_prompt():
"""Test that Event provides fallback visualization."""
system_prompt_event = SystemPromptEvent(
source="agent",
system_prompt=TextContent(text="dummy"),
tools=[]
)
conv_viz = CLIVisualizer()
panel = conv_viz._create_event_panel(system_prompt_event)
assert panel is None
+9 -9
View File
@@ -1902,7 +1902,7 @@ wheels = [
[[package]]
name = "openhands"
version = "1.0.5"
version = "1.0.7"
source = { editable = "." }
dependencies = [
{ name = "openhands-sdk" },
@@ -1929,8 +1929,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "openhands-sdk", specifier = "==1.0.0" },
{ name = "openhands-tools", specifier = "==1.0.0" },
{ name = "openhands-sdk", specifier = "==1.1" },
{ name = "openhands-tools", specifier = "==1.1" },
{ name = "prompt-toolkit", specifier = ">=3" },
{ name = "typer", specifier = ">=0.17.4" },
]
@@ -1953,7 +1953,7 @@ dev = [
[[package]]
name = "openhands-sdk"
version = "1.0.0"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastmcp" },
@@ -1966,14 +1966,14 @@ dependencies = [
{ name = "tenacity" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/58/d6117840a14d013176a7a490a74295dffac64b44dc098532d4e8526c9a87/openhands_sdk-1.0.0.tar.gz", hash = "sha256:7c3a0d77d48d7eceaa77fda90ac654697ce916431b5c905d10d9ab6c07609a1a", size = 160726, upload-time = "2025-11-06T17:05:44.545Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/b2/97d9deb743b266683f3e70cebaa1d34ee247c019f7d6e42c2f5de529cb47/openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c", size = 166636, upload-time = "2025-11-11T19:07:04.249Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/9b/4d4c356ed50e6ad87e6dc8f87af1966c51c55a22955cebd632bf62040e5b/openhands_sdk-1.0.0-py3-none-any.whl", hash = "sha256:73916e22783e2c8500f19765fa340631a0e47ae9a3c5e40fb8411ecab4a1f49a", size = 214807, upload-time = "2025-11-06T17:05:43.474Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9f/a97a10447f3be53df4639e43748c4178853e958df07ba74890f4968829d6/openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e", size = 221594, upload-time = "2025-11-11T19:07:02.847Z" },
]
[[package]]
name = "openhands-tools"
version = "1.0.0"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bashlex" },
@@ -1985,9 +1985,9 @@ dependencies = [
{ name = "openhands-sdk" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/49/3bad4d8283c76f72dacfde8fece9d1190774c87c40a011075868e8d18cbf/openhands_tools-1.0.0.tar.gz", hash = "sha256:f6bc8647149d541730520f1aeb409cd9eac96d796d19e39a40f300dcd2b0284c", size = 61997, upload-time = "2025-11-06T17:05:46.455Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/89/e2c5fc2d9e8dc6840ef2891ff6f76b9769b50a4c508fd3a626c1ab476fb1/openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6", size = 62818, upload-time = "2025-11-11T19:07:06.527Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/15/23c5650a9470f9c125288508bf966e6b2ece479f5407801aa7fdda2ba5a0/openhands_tools-1.0.0-py3-none-any.whl", hash = "sha256:21a4ff3f37a3c71edd17b861fe1a9b86cc744ad9dc8a3626898ecdeeea7ae30f", size = 84232, upload-time = "2025-11-06T17:05:45.527Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a3/e58d75b7bd8d5dfbe063fcfaaadbdfd24fd511d633a528cefd29f0e01056/openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9", size = 85062, upload-time = "2025-11-11T19:07:05.315Z" },
]
[[package]]
@@ -286,7 +286,7 @@ def response_to_actions(
f'Unexpected task format in task_list: {type(task)} - {task}'
)
raise FunctionCallValidationError(
f'Unexpected task format in task_list: {type(task)}. Each task shoud be a dictionary.'
f'Unexpected task format in task_list: {type(task)}. Each task should be a dictionary.'
)
normalized_task_list.append(normalized_task)
@@ -215,11 +215,12 @@ class LiveStatusAppConversationService(GitAppConversationService):
yield task
# Start conversation...
body_json = start_conversation_request.model_dump(
mode='json', context={'expose_secrets': True}
)
response = await self.httpx_client.post(
f'{agent_server_url}/api/conversations',
json=start_conversation_request.model_dump(
mode='json', context={'expose_secrets': True}
),
json=body_json,
headers={'X-Session-API-Key': sandbox.session_api_key},
timeout=self.sandbox_startup_timeout,
)
@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:d5995c3-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:f3c0c19-python'
class SandboxSpecService(ABC):
+1 -1
View File
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.61-nikolaik"
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.62-nikolaik"
```
#### Additional Kubernetes Options
@@ -24,7 +24,9 @@ from openhands.app_server.app_conversation.app_conversation_service import (
from openhands.app_server.config import (
depends_app_conversation_info_service,
depends_app_conversation_service,
depends_sandbox_service,
)
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig
from openhands.core.logger import openhands_logger as logger
@@ -96,6 +98,7 @@ from openhands.utils.environment import get_effective_llm_base_url
app = APIRouter(prefix='/api', dependencies=get_dependencies())
app_conversation_service_dependency = depends_app_conversation_service()
app_conversation_info_service_dependency = depends_app_conversation_info_service()
sandbox_service_dependency = depends_sandbox_service()
def _filter_conversations_by_age(
@@ -467,10 +470,13 @@ async def delete_conversation(
conversation_id: str = Depends(validate_conversation_id),
user_id: str | None = Depends(get_user_id),
app_conversation_service: AppConversationService = app_conversation_service_dependency,
sandbox_service: SandboxService = sandbox_service_dependency,
) -> bool:
# Try V1 conversation first
v1_result = await _try_delete_v1_conversation(
conversation_id, app_conversation_service
conversation_id,
app_conversation_service,
sandbox_service,
)
if v1_result is not None:
return v1_result
@@ -480,9 +486,12 @@ async def delete_conversation(
async def _try_delete_v1_conversation(
conversation_id: str, app_conversation_service: AppConversationService
conversation_id: str,
app_conversation_service: AppConversationService,
sandbox_service: SandboxService,
) -> bool | None:
"""Try to delete a V1 conversation. Returns None if not a V1 conversation."""
result = None
try:
conversation_uuid = uuid.UUID(conversation_id)
# Check if it's a V1 conversation by trying to get it
@@ -492,9 +501,10 @@ async def _try_delete_v1_conversation(
if app_conversation:
# This is a V1 conversation, delete it using the app conversation service
# Pass the conversation ID for secure deletion
return await app_conversation_service.delete_app_conversation(
result = await app_conversation_service.delete_app_conversation(
app_conversation.id
)
await sandbox_service.delete_sandbox(app_conversation.sandbox_id)
except (ValueError, TypeError):
# Not a valid UUID, continue with V0 logic
pass
@@ -502,7 +512,7 @@ async def _try_delete_v1_conversation(
# Some other error, continue with V0 logic
pass
return None
return result
async def _delete_v0_conversation(conversation_id: str, user_id: str | None) -> bool:
Generated
+10 -10
View File
@@ -7329,14 +7329,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.0.0a6-py3-none-any.whl", hash = "sha256:72b0da038ede018c55c64f0ac99bc5d991af173627efc63de87d54b3cd69134c"},
{file = "openhands_agent_server-1.0.0a6.tar.gz", hash = "sha256:8c6fbceb07990e3caf7f8797082d1bb614b9f7339bd00576c24fd34a956a03b4"},
{file = "openhands_agent_server-1.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
{file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
]
[package.dependencies]
@@ -7352,14 +7352,14 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-sdk"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.0.0a6-py3-none-any.whl", hash = "sha256:0b0b579fc48a5b7eaa418ca66188206ba00f4d883997bc29291bc1745e0b7ddc"},
{file = "openhands_sdk-1.0.0a6.tar.gz", hash = "sha256:01daff435c5f94037b9b4ba85054097ca6235982a9b0fee00341279d4c4b5a01"},
{file = "openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
{file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
]
[package.dependencies]
@@ -7378,14 +7378,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.0.0a6"
version = "1.1.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.0.0a6-py3-none-any.whl", hash = "sha256:55b75016f7e3930e4365393a026726eeffae027363d03862a17a8cebc1aed670"},
{file = "openhands_tools-1.0.0a6.tar.gz", hash = "sha256:4d5382f3e1cab9d23c1ef7ea8e36e821083886d6d4b019100cbf897e3b0cd3be"},
{file = "openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
{file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
]
[package.dependencies]
@@ -16729,4 +16729,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "57ed6b7f4613e668fd1d0e10a21f7c915cdbb9c7b906a0b71a8ba222733c082d"
content-hash = "0fe5bab6aeb5ebce4588b30cfcf491af4cc9d9b9cd5160e67c8a055d9db276fc"
+7 -7
View File
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.61.0"
version = "0.62.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -113,12 +113,12 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
openhands-sdk = "1.0.0a6"
openhands-agent-server = "1.0.0a6"
openhands-tools = "1.0.0a6"
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
openhands-sdk = "1.1.0"
openhands-agent-server = "1.1.0"
openhands-tools = "1.1.0"
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"
-594
View File
@@ -1,594 +0,0 @@
import os
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from openhands.events.action import CmdRunAction
from openhands.events.observation import ErrorObservation
from openhands.events.observation.commands import (
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected timeout suffix."""
return (
f'[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
# Skip all tests in this module if not running on Windows
pytestmark = pytest.mark.skipif(
sys.platform != 'win32', reason='WindowsPowershellSession tests require Windows'
)
@pytest.fixture
def windows_bash_session(temp_dir):
"""Create a WindowsPowershellSession instance for testing."""
# Instantiate the class. Initialization happens in __init__.
session = WindowsPowershellSession(
work_dir=temp_dir,
username=None,
)
assert session._initialized # Should be true after __init__
yield session
# Ensure cleanup happens even if test fails
session.close()
if sys.platform == 'win32':
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
def test_command_execution(windows_bash_session):
"""Test basic command execution."""
# Test a simple command
action = CmdRunAction(command="Write-Output 'Hello World'")
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check content, stripping potential trailing newlines
content = result.content.strip()
assert content == 'Hello World'
assert result.exit_code == 0
# Test a simple command with multiline input but single line output
action = CmdRunAction(
command="""Write-Output `
('hello ' + `
'world')"""
)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check content, stripping potential trailing newlines
content = result.content.strip()
assert content == 'hello world'
assert result.exit_code == 0
# Test a simple command with a newline
action = CmdRunAction(command='Write-Output "Hello\\n World"')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check content, stripping potential trailing newlines
content = result.content.strip()
assert content == 'Hello\\n World'
assert result.exit_code == 0
def test_command_with_error(windows_bash_session):
"""Test command execution with an error reported via Write-Error."""
# Test a command that will write an error
action = CmdRunAction(command="Write-Error 'Test Error'")
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Error stream is captured and appended
assert 'ERROR' in result.content
# Our implementation should set exit code to 1 when errors occur in stream
assert result.exit_code == 1
def test_command_failure_exit_code(windows_bash_session):
"""Test command execution that results in a non-zero exit code."""
# Test a command that causes a script failure (e.g., invalid cmdlet)
action = CmdRunAction(command='Get-NonExistentCmdlet')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Error should be captured in the output
assert 'ERROR' in result.content
assert (
'is not recognized' in result.content
or 'CommandNotFoundException' in result.content
)
assert result.exit_code == 1
def test_control_commands(windows_bash_session):
"""Test handling of control commands (not supported)."""
# Test Ctrl+C - should return ErrorObservation if no command is running
action_c = CmdRunAction(command='C-c', is_input=True)
result_c = windows_bash_session.execute(action_c)
assert isinstance(result_c, ErrorObservation)
assert 'No previous running command to interact with' in result_c.content
# Run a long-running command
action_long_running = CmdRunAction(command='Start-Sleep -Seconds 100')
result_long_running = windows_bash_session.execute(action_long_running)
assert isinstance(result_long_running, CmdOutputObservation)
assert result_long_running.exit_code == -1
# Test unsupported control command
action_d = CmdRunAction(command='C-d', is_input=True)
result_d = windows_bash_session.execute(action_d)
assert "Your input command 'C-d' was NOT processed" in result_d.metadata.suffix
assert (
'Direct input to running processes (is_input=True) is not supported by this PowerShell session implementation.'
in result_d.metadata.suffix
)
assert 'You can use C-c to stop the process' in result_d.metadata.suffix
# Ctrl+C now can cancel the long-running command
action_c = CmdRunAction(command='C-c', is_input=True)
result_c = windows_bash_session.execute(action_c)
assert isinstance(result_c, CmdOutputObservation)
assert result_c.exit_code == 0
def test_command_timeout(windows_bash_session):
"""Test command timeout handling."""
# Test a command that will timeout
test_timeout_sec = 1
action = CmdRunAction(command='Start-Sleep -Seconds 5')
action.set_hard_timeout(test_timeout_sec)
start_time = time.monotonic()
result = windows_bash_session.execute(action)
duration = time.monotonic() - start_time
assert isinstance(result, CmdOutputObservation)
# Check for timeout specific metadata
assert 'timed out' in result.metadata.suffix.lower() # Check suffix, not content
assert result.exit_code == -1 # Timeout should result in exit code -1
# Check that it actually timed out near the specified time
assert abs(duration - test_timeout_sec) < 0.5 # Allow some buffer
def test_long_running_command(windows_bash_session, dynamic_port):
action = CmdRunAction(command=f'python -u -m http.server {dynamic_port}')
action.set_hard_timeout(1)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Verify the initial output was captured
assert 'Serving HTTP on' in result.content
# Check for timeout specific metadata
assert get_timeout_suffix(1.0) in result.metadata.suffix
assert result.exit_code == -1
# The action timed out, but the command should be still running
# We should now be able to interrupt it
action = CmdRunAction(command='C-c', is_input=True)
action.set_hard_timeout(30) # Give it enough time to stop
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# On Windows, Stop-Job termination doesn't inherently return output.
# The CmdOutputObservation will have content="" and exit_code=0 if successful.
# The KeyboardInterrupt message assertion is removed as it's added manually
# by the wrapper and might not be guaranteed depending on timing/implementation details.
assert result.exit_code == 0
# Verify the server is actually stopped by starting another one on the same port
action = CmdRunAction(command=f'python -u -m http.server {dynamic_port}')
action.set_hard_timeout(1) # Set a short timeout to check if it starts
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Verify the initial output was captured, indicating the port was free
assert 'Serving HTTP on' in result.content
# The command will time out again, so the exit code should be -1
assert result.exit_code == -1
# Clean up the second server process
action = CmdRunAction(command='C-c', is_input=True)
action.set_hard_timeout(30)
result = windows_bash_session.execute(action)
assert result.exit_code == 0
def test_multiple_commands_rejected_and_individual_execution(windows_bash_session):
"""Test that executing multiple commands separated by newline is rejected,
but individual commands (including multiline) execute correctly.
"""
# Define a list of commands, including multiline and special characters
cmds = [
'Get-ChildItem',
'Write-Output "hello`nworld"',
"""Write-Output "hello it's me\"""",
"""Write-Output `
'hello' `
-NoNewline""",
"""Write-Output 'hello`nworld`nare`nyou`nthere?'""",
"""Write-Output 'hello`nworld`nare`nyou`n`nthere?'""",
"""Write-Output 'hello`nworld `"'""", # Escape the trailing double quote
]
joined_cmds = '\n'.join(cmds)
# 1. Test that executing multiple commands at once fails
action_multi = CmdRunAction(command=joined_cmds)
result_multi = windows_bash_session.execute(action_multi)
assert isinstance(result_multi, ErrorObservation)
assert 'ERROR: Cannot execute multiple commands at once' in result_multi.content
# 2. Now run each command individually and verify they work
results = []
for cmd in cmds:
action_single = CmdRunAction(command=cmd)
obs = windows_bash_session.execute(action_single)
assert isinstance(obs, CmdOutputObservation)
assert obs.exit_code == 0
results.append(obs.content.strip()) # Strip trailing newlines for comparison
def test_working_directory(windows_bash_session, temp_dir):
"""Test working directory handling."""
initial_cwd = windows_bash_session._cwd
abs_temp_work_dir = os.path.abspath(temp_dir)
assert initial_cwd == abs_temp_work_dir
# Create a subdirectory
sub_dir_path = Path(abs_temp_work_dir) / 'subdir'
sub_dir_path.mkdir()
assert sub_dir_path.is_dir()
# Test changing directory
action_cd = CmdRunAction(command='Set-Location subdir')
result_cd = windows_bash_session.execute(action_cd)
assert isinstance(result_cd, CmdOutputObservation)
assert result_cd.exit_code == 0
# Check that the session's internal CWD state was updated - only check the last component of path
assert windows_bash_session._cwd.lower().endswith('\\subdir')
# Check that the metadata reflects the directory *after* the command
assert result_cd.metadata.working_dir.lower().endswith('\\subdir')
# Execute a command in the new directory to confirm
action_pwd = CmdRunAction(command='(Get-Location).Path')
result_pwd = windows_bash_session.execute(action_pwd)
assert isinstance(result_pwd, CmdOutputObservation)
assert result_pwd.exit_code == 0
# Check the command output reflects the new directory
assert result_pwd.content.strip().lower().endswith('\\subdir')
# Metadata should also reflect the current directory
assert result_pwd.metadata.working_dir.lower().endswith('\\subdir')
# Test changing back to original directory
action_cd_back = CmdRunAction(command=f"Set-Location '{abs_temp_work_dir}'")
result_cd_back = windows_bash_session.execute(action_cd_back)
assert isinstance(result_cd_back, CmdOutputObservation)
assert result_cd_back.exit_code == 0
# Check only the base name of the temp directory
temp_dir_basename = os.path.basename(abs_temp_work_dir)
assert windows_bash_session._cwd.lower().endswith(temp_dir_basename.lower())
assert result_cd_back.metadata.working_dir.lower().endswith(
temp_dir_basename.lower()
)
def test_cleanup(windows_bash_session):
"""Test proper cleanup of resources (runspace)."""
# Session should be initialized before close
assert windows_bash_session._initialized
assert windows_bash_session.runspace is not None
# Close the session
windows_bash_session.close()
# Verify cleanup
assert not windows_bash_session._initialized
assert windows_bash_session.runspace is None
assert windows_bash_session._closed
def test_syntax_error_handling(windows_bash_session):
"""Test handling of syntax errors in PowerShell commands."""
# Test invalid command syntax
action = CmdRunAction(command="Write-Output 'Missing Quote")
result = windows_bash_session.execute(action)
assert isinstance(result, ErrorObservation)
# Error message appears in the output via PowerShell error stream
assert 'missing' in result.content.lower() or 'terminator' in result.content.lower()
def test_special_characters_handling(windows_bash_session):
"""Test handling of commands containing special characters."""
# Test command with special characters
special_chars_cmd = '''Write-Output "Special Chars: \\`& \\`| \\`< \\`> \\`\\` \\`' \\`\" \\`! \\`$ \\`% \\`^ \\`( \\`) \\`- \\`= \\`+ \\`[ \\`] \\`{ \\`} \\`; \\`: \\`, \\`. \\`? \\`/ \\`~"'''
action = CmdRunAction(command=special_chars_cmd)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check output contains the special characters
assert 'Special Chars:' in result.content
assert '&' in result.content and '|' in result.content
assert result.exit_code == 0
def test_empty_command(windows_bash_session):
"""Test handling of empty command string when no command is running."""
action = CmdRunAction(command='')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Should indicate error as per test_bash.py behavior
assert 'ERROR: No previous running command to retrieve logs from.' in result.content
# Exit code is typically 0 even for this specific "error" message in the bash implementation
assert result.exit_code == 0
def test_exception_during_execution(windows_bash_session):
"""Test handling of exceptions during command execution."""
# Patch the PowerShell class itself within the module where it's used
patch_target = 'openhands.runtime.utils.windows_bash.PowerShell'
# Create a mock PowerShell class
mock_powershell_class = MagicMock()
# Configure its Create method (which is called in execute) to raise an exception
# This simulates an error during the creation of the PowerShell object itself.
mock_powershell_class.Create.side_effect = Exception(
'Test exception from mocked Create'
)
with patch(patch_target, mock_powershell_class):
action = CmdRunAction(command="Write-Output 'Test'")
# Now, when execute calls PowerShell.Create(), it will hit our mock and raise the exception
result = windows_bash_session.execute(action)
# The exception should be caught by the try...except block in execute()
assert isinstance(result, ErrorObservation)
# Check the error message generated by the execute method's exception handler
assert 'Failed to start PowerShell job' in result.content
assert 'Test exception from mocked Create' in result.content
def test_streaming_output(windows_bash_session):
"""Test handling of streaming output from commands."""
# Command that produces output incrementally
command = """
1..3 | ForEach-Object {
Write-Output "Line $_"
Start-Sleep -Milliseconds 100
}
"""
action = CmdRunAction(command=command)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
assert 'Line 1' in result.content
assert 'Line 2' in result.content
assert 'Line 3' in result.content
assert result.exit_code == 0
def test_shutdown_signal_handling(windows_bash_session):
"""Test handling of shutdown signal during command execution."""
# This would require mocking the shutdown_listener, which might be complex.
# For now, we'll just verify that a long-running command can be executed
# and that execute() returns properly.
command = 'Start-Sleep -Seconds 1'
action = CmdRunAction(command=command)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
assert result.exit_code == 0
def test_runspace_state_after_error(windows_bash_session):
"""Test that the runspace remains usable after a command error."""
# First, execute a command with an error
error_action = CmdRunAction(command='NonExistentCommand')
error_result = windows_bash_session.execute(error_action)
assert isinstance(error_result, CmdOutputObservation)
assert error_result.exit_code == 1
# Then, execute a valid command
valid_action = CmdRunAction(command="Write-Output 'Still working'")
valid_result = windows_bash_session.execute(valid_action)
assert isinstance(valid_result, CmdOutputObservation)
assert 'Still working' in valid_result.content
assert valid_result.exit_code == 0
def test_stateful_file_operations(windows_bash_session, temp_dir):
"""Test file operations to verify runspace state persistence.
This test verifies that:
1. The working directory state persists between commands
2. File operations work correctly relative to the current directory
3. The runspace maintains state for path-dependent operations
"""
abs_temp_work_dir = os.path.abspath(temp_dir)
# 1. Create a subdirectory
sub_dir_name = 'file_test_dir'
sub_dir_path = Path(abs_temp_work_dir) / sub_dir_name
# Use PowerShell to create directory
create_dir_action = CmdRunAction(
command=f'New-Item -Path "{sub_dir_name}" -ItemType Directory'
)
result = windows_bash_session.execute(create_dir_action)
assert result.exit_code == 0
# Verify directory exists on disk
assert sub_dir_path.exists() and sub_dir_path.is_dir()
# 2. Change to the new directory
cd_action = CmdRunAction(command=f"Set-Location '{sub_dir_name}'")
result = windows_bash_session.execute(cd_action)
assert result.exit_code == 0
# Check only the last directory component
assert windows_bash_session._cwd.lower().endswith(f'\\{sub_dir_name.lower()}')
# 3. Create a file in the current directory (which should be the subdirectory)
test_content = 'This is a test file created by PowerShell'
create_file_action = CmdRunAction(
command=f'Set-Content -Path "test_file.txt" -Value "{test_content}"'
)
result = windows_bash_session.execute(create_file_action)
assert result.exit_code == 0
# 4. Verify file exists at the expected path (in the subdirectory)
expected_file_path = sub_dir_path / 'test_file.txt'
assert expected_file_path.exists() and expected_file_path.is_file()
# 5. Read file contents using PowerShell and verify
read_file_action = CmdRunAction(command='Get-Content -Path "test_file.txt"')
result = windows_bash_session.execute(read_file_action)
assert result.exit_code == 0
assert test_content in result.content
# 6. Go back to parent and try to access file using relative path
cd_parent_action = CmdRunAction(command='Set-Location ..')
result = windows_bash_session.execute(cd_parent_action)
assert result.exit_code == 0
# Check only the base name of the temp directory
temp_dir_basename = os.path.basename(abs_temp_work_dir)
assert windows_bash_session._cwd.lower().endswith(temp_dir_basename.lower())
# 7. Read the file using relative path
read_from_parent_action = CmdRunAction(
command=f'Get-Content -Path "{sub_dir_name}/test_file.txt"'
)
result = windows_bash_session.execute(read_from_parent_action)
assert result.exit_code == 0
assert test_content in result.content
# 8. Clean up
remove_file_action = CmdRunAction(
command=f'Remove-Item -Path "{sub_dir_name}/test_file.txt" -Force'
)
result = windows_bash_session.execute(remove_file_action)
assert result.exit_code == 0
def test_command_output_continuation(windows_bash_session):
"""Test retrieving continued output using empty command after timeout."""
# Windows PowerShell version
action = CmdRunAction('1..5 | ForEach-Object { Write-Output $_; Start-Sleep 3 }')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert obs.content.strip() == '1'
assert obs.metadata.prefix == ''
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
# Continue watching output
action = CmdRunAction('')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '[Below is the output of the previous command.]' in obs.metadata.prefix
assert obs.content.strip() == '2'
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
# Continue until completion
for expected in ['3', '4', '5']:
action = CmdRunAction('')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '[Below is the output of the previous command.]' in obs.metadata.prefix
assert obs.content.strip() == expected
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
# Final empty command to complete
action = CmdRunAction('')
obs = windows_bash_session.execute(action)
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
def test_long_running_command_followed_by_execute(windows_bash_session):
"""Tests behavior when a new command is sent while another is running after timeout."""
# Start a slow command
action = CmdRunAction('1..3 | ForEach-Object { Write-Output $_; Start-Sleep 3 }')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '1' in obs.content # First number should appear before timeout
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
assert obs.metadata.prefix == ''
# Continue watching output
action = CmdRunAction('')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '2' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
# Test command that produces no output
action = CmdRunAction('sleep 15')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '3' not in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert 'The previous command is still running' in obs.metadata.suffix
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
# Finally continue again
action = CmdRunAction('')
obs = windows_bash_session.execute(action)
assert '3' in obs.content
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
def test_command_non_existent_file(windows_bash_session):
"""Test command execution for a non-existent file returns non-zero exit code."""
# Use Get-Content which should fail if the file doesn't exist
action = CmdRunAction(command='Get-Content non_existent_file.txt')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check that the exit code is non-zero (should be 1 due to the '$?' check)
assert result.exit_code == 1
# Check that the error message is captured in the output (error stream part)
assert 'Cannot find path' in result.content or 'does not exist' in result.content
def test_interactive_input(windows_bash_session):
"""Test interactive input attempt reflects implementation limitations."""
action = CmdRunAction('$name = Read-Host "Enter name"')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
assert (
'A command that prompts the user failed because the host program or the command type does not support user interaction. The host was attempting to request confirmation with the following message'
in result.content
)
assert result.exit_code == 1
def test_windows_path_handling(windows_bash_session, temp_dir):
"""Test that os.chdir works with both forward slashes and escaped backslashes on Windows."""
# Create a test directory
test_dir = Path(temp_dir) / 'test_dir'
test_dir.mkdir()
# Test both path formats
path_formats = [
str(test_dir).replace('\\', '/'), # Forward slashes
str(test_dir).replace('\\', '\\\\'), # Escaped backslashes
]
for path in path_formats:
# Test changing directory using os.chdir through PowerShell
action = CmdRunAction(command=f'python -c "import os; os.chdir(\'{path}\')"')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
assert result.exit_code == 0, f'Failed with path format: {path}'
@@ -1,179 +0,0 @@
import sys
from unittest.mock import patch
import pytest
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.core.config import AgentConfig
from openhands.llm.llm import LLM
# Skip all tests in this module if not running on Windows
pytestmark = pytest.mark.skipif(
sys.platform != 'win32', reason='Windows prompt refinement tests require Windows'
)
@pytest.fixture
def mock_llm():
"""Create a mock LLM for testing."""
llm = LLM(config={'model': 'gpt-4', 'api_key': 'test'})
return llm
@pytest.fixture
def agent_config():
"""Create a basic agent config for testing."""
return AgentConfig()
def test_codeact_agent_system_prompt_no_bash_on_windows(mock_llm, agent_config):
"""Test that CodeActAgent's system prompt doesn't contain 'bash' on Windows."""
# Create a CodeActAgent instance
agent = CodeActAgent(llm=mock_llm, config=agent_config)
# Get the system prompt
system_prompt = agent.prompt_manager.get_system_message()
# Assert that 'bash' doesn't exist in the system prompt (case-insensitive)
assert 'bash' not in system_prompt.lower(), (
f"System prompt contains 'bash' on Windows platform. "
f"It should be replaced with 'powershell'. "
f'System prompt: {system_prompt}'
)
# Verify that 'powershell' exists instead (case-insensitive)
assert 'powershell' in system_prompt.lower(), (
f"System prompt should contain 'powershell' on Windows platform. "
f'System prompt: {system_prompt}'
)
def test_codeact_agent_tool_descriptions_no_bash_on_windows(mock_llm, agent_config):
"""Test that CodeActAgent's tool descriptions don't contain 'bash' on Windows."""
# Create a CodeActAgent instance
agent = CodeActAgent(llm=mock_llm, config=agent_config)
# Get the tools
tools = agent.tools
# Check each tool's description and parameters
for tool in tools:
if tool['type'] == 'function':
function_info = tool['function']
# Check function description
description = function_info.get('description', '')
assert 'bash' not in description.lower(), (
f"Tool '{function_info['name']}' description contains 'bash' on Windows. "
f'Description: {description}'
)
# Check parameter descriptions
parameters = function_info.get('parameters', {})
properties = parameters.get('properties', {})
for param_name, param_info in properties.items():
param_description = param_info.get('description', '')
assert 'bash' not in param_description.lower(), (
f"Tool '{function_info['name']}' parameter '{param_name}' "
f"description contains 'bash' on Windows. "
f'Parameter description: {param_description}'
)
def test_in_context_learning_example_no_bash_on_windows():
"""Test that in-context learning examples don't contain 'bash' on Windows."""
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
from openhands.agenthub.codeact_agent.tools.finish import FinishTool
from openhands.agenthub.codeact_agent.tools.str_replace_editor import (
create_str_replace_editor_tool,
)
from openhands.llm.fn_call_converter import get_example_for_tools
# Create a sample set of tools
tools = [
create_cmd_run_tool(),
create_str_replace_editor_tool(),
FinishTool,
]
# Get the in-context learning example
example = get_example_for_tools(tools)
# Assert that 'bash' doesn't exist in the example (case-insensitive)
assert 'bash' not in example.lower(), (
f"In-context learning example contains 'bash' on Windows platform. "
f"It should be replaced with 'powershell'. "
f'Example: {example}'
)
# Verify that 'powershell' exists instead (case-insensitive)
if example: # Only check if example is not empty
assert 'powershell' in example.lower(), (
f"In-context learning example should contain 'powershell' on Windows platform. "
f'Example: {example}'
)
def test_refine_prompt_function_works():
"""Test that the refine_prompt function correctly replaces 'bash' with 'powershell'."""
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
# Test basic replacement
test_prompt = 'Execute a bash command to list files'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert 'powershell' in refined_prompt.lower()
assert refined_prompt == 'Execute a powershell command to list files'
# Test multiple occurrences
test_prompt = 'Use bash to run bash commands in the bash shell'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert (
refined_prompt
== 'Use powershell to run powershell commands in the powershell shell'
)
# Test case sensitivity
test_prompt = 'BASH and Bash and bash should all be replaced'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert (
refined_prompt
== 'powershell and powershell and powershell should all be replaced'
)
# Test execute_bash tool name replacement
test_prompt = 'Use the execute_bash tool to run commands'
refined_prompt = refine_prompt(test_prompt)
assert 'execute_bash' not in refined_prompt.lower()
assert 'execute_powershell' in refined_prompt.lower()
assert refined_prompt == 'Use the execute_powershell tool to run commands'
# Test that words containing 'bash' but not equal to 'bash' are preserved
test_prompt = 'The bashful person likes bash-like syntax'
refined_prompt = refine_prompt(test_prompt)
# 'bashful' should be preserved, 'bash-like' should become 'powershell-like'
assert 'bashful' in refined_prompt
assert 'powershell-like' in refined_prompt
assert refined_prompt == 'The bashful person likes powershell-like syntax'
def test_refine_prompt_function_on_non_windows():
"""Test that the refine_prompt function doesn't change anything on non-Windows platforms."""
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
# Mock sys.platform to simulate non-Windows
with patch('openhands.agenthub.codeact_agent.tools.bash.sys.platform', 'linux'):
test_prompt = 'Execute a bash command to list files'
refined_prompt = refine_prompt(test_prompt)
# On non-Windows, the prompt should remain unchanged
assert refined_prompt == test_prompt
assert 'bash' in refined_prompt.lower()