Compare commits

..

87 Commits

Author SHA1 Message Date
openhands 6903223ef7 Merge main into feature/agent-mode-toggle and resolve conflicts 2025-05-17 06:26:56 +00:00
Justin Coffi bf383b4881 Add SSH Microagent (#8436)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyaoww@gmail.com>
2025-05-17 05:56:51 +00:00
OpenHands c17b0ebfc6 Fix issue #8304: [Bug]: Non-native tool use converter fails when builtin tools are disabled (#8310)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-05-17 06:37:45 +02:00
Xingyao Wang 1f390430e5 feat(MCP, microagent): MCP-support for Repo Microagent & add fetch as default tool (#8360)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-16 23:32:38 +00:00
Robert Brennan 819bad0777 Fix: Only show login modal for genuine 401 errors, not connection issues (#8540)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 13:33:06 -04:00
dependabot[bot] 2faed14139 chore(deps): bump the version-all group with 5 updates (#8541)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 17:17:38 +00:00
tofarr 4733270e3c Add event search endpoints with filtering and pagination (#8538)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 09:51:40 -06:00
Robert Brennan 21dd91de63 Add info logging for 401 Unauthorized responses (#8527)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 11:46:15 -04:00
Graham Neubig 25619c5a93 Fix #8510: Improve error messages for invalid microagent format (#8511)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 15:01:39 +00:00
Robert Brennan 15f71e7ed6 Update microagent message from "Activated" to "ready" (#8536)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 10:44:37 -04:00
mamoodi 5b583e5f27 Organization and user microagents (#8506) 2025-05-16 10:42:32 -04:00
Rohit Malhotra c191a17afb [Fix]: don't access secrets if doesn't exist (#8535)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 14:30:16 +00:00
Robert Brennan 8ec5d0e043 Add WebSocket connection documentation (#8404)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 10:12:05 -04:00
Robert Brennan f3f038bb60 Fix mypy error for pkg_resources import (#8537)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 14:06:41 +00:00
Robert Brennan b8d3027cfe Always enable logout button regardless of GitHub connection status (#8529)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-16 09:09:33 -04:00
Rohit Malhotra feb04dc65f Plumb custom secrets to runtime (#8330)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-15 20:06:30 -04:00
Engel Nyst 1f827170f4 Fix resolver test (#8530) 2025-05-15 21:57:02 +00:00
Engel Nyst f7cb2d0f64 Restore previous conversation in CLI (#8431) 2025-05-15 23:47:41 +02:00
tofarr 033788c2d0 API Updates to facilitate nested runtimes. (#8525) 2025-05-15 15:38:09 -06:00
dependabot[bot] 21d0990be4 chore(deps): bump the version-all group with 7 updates (#8522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-15 20:17:51 +00:00
tofarr 6227073cff Add missing Ukrainian translations for secrets-related keys (#8526)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-15 19:25:06 +00:00
Graham Neubig 4c38113cb7 Fix CI to check for missing translations (#8486)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-15 13:31:44 -04:00
Emmanuel Ferdman fb516dfa0f Remove obsolete task.py file (#8517)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-05-15 18:54:25 +02:00
sp.wack 04d585513c feat: secrets manager settings (#8068)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-05-15 11:30:10 -04:00
Robert Brennan 7a4ea23b9d Revert "Add Docker, Java, Golang, and other programming languages to runtime image" (#8518) 2025-05-15 14:29:15 +00:00
sp.wack 7490c1927f fix(frontend): Failing tests (#8519) 2025-05-15 14:20:52 +00:00
tofarr 8d2ac59909 Fix passing environment (#8483)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-15 07:23:21 -06:00
Ryan H. Tran 68e5f485aa fix: validation error when saving SSE MCP server url on the UI (#8502) 2025-05-15 06:35:06 +00:00
Graham Neubig e4c284f96d Add timeout parameter to bash tool for hard timeout control (#8106)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-05-15 13:24:42 +08:00
Yueqi Song 3ca585b79f Update run_infer.py to incorporate selection of task based on repo (#8509) 2025-05-15 12:27:28 +08:00
tofarr 7e88d4195f Refactor event store cleanup (#8505)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 16:20:43 -06:00
Engel Nyst f046efd53d Revert "Make str_replace_editor description more clear (#8434)" (#8501) 2025-05-15 04:08:16 +08:00
Engel Nyst e4586432ad Add top_k (#8480) 2025-05-14 21:46:03 +02:00
sp.wack d956abe56b fix(frontend): Show actions when idle (#8507) 2025-05-14 18:53:32 +00:00
Robert Brennan 6145552841 Add Docker, Java, Golang, and other programming languages to runtime image (#8026)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 13:46:06 -04:00
dependabot[bot] b1dca48c8e chore(deps): bump the version-all group with 6 updates (#8504)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-14 16:39:01 +00:00
Robert Brennan 81ba80dde0 Fix issue #8327: Unable to delete the entire default branch name in input box (#8329)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 12:07:12 -04:00
mamoodi 08a790c4ca Update default model to sonnet 3.7 in all applicable places (#8489)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 10:55:34 -04:00
Robert Brennan 1b57fd4d1e Remove github_user_id in favor of user_id (#8406)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-14 10:17:35 -04:00
Robert Brennan c36cbf6543 Fix padding on last paragraph in messages (#8491)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-05-14 10:24:21 +00:00
Xingyao Wang 67d438ea4f Fix workspace mount behavior with SANDBOX_VOLUMES (#8500) 2025-05-14 14:58:03 +08:00
Graham Neubig 154eed148f Fix typing in server directory (#8375)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-05-13 21:27:59 +00:00
Graham Neubig f9b0fcd76e Add API documentation link to API Keys tab in settings (#8363)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 20:09:58 +00:00
mamoodi 0782aeb1c4 Update recommended models (#8488)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-13 15:46:22 -04:00
dependabot[bot] 55fbb65e05 chore(deps): bump the version-all group across 1 directory with 12 updates (#8478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 21:20:50 +02:00
mamoodi 1abed30b44 Update MCP docs and LLM docs with more accurate information (#8482) 2025-05-13 13:09:25 -04:00
tofarr 1f29ec836b Fix missing translations in frontend i18n files (#8481)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 17:02:06 +00:00
Rohit Malhotra 81c754ec65 [Fix]: Don't allow endpoint to modify conversation trigger (#8396) 2025-05-13 11:57:32 -04:00
Rohit Malhotra 880ec57c78 [Fix]: Status icon regression (#8427) 2025-05-13 11:43:35 -04:00
mamoodi e06aac7521 Remove unnecessary frontmatter from repo microagent (#8477) 2025-05-13 11:32:35 -04:00
Graham Neubig 60d9b519e0 Add proper typing to cli directory (#8374)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 09:55:44 -04:00
Graham Neubig 5ad11e73b8 Proposed update to resolver prompt (#8473) 2025-05-13 13:48:23 +00:00
Graham Neubig 3e5b16b348 Fix translation completeness issues (#8472)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 09:42:33 -04:00
Graham Neubig f3d0ae3fbf Add type annotations to local runtime implementation (#8376)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 09:42:07 -04:00
Engel Nyst dea3ddfcc6 Clean up codecov (#8465) 2025-05-13 14:51:47 +02:00
kotauchisunsun 31b2f3c9c2 [refactor]: Refactor sandbox configuration setup in IssueResolver class (#8414) 2025-05-13 13:37:15 +02:00
omahs 4bb6ec2ee5 Fix typos (#8469) 2025-05-13 09:34:21 +00:00
Engel Nyst ae8ed49280 Make str_replace_editor description more clear (#8434) 2025-05-13 13:08:53 +07:00
mamoodi 786e21fb8a Add more run eval options (#8468) 2025-05-13 02:33:14 +00:00
Graham Neubig f317c03b1b Fix inconsistent max_iterations in SWE-bench evaluation (#8467)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-13 02:07:57 +00:00
Chase e72153629d fix #8424: change native_tool_calling semantics (#8463) 2025-05-12 19:21:51 -04:00
mamoodi b127d5f656 Add exc_info to remote runtime log (#8457) 2025-05-12 15:45:58 -05:00
tofarr f75fa50b80 Add number of connections to Conversation Info (#8456)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-12 13:57:52 -06:00
mamoodi 5a927c8651 Release 0.38.0 (#8446) 2025-05-12 15:41:14 -04:00
chuckbutkus 2693360ad0 Auth URL fix of on-prem (#8455) 2025-05-12 17:28:04 +00:00
sp.wack 1081f8091d improve(frontend): Changes tab status message logic (#8454) 2025-05-12 12:57:35 -04:00
sp.wack 8d0e5c6c34 hotfix: Don't handle git changes side effect too frequently (#8451) 2025-05-12 12:57:00 -04:00
Robert Brennan 0b897ff3dc Add 10px bottom padding to paragraph tags in markdown rendering (#8440)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-12 11:03:17 -04:00
Ryan H. Tran c5ace563c4 fix: remove duplicate rendering of tab components (#8442)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-05-12 11:15:41 +00:00
Engel Nyst 9af132933c Fix log for clearing pending action to be at the same level (#8430) 2025-05-12 02:50:36 +02:00
Engel Nyst 10c56932af Fix: update pre-commit docs (#8433)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-12 02:13:33 +02:00
Robert Brennan e9905115c4 Allow websocket connection to pass in Authorization header to conversation validator (#8405)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-11 16:27:02 -04:00
Emmanuel Ferdman 6b11fff735 Resolve warnings of logger library (#8432)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-05-11 15:29:53 +00:00
Xingyao Wang 3d02c0c3a3 Fix issue #8372: Implement browser screenshot saving functionality (#8383)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-05-11 15:51:18 +08:00
kotauchisunsun a17c57d82e [refactor]: Refactored the initialization of issue_handler within IssueResolver (#8417)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-05-10 21:27:06 -04:00
Muly Oved da637a0dad Update docker_runtime.py #8422 (#8423) 2025-05-11 00:08:14 +02:00
Polly 27c49471a8 Fix Bug #8425 - Enable prompt cache for OpenRouter model of calude-3.7-sonnet (#8426) 2025-05-11 00:07:31 +02:00
Robert Brennan bffe8de597 Add support for user/org level microagents (#8402)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-10 09:34:34 -04:00
OpenHands f0bb7de1c6 Fix issue #8145: Add docs about runtime tests (#8146) 2025-05-10 12:40:35 +02:00
Polly 90aab29bc0 Fix Issue #8413 max_output_tokens in openrouter/anthropic/claude-3.7-sonnet doesn't work correctly (#8415) 2025-05-10 08:29:39 +00:00
sp.wack ade059bfba feat/fix(fontend): Get public repos via repo URL (#8223)
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-05-09 23:45:33 +00:00
openhands 22420ce6a7 Add backend implementation for agent mode toggle feature 2025-04-25 21:23:55 +00:00
Xingyao Wang ef360c1f67 Merge branch 'main' into feature/agent-mode-toggle 2025-04-25 17:18:38 -04:00
openhands b202407a3d Fix TypeScript errors in test files by adding required agent state properties 2025-04-23 01:54:23 +00:00
openhands 52cb8341fb Update agent mode toggle design document with implementation status 2025-04-23 01:40:13 +00:00
openhands d37dfc49c0 Add agent mode toggle functionality with notifications 2025-04-23 01:39:27 +00:00
openhands 01b4729095 Add design document for agent mode toggle feature 2025-04-23 01:32:13 +00:00
249 changed files with 7308 additions and 2287 deletions
-19
View File
@@ -1,19 +0,0 @@
codecov:
notify:
wait_for_ci: true
# our project is large, so 6 builds are typically uploaded. this waits till 5/6
# See https://docs.codecov.com/docs/notifications#section-preventing-notifications-until-after-n-builds
after_n_builds: 5
coverage:
status:
patch:
default:
threshold: 100% # allow patch coverage to be lower than project coverage by any amount
project:
default:
threshold: 5% # allow project coverage to drop at most 5%
comment: false
github_checks:
annotations: false
+4
View File
@@ -13,6 +13,10 @@ updates:
browsergym:
patterns:
- "browsergym*"
mcp-packages:
patterns:
- "mcp"
- "mcpm"
security-all:
applies-to: "security-updates"
patterns:
-4
View File
@@ -42,7 +42,3 @@ jobs:
- name: Run tests and collect coverage
working-directory: ./frontend
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+2 -10
View File
@@ -312,11 +312,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
@@ -381,11 +377,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge
+2 -1
View File
@@ -30,11 +30,12 @@ jobs:
run: |
cd frontend
npm install --frozen-lockfile
- name: Lint and TypeScript compilation
- name: Lint, TypeScript compilation, and translation checks
run: |
cd frontend
npm run lint
npm run make-i18n && tsc
npm run check-translation-completeness
# Run lint on the python code
lint-python:
+1 -5
View File
@@ -48,11 +48,7 @@ jobs:
- name: Build Environment
run: make build
- name: Run Tests
run: poetry run pytest --forked -n auto --cov=openhands --cov-report=xml -svv ./tests/unit
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: poetry run pytest --forked -n auto -svv ./tests/unit
# Run specific Windows python tests
test-on-windows:
+8 -6
View File
@@ -9,7 +9,7 @@ on:
jobs:
trigger-job:
name: Trigger remote eval job
if: ${{ github.event.label.name == 'run-eval-xs' || github.event.label.name == 'run-eval-s' || github.event.label.name == 'run-eval-m' }}
if: ${{ github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100' }}
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
@@ -26,12 +26,14 @@ jobs:
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"
if [[ "${{ github.event.label.name }}" == "run-eval-xs" ]]; then
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-s" ]]; then
EVAL_INSTANCES="5"
elif [[ "${{ github.event.label.name }}" == "run-eval-m" ]]; then
EVAL_INSTANCES="30"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
curl -X POST \
+1 -6
View File
@@ -1,8 +1,3 @@
---
name: repo
type: repo
agent: CodeActAgent
---
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
@@ -14,7 +9,7 @@ IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-p
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
+18 -13
View File
@@ -1,8 +1,8 @@
# Development Guide
This guide is for people working on OpenHands and editing the source code.
If you wish to contribute your changes, check out the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) on how to clone and setup the project initially before moving on.
Otherwise, you can clone the OpenHands project directly.
If you wish to contribute your changes, check out the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) on how to clone and setup the project
initially before moving on. Otherwise, you can clone the OpenHands project directly.
## Start the Server for Development
@@ -21,7 +21,8 @@ Make sure you have all these dependencies installed before moving on to `make bu
#### Develop without sudo access
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJs`, you can use `conda` or `mamba` to manage the packages for you:
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJs`, you can use
`conda` or `mamba` to manage the packages for you:
```bash
# Download and install Mamba (a faster version of conda)
@@ -36,7 +37,8 @@ mamba install conda-forge::poetry
### 2. Build and Setup The Environment
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures that OpenHands is ready to run on your system:
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
that OpenHands is ready to run on your system:
```bash
make build
@@ -45,8 +47,6 @@ make build
### 3. Configuring the Language Model
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
By default, we've chosen Claude Sonnet 3.5 as our go-to model, but the world is your oyster! You can unleash the
potential of any other LM that piques your interest.
To configure the LM of your choice, run:
@@ -54,9 +54,12 @@ To configure the LM of your choice, run:
make setup-config
```
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI, please set the model in the UI.
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
please set the model in the UI.
Note: If you have previously run OpenHands using the docker command, you may have already set some environmental variables in your terminal. The final configurations are set from highest to lowest priority:
Note: If you have previously run OpenHands using the docker command, you may have already set some environmental
variables in your terminal. The final configurations are set from highest to lowest priority:
Environment variables > config.toml variables > default variables
**Note on Alternative Models:**
@@ -74,13 +77,15 @@ make run
#### Option B: Individual Server Startup
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on backend-related tasks or configurations.
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
backend-related tasks or configurations.
```bash
make start-backend
```
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related components or interface enhancements.
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
components or interface enhancements.
```bash
make start-frontend
```
@@ -115,10 +120,10 @@ poetry run pytest ./tests/unit/test_*.py
### 9. Use existing Docker image
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.
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/all-hands-ai/runtime:0.37-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.38-nikolaik`
## Develop inside Docker container
+1 -6
View File
@@ -3,17 +3,12 @@ These are the procedures and guidelines on how issues are triaged in this repo b
## General
* All issues must be tagged with **enhancement**, **bug** or **troubleshooting/help**.
* Issues may be tagged with what it relates to (**agent quality**, **frontend**, **resolver**, etc.).
* Issues may be tagged with what it relates to (**agent quality**, **resolver**, **CLI**, etc.).
## Severity
* **Low**: Minor issues or affecting single user.
* **Medium**: Affecting multiple users.
* **High**: High visibility issues or affecting many users.
* **Critical**: Affecting all users or potential security issues.
## Effort
* Issues may be estimated with effort required (**small effort**, **medium effort**, **large effort**).
## Difficulty
* Issues with low implementation difficulty may be tagged with **good first issue**.
+3 -3
View File
@@ -51,17 +51,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.37-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.38-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.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.38-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-state for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+249
View File
@@ -0,0 +1,249 @@
# Agent Mode Toggle Design Document
## Overview
This document outlines the design for implementing a toggle switch between "Read-only mode" and "Execute mode" in the OpenHands application. This feature will allow users to switch between a restricted ReadOnlyAgent that can only explore and analyze code, and the fully capable CodeActAgent that can modify code and execute commands.
## Motivation
Users often want to explore a codebase and discuss implementation details with the agent before making any changes. The ability to switch between read-only and execute modes provides several benefits:
1. **Safety**: Users can ensure no changes are made during the exploration phase
2. **Clarity**: Clear indication of the agent's current capabilities
3. **Control**: Users decide when to transition from planning to execution
4. **Workflow**: Supports a natural workflow of exploration → planning → implementation
## Architecture
The implementation will leverage the existing agent delegation mechanism in OpenHands. When a user toggles the switch:
1. In **Execute Mode** (default): The application uses the standard CodeActAgent
2. In **Read-only Mode**: The application delegates to a ReadOnlyAgent
### Key Components
#### Frontend
1. **Toggle Switch Component**:
- UI element that shows the current mode and allows switching
- Sends appropriate actions to the event stream when toggled
2. **Agent State Tracking**:
- Redux state to track current agent type and delegation status
- Event listeners to update state based on event stream
3. **Visual Indicators**:
- Mode indicator showing current agent mode
- Visual styling differences between modes
#### Backend
1. **Agent Delegation**:
- Uses existing delegation mechanism to switch to ReadOnlyAgent
- User-initiated FinishAction to end delegation and return to CodeActAgent
2. **Event Stream Integration**:
- AgentDelegateAction to start read-only mode
- AgentFinishAction to end read-only mode
- System messages to indicate mode changes
## Implementation Details
### Frontend Implementation
#### Redux State Extension
```typescript
interface AgentState {
curAgentState: AgentState;
currentAgentType: string; // Track the agent type
isDelegated: boolean; // Track if we're in a delegation
// other existing fields...
}
const initialState: AgentState = {
curAgentState: AgentState.IDLE,
currentAgentType: "CodeActAgent", // Default agent type
isDelegated: false,
// other initial values...
};
```
#### Action Generators
```typescript
export const generateDelegateToReadOnlyAction = () => ({
action: ActionType.DELEGATE,
args: {
agent: "ReadOnlyAgent",
inputs: {
task: "Continue the conversation in READ-ONLY MODE. You can explore and analyze code but cannot make changes."
},
thought: "Switching to read-only mode at user's request"
}
});
export const generateFinishDelegationAction = () => ({
action: ActionType.FINISH,
args: {
message: "Switching back to EXECUTE MODE. You now have full capabilities to modify code and execute commands.",
task_completed: "true",
outputs: {
mode_switch: true
}
}
});
```
#### Toggle Switch Component
```tsx
function AgentModeToggle() {
const { t } = useTranslation();
const dispatch = useDispatch();
const { send } = useWsClient();
// Get agent type from Redux
const { currentAgentType, isDelegated } = useSelector((state: RootState) => state.agent);
// Compute if we're in read-only mode
const isReadOnly = currentAgentType === "ReadOnlyAgent";
const handleToggle = () => {
if (isReadOnly) {
// Currently in read-only mode, switch back to execute mode
send(generateFinishDelegationAction());
} else {
// Currently in execute mode, switch to read-only mode
send(generateDelegateToReadOnlyAction());
}
};
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{isReadOnly ? "Read-Only Mode" : "Execute Mode"}
</span>
<Switch
checked={isReadOnly}
onChange={handleToggle}
className={`${isReadOnly ? 'bg-amber-600' : 'bg-blue-600'} relative inline-flex h-6 w-11 items-center rounded-full`}
>
<span className="sr-only">Toggle agent mode</span>
<span
className={`${isReadOnly ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition`}
/>
</Switch>
</div>
);
}
```
#### Event Listener for State Updates
```typescript
function handleEvent(event) {
// Handle agent delegation events
if (event.action === ActionType.DELEGATE) {
// A delegation is starting
dispatch(setDelegationState(true));
dispatch(setAgentType(event.args.agent));
}
// Handle agent delegate observation (delegation ended)
else if (event.observation === "delegate") {
// Delegation has ended, returning to parent agent
dispatch(setDelegationState(false));
dispatch(setAgentType("CodeActAgent")); // Reset to default agent
}
// Handle other events...
}
```
### Backend Considerations
The backend implementation will leverage the existing agent delegation mechanism:
1. When the user toggles to read-only mode:
- An AgentDelegateAction is sent to the event stream
- The AgentController creates a ReadOnlyAgent delegate
- All subsequent events are handled by the delegate
2. When the user toggles back to execute mode:
- An AgentFinishAction is sent to the event stream
- The delegate agent finishes its task
- The parent AgentController resumes normal operation
No backend code changes are required as we're using the existing delegation mechanism.
## User Experience
1. **Initial State**: The application starts in Execute Mode with CodeActAgent
2. **Mode Switching**:
- User clicks the toggle switch to enter Read-only Mode
- System message indicates the mode change
- Agent capabilities are restricted to read-only tools
- UI shows visual indicators of the current mode
- User clicks the toggle switch again to return to Execute Mode
- System message indicates the return to full capabilities
3. **Visual Indicators**:
- Toggle switch position (left/right)
- Color coding (amber for read-only, blue for execute)
- Mode label text
- System messages in the conversation
## Future Enhancements
1. **Persistent Mode Preference**: Remember the user's preferred starting mode
2. **Context Preservation**: Improve context retention when switching modes
3. **Custom Tool Sets**: Allow users to customize which tools are available in each mode
4. **Mode-specific Prompts**: Optimize agent prompts for each mode
## Implementation Plan
1. **Frontend Implementation**:
- Add Redux state for agent type tracking ✅
- Create toggle switch component ✅
- Implement event listeners for state updates ✅
- Add visual indicators for current mode ✅
- Add notifications for mode changes ✅
2. **Testing**:
- Test mode switching with various conversation states
- Verify proper tool restrictions in read-only mode
- Test persistence across page refreshes
3. **Documentation**:
- Update user documentation to explain the mode toggle feature
- Add developer documentation for the implementation details ✅
## Implementation Status
The agent mode toggle feature has been implemented with the following components:
1. **Redux State**:
- Added `currentAgentType` and `isDelegated` properties to the agent slice
- Default agent type is set to "CodeActAgent"
2. **Agent Mode Service**:
- Created `agent-mode-service.ts` with action generators for delegation
- Implemented `generateDelegateToReadOnlyAction()` and `generateFinishDelegationAction()`
3. **UI Components**:
- Created `AgentModeToggle` component with toggle switch UI
- Integrated toggle into the agent control bar
- Updated agent status bar to display current mode
- Added color coding (amber for read-only, blue for execute)
4. **Event Handling**:
- Updated `use-handle-ws-events.ts` to process agent delegation events
- Added state updates when delegation starts/ends
- Added notifications to inform users of mode changes
5. **Internationalization**:
- Added translations for all UI elements
- Supported multiple languages through i18n
The implementation is complete and ready for testing. The feature allows users to seamlessly switch between read-only and execute modes during a conversation, with clear visual indicators and notifications of the current mode.
@@ -37,7 +37,7 @@ Pour exécuter OpenHands en mode CLI avec Docker :
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -46,7 +46,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.cli
```
@@ -34,7 +34,7 @@ Pour exécuter OpenHands en mode Headless avec Docker :
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -58,17 +58,17 @@ Un système avec un processeur moderne et un minimum de **4 Go de RAM** est reco
La façon la plus simple d'exécuter OpenHands est dans Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
Vous trouverez OpenHands en cours d'exécution à l'adresse http://localhost:3000 !
@@ -36,7 +36,7 @@ DockerでOpenHandsをCLIモードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.cli
```
@@ -33,7 +33,7 @@ DockerでヘッドレスモードでOpenHandsを実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -58,17 +58,17 @@ OpenHandsを実行するには、最新のプロセッサと最低**4GB RAM**を
OpenHandsを実行する最も簡単な方法はDockerを使用することです。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
OpenHandsは http://localhost:3000 で実行されています!
@@ -37,7 +37,7 @@ Para executar o OpenHands no modo CLI com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -46,7 +46,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.cli
```
@@ -34,7 +34,7 @@ Para executar o OpenHands em modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
Você encontrará o OpenHands rodando em http://localhost:3000!
@@ -36,7 +36,7 @@ poetry run python -m openhands.core.cli
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.cli
```
@@ -33,7 +33,7 @@ poetry run python -m openhands.core.main -t "write a bash script that prints hi"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -58,17 +58,17 @@
运行 OpenHands 最简单的方法是使用 Docker。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
OpenHands 将在 http://localhost:3000 运行!
+2 -2
View File
@@ -31,7 +31,7 @@ This command opens an interactive prompt where you can type tasks or commands an
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -40,7 +40,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.cli.main
```
+2 -2
View File
@@ -31,7 +31,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -41,7 +41,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.37 \
docker.all-hands.dev/all-hands-ai/openhands:0.38 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -0,0 +1,181 @@
---
sidebar_position: 9
---
# Connecting to the WebSocket
This guide explains how to connect to the OpenHands WebSocket API to receive real-time events and send actions to the agent.
## Overview
OpenHands uses [Socket.IO](https://socket.io/) for WebSocket communication between the client and server. The WebSocket connection allows you to:
1. Receive real-time events from the agent
2. Send user actions to the agent
3. Maintain a persistent connection for ongoing conversations
## Connecting to the WebSocket
### Connection Parameters
When connecting to the WebSocket, you need to provide the following query parameters:
- `conversation_id`: The ID of the conversation you want to join
- `latest_event_id`: The ID of the latest event you've received (use `-1` for a new connection)
- `providers_set`: (Optional) A comma-separated list of provider types
### Connection Example
Here's a basic example of connecting to the WebSocket using JavaScript:
```javascript
import { io } from "socket.io-client";
const socket = io("http://localhost:3000", {
transports: ["websocket"],
query: {
conversation_id: "your-conversation-id",
latest_event_id: -1,
providers_set: "github,gitlab" // Optional
}
});
socket.on("connect", () => {
console.log("Connected to OpenHands WebSocket");
});
socket.on("oh_event", (event) => {
console.log("Received event:", event);
});
socket.on("connect_error", (error) => {
console.error("Connection error:", error);
});
socket.on("disconnect", (reason) => {
console.log("Disconnected:", reason);
});
```
## Sending Actions to the Agent
To send an action to the agent, use the `oh_user_action` event:
```javascript
// Send a user message to the agent
socket.emit("oh_user_action", {
type: "message",
source: "user",
message: "Hello, can you help me with my project?"
});
```
## Receiving Events from the Agent
The server emits events using the `oh_event` event type. Here are some common event types you might receive:
- User messages (`source: "user", type: "message"`)
- Agent messages (`source: "agent", type: "message"`)
- File edits (`action: "edit"`)
- File writes (`action: "write"`)
- Command executions (`action: "run"`)
Example event handler:
```javascript
socket.on("oh_event", (event) => {
if (event.source === "agent" && event.type === "message") {
console.log("Agent says:", event.message);
} else if (event.action === "run") {
console.log("Command executed:", event.args.command);
console.log("Result:", event.result);
}
});
```
## Using Websocat for Testing
[Websocat](https://github.com/vi/websocat) is a command-line tool for interacting with WebSockets. It's useful for testing your WebSocket connection without writing a full client application.
### Installation
```bash
# On macOS
brew install websocat
# On Linux
curl -L https://github.com/vi/websocat/releases/download/v1.11.0/websocat.x86_64-unknown-linux-musl > websocat
chmod +x websocat
sudo mv websocat /usr/local/bin/
```
### Connecting to the WebSocket
```bash
# Connect to the WebSocket and print all received messages
echo "40{}" | \
websocat "ws://localhost:3000/socket.io/?EIO=4&transport=websocket&conversation_id=your-conversation-id&latest_event_id=-1"
```
### Sending a Message
```bash
# Send a message to the agent
echo '42["oh_user_action",{"type":"message","source":"user","message":"Hello, agent!"}]' | \
websocat "ws://localhost:3000/socket.io/?EIO=4&transport=websocket&conversation_id=your-conversation-id&latest_event_id=-1"
```
### Complete Example with Websocat
Here's a complete example of connecting to the WebSocket, sending a message, and receiving events:
```bash
# Start a persistent connection
websocat -v "ws://localhost:3000/socket.io/?EIO=4&transport=websocket&conversation_id=your-conversation-id&latest_event_id=-1"
# In another terminal, send a message
echo '42["oh_user_action",{"type":"message","source":"user","message":"Can you help me with my project?"}]' | \
websocat "ws://localhost:3000/socket.io/?EIO=4&transport=websocket&conversation_id=your-conversation-id&latest_event_id=-1"
```
## Event Structure
Events sent and received through the WebSocket follow a specific structure:
```typescript
interface OpenHandsEvent {
id: string; // Unique event ID
source: string; // "user" or "agent"
timestamp: string; // ISO timestamp
message?: string; // For message events
type?: string; // Event type (e.g., "message")
action?: string; // Action type (e.g., "run", "edit", "write")
args?: any; // Action arguments
result?: any; // Action result
}
```
## Best Practices
1. **Handle Reconnection**: Implement reconnection logic in your client to handle network interruptions.
2. **Track Event IDs**: Store the latest event ID you've received and use it when reconnecting to avoid duplicate events.
3. **Error Handling**: Implement proper error handling for connection errors and failed actions.
4. **Rate Limiting**: Avoid sending too many actions in a short period to prevent overloading the server.
## Troubleshooting
### Connection Issues
- Verify that the OpenHands server is running and accessible
- Check that you're providing the correct conversation ID
- Ensure your WebSocket URL is correctly formatted
### Authentication Issues
- Make sure you have the necessary authentication cookies if required
- Verify that you have permission to access the specified conversation
### Event Handling Issues
- Check that you're correctly parsing the event data
- Verify that your event handlers are properly registered
+3 -3
View File
@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.37-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.38-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.37
docker.all-hands.dev/all-hands-ai/openhands:0.38
```
You'll find OpenHands running at http://localhost:3000!
+3 -2
View File
@@ -1,6 +1,7 @@
# Azure
OpenHands uses LiteLLM to make calls to Azure's chat models. You can find their documentation on using Azure as a provider [here](https://docs.litellm.ai/docs/providers/azure).
OpenHands uses LiteLLM to make calls to Azure's chat models. You can find their documentation on using Azure as a
provider [here](https://docs.litellm.ai/docs/providers/azure).
## Azure OpenAI Configuration
@@ -18,7 +19,7 @@ docker run -it --pull=always \
...
```
Then in the OpenHands UI Settings:
Then in the OpenHands UI Settings under the `LLM` tab:
:::note
You will need your ChatGPT deployment name which can be found on the deployments page in Azure. This is referenced as
+6 -4
View File
@@ -7,10 +7,11 @@ OpenHands uses LiteLLM to make calls to Google's chat models. You can find their
## Gemini - Google AI Studio Configs
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `Gemini`
- `LLM Model` to the model you will be using.
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. gemini/&lt;model-name&gt; like `gemini/gemini-2.0-flash`).
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
(e.g. gemini/&lt;model-name&gt; like `gemini/gemini-2.0-flash`).
- `API Key` to your Gemini API key
## VertexAI - Google Cloud Platform Configs
@@ -24,7 +25,8 @@ VERTEXAI_PROJECT="<your-gcp-project-id>"
VERTEXAI_LOCATION="<your-gcp-location>"
```
Then set the following in the OpenHands UI through the Settings:
Then set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `VertexAI`
- `LLM Model` to the model you will be using.
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. vertex_ai/&lt;model-name&gt;).
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
(e.g. vertex_ai/&lt;model-name&gt;).
+6 -7
View File
@@ -1,22 +1,21 @@
# Groq
OpenHands uses LiteLLM to make calls to chat models on Groq. You can find their documentation on using Groq as a provider [here](https://docs.litellm.ai/docs/providers/groq).
OpenHands uses LiteLLM to make calls to chat models on Groq. You can find their documentation on using Groq as a
provider [here](https://docs.litellm.ai/docs/providers/groq).
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `Groq`
- `LLM Model` to the model you will be using. [Visit here to see the list of
models that Groq hosts](https://console.groq.com/docs/models). If the model is not in the list, toggle
`Advanced` options, and enter it in `Custom Model` (e.g. groq/&lt;model-name&gt; like `groq/llama3-70b-8192`).
models that Groq hosts](https://console.groq.com/docs/models). If the model is not in the list,
enable `Advanced` options, and enter it in `Custom Model` (e.g. groq/&lt;model-name&gt; like `groq/llama3-70b-8192`).
- `API key` to your Groq API key. To find or create your Groq API Key, [see here](https://console.groq.com/keys).
## Using Groq as an OpenAI-Compatible Endpoint
The Groq endpoint for chat completion is [mostly OpenAI-compatible](https://console.groq.com/docs/openai). Therefore, you can access Groq models as you
would access any OpenAI-compatible endpoint. In the OpenHands UI through the Settings:
would access any OpenAI-compatible endpoint. In the OpenHands UI through the Settings under the `LLM` tab:
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to the prefix `openai/` + the model you will be using (e.g. `openai/llama3-70b-8192`)
+3 -2
View File
@@ -7,7 +7,7 @@ OpenHands supports using the [LiteLLM proxy](https://docs.litellm.ai/docs/proxy/
To use LiteLLM proxy with OpenHands, you need to:
1. Set up a LiteLLM proxy server (see [LiteLLM documentation](https://docs.litellm.ai/docs/proxy/quick_start))
2. When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
2. When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
* Enable `Advanced` options
* `Custom Model` to the prefix `litellm_proxy/` + the model you will be using (e.g. `litellm_proxy/anthropic.claude-3-5-sonnet-20241022-v2:0`)
* `Base URL` to your LiteLLM proxy URL (e.g. `https://your-litellm-proxy.com`)
@@ -15,6 +15,7 @@ To use LiteLLM proxy with OpenHands, you need to:
## Supported Models
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy is configured to handle.
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy
is configured to handle.
Refer to your LiteLLM proxy configuration for the list of available models and their names.
+4 -6
View File
@@ -11,14 +11,12 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
Based on these findings and community feedback, the following models have been verified to work reasonably well with OpenHands:
Based on these findings and community feedback, these are the latest models that have been verified to work reasonably well with OpenHands:
- [anthropic/claude-3-7-sonnet-20250219](https://www.anthropic.com/api) (recommended)
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
- [openai/o3-mini](https://openai.com/index/openai-o3-mini/)
- [openai/o3](https://openai.com/index/introducing-o3-and-o4-mini/)
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
- [all-hands/openhands-lm-32b-v0.1](https://www.all-hands.dev/blog/introducing-openhands-lm-32b----a-strong-open-coding-agent-model) -- available through [OpenRouter](https://openrouter.ai/all-hands/openhands-lm-32b-v0.1)
@@ -27,8 +25,8 @@ OpenHands will issue many prompts to the LLM you configure. Most of these LLMs c
limits and monitor usage.
:::
If you have successfully run OpenHands with specific LLMs not in the list, please add them to the verified list. We
also encourage you to open a PR to share your setup process to help others using the same provider and LLM!
If you have successfully run OpenHands with specific providers, we encourage you to open a PR to share your setup process
to help others using the same provider!
For a full list of the providers and models available, please consult the
[litellm documentation](https://docs.litellm.ai/docs/providers).
+1 -1
View File
@@ -75,7 +75,7 @@ Start OpenHands using `make run`.
### Configure OpenHands
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings:
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
1. Enable `Advanced` options.
2. Set the following:
- `Custom Model` to `openai/<served-model-name>` (e.g. `openai/openhands-lm-32b-v0.1`)
+5 -4
View File
@@ -1,14 +1,15 @@
# OpenAI
OpenHands uses LiteLLM to make calls to OpenAI's chat models. You can find their documentation on using OpenAI as a provider [here](https://docs.litellm.ai/docs/providers/openai).
OpenHands uses LiteLLM to make calls to OpenAI's chat models. You can find their documentation on using OpenAI as a
provider [here](https://docs.litellm.ai/docs/providers/openai).
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
* `LLM Provider` to `OpenAI`
* `LLM Model` to the model you will be using.
[Visit here to see a full list of OpenAI models that LiteLLM supports.](https://docs.litellm.ai/docs/providers/openai#openai-chat-completion-models)
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. openai/&lt;model-name&gt; like `openai/gpt-4o`).
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model` (e.g. openai/&lt;model-name&gt; like `openai/gpt-4o`).
* `API Key` to your OpenAI API key. To find or create your OpenAI Project API Key, [see here](https://platform.openai.com/api-keys).
## Using OpenAI-Compatible Endpoints
@@ -17,7 +18,7 @@ Just as for OpenAI Chat completions, we use LiteLLM for OpenAI-compatible endpoi
## Using an OpenAI Proxy
If you're using an OpenAI proxy, in the OpenHands UI through the Settings:
If you're using an OpenAI proxy, in the OpenHands UI through the Settings under the `LLM` tab:
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to openai/&lt;model-name&gt; (e.g. `openai/gpt-4o` or openai/&lt;proxy-prefix&gt;/&lt;model-name&gt;)
+5 -3
View File
@@ -1,12 +1,14 @@
# OpenRouter
OpenHands uses LiteLLM to make calls to chat models on OpenRouter. You can find their documentation on using OpenRouter as a provider [here](https://docs.litellm.ai/docs/providers/openrouter).
OpenHands uses LiteLLM to make calls to chat models on OpenRouter. You can find their documentation on using
OpenRouter as a provider [here](https://docs.litellm.ai/docs/providers/openrouter).
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
* `LLM Provider` to `OpenRouter`
* `LLM Model` to the model you will be using.
[Visit here to see a full list of OpenRouter models](https://openrouter.ai/models).
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. openrouter/&lt;model-name&gt; like `openrouter/anthropic/claude-3.5-sonnet`).
If the model is not in the list, enable `Advanced` options, and enter it in
`Custom Model` (e.g. openrouter/&lt;model-name&gt; like `openrouter/anthropic/claude-3.5-sonnet`).
* `API Key` to your OpenRouter API key.
+5 -3
View File
@@ -13,9 +13,11 @@ or custom tools. MCP is based on the open standard defined at [modelcontextproto
## Configuration
MCP configuration is defined in the `[mcp]` section of your `config.toml` file.
MCP configuration can be defined in:
* The OpenHands UI through the Settings under the `MCP` tab.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Example
### Configuration Example via config.toml
```toml
[mcp]
@@ -82,7 +84,7 @@ Stdio servers are configured using an object with the following properties:
When OpenHands starts, it:
1. Reads the MCP configuration from `config.toml`.
1. Reads the MCP configuration.
2. Connects to any configured SSE servers.
3. Starts any configured stdio servers.
4. Registers the tools provided by these servers with the agent.
@@ -0,0 +1,23 @@
# Organization and User Microagents
## Purpose
Organizations and users can define microagents that apply to all repositories belonging to the organization or user.
## Usage
These microagents can be [any type of microagent](./microagents-overview#microagent-types) and will be loaded
accordingly. However, they are applied to all repositories belonging to the organization or user.
Add a `.openhands` repository under the organization or user and create a `microagents` directory and place the
microagents in that directory.
## Example
General microagent file example for organization `Great-Co` located inside the `.openhands` repository:
`microagents/org-microagent.md`:
```
* Use type hints and error boundaries; validate inputs at system boundaries and fail with meaningful error messages.
* Document interfaces and public APIs; use implementation comments only for non-obvious logic.
* Follow the same naming convention for variables, classes, constants, etc. already used in each repository.
```
@@ -7,7 +7,7 @@ They provide expert guidance, automate common tasks, and ensure consistent pract
Currently OpenHands supports the following types of microagents:
- [General Repository Microagents](./microagents-repo): General guidelines for OpenHands about the repository.
- [General Microagents](./microagents-repo): General guidelines for OpenHands about the repository.
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts.
To customize OpenHands' behavior, create a .openhands/microagents/ directory in the root of your repository and
@@ -24,7 +24,7 @@ Example repository structure:
some-repository/
└── .openhands/
└── microagents/
└── repo.md # General repository guidelines
└── repo.md # General guidelines
└── trigger_this.md # Microagent triggered by specific keywords
└── trigger_that.md # Microagent triggered by specific keywords
```
@@ -34,7 +34,7 @@ some-repository/
Each microagent file may include frontmatter that provides additional information. In some cases, this frontmatter
is required:
| Microagent Type | Required |
|----------------------------------|----------|
| `General Repository Microagents` | No |
| `Keyword-Triggered Microagents` | Yes |
| Microagent Type | Required |
|---------------------------------|----------|
| `General Microagents` | No |
| `Keyword-Triggered Microagents` | Yes |
@@ -1,4 +1,4 @@
# General Repository Microagents
# General Microagents
## Purpose
@@ -20,7 +20,7 @@ Frontmatter should be enclosed in triple dashes (---) and may include the follow
## Example
General repository microagent file example located at `.openhands/microagents/repo.md`:
General microagent file example located at `.openhands/microagents/repo.md`:
```
This project is a TODO application that allows users to track TODO items.
@@ -28,4 +28,4 @@ To set it up, you can run `npm run build`.
Always make sure the tests are passing before committing changes. You can run the tests by running `npm run test`.
```
[See more examples of general repository microagents here.](https://github.com/All-Hands-AI/OpenHands/tree/main/.openhands/microagents)
[See more examples of general microagents here.](https://github.com/All-Hands-AI/OpenHands/tree/main/.openhands/microagents)
+11 -1
View File
@@ -70,7 +70,7 @@ const sidebars: SidebarsConfig = {
},
{
type: 'doc',
label: 'General Repository Microagents',
label: 'General Microagents',
id: 'usage/prompting/microagents-repo',
},
{
@@ -78,6 +78,11 @@ const sidebars: SidebarsConfig = {
label: 'Keyword-Triggered Microagents',
id: 'usage/prompting/microagents-keyword',
},
{
type: 'doc',
label: 'Organization and User Microagents',
id: 'usage/prompting/microagents-org',
},
{
type: 'doc',
label: 'Global Microagents',
@@ -267,6 +272,11 @@ const sidebars: SidebarsConfig = {
label: 'Evaluation',
id: 'usage/how-to/evaluation-harness',
},
{
type: 'doc',
label: 'WebSocket Connection',
id: 'usage/how-to/websocket-connection',
},
],
},
{
+55
View File
@@ -0,0 +1,55 @@
# Agent Mode Toggle
The Agent Mode Toggle feature allows you to switch between two different agent modes:
1. **Execute Mode** (default): Full capabilities with the CodeActAgent, which can modify code and execute commands
2. **Read-only Mode**: Restricted capabilities with the ReadOnlyAgent, which can only explore and analyze code
## Why Use Different Modes?
- **Safety**: Ensure no changes are made during the exploration phase
- **Clarity**: Clear indication of the agent's current capabilities
- **Control**: Decide when to transition from planning to execution
- **Workflow**: Support a natural workflow of exploration → planning → implementation
## How to Use
1. **Toggle Switch**: Click the toggle switch in the agent control bar to switch between modes
- Blue toggle: Execute Mode (default)
- Amber toggle: Read-only Mode
2. **Mode Indicators**:
- The current mode is displayed in the agent status bar
- System messages indicate when the mode changes
## Available Tools in Each Mode
### Execute Mode (CodeActAgent)
All tools are available, including:
- File editing (`str_replace_editor`)
- Command execution (`execute_bash`)
- Python code execution (`execute_ipython_cell`)
- Web browsing (`browser`, `web_read`)
- Thinking and finishing (`think`, `finish`)
### Read-only Mode (ReadOnlyAgent)
Only non-destructive tools are available:
- File viewing (`view`)
- File searching (`grep`, `glob`)
- Web reading (`web_read`)
- Thinking and finishing (`think`, `finish`)
## Best Practices
1. **Start in Read-only Mode** for new codebases to safely explore without making changes
2. **Switch to Execute Mode** when you're ready to implement changes
3. **Return to Read-only Mode** when you want to explore different parts of the codebase
## Technical Details
The agent mode toggle uses OpenHands' agent delegation mechanism:
- When toggling to Read-only Mode, the system delegates to a ReadOnlyAgent
- When toggling back to Execute Mode, the delegation ends and returns to the CodeActAgent
- Context is preserved between mode switches
@@ -17,7 +17,7 @@ RUN git checkout 4eddc7db6449a5ade3e37285747c8b208cd54ce7
RUN micromamba create -n sci-agent python=3.10 pip setuptools wheel
RUN micromamba run -n sci-agent pip install -r requirements.txt
# Replace all occurence of conda with micromamba under the /workspace
# Replace all occurrences of conda with micromamba under the /workspace
RUN find ./ -type f -exec sed -i 's/conda/micromamba/g' {} \;
# pushd evaluation/scienceagentbench
+4 -4
View File
@@ -63,7 +63,7 @@ to `CodeActAgent`.
default, the script evaluates the entire SWE-bench_Lite test set (300 issues). Note:
in order to use `eval_limit`, you must also set `agent`.
- `max_iter`, e.g. `20`, is the maximum number of iterations for the agent to run. By
default, it is set to 60.
default, it is set to 100.
- `num_workers`, e.g. `3`, is the number of parallel workers to run the evaluation. By
default, it is set to 1.
- `dataset`, a huggingface dataset name. e.g. `princeton-nlp/SWE-bench`, `princeton-nlp/SWE-bench_Lite`, `princeton-nlp/SWE-bench_Verified`, or `princeton-nlp/SWE-bench_Multimodal`, specifies which dataset to evaluate on.
@@ -102,9 +102,9 @@ Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZj
```bash
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [max_iter] [num_workers] [dataset] [dataset_split]
# Example - This runs evaluation on CodeActAgent for 300 instances on "princeton-nlp/SWE-bench_Lite"'s test set, with max 30 iteration per instances, with 16 number of workers running in parallel
# Example - This runs evaluation on CodeActAgent for 300 instances on "princeton-nlp/SWE-bench_Lite"'s test set, with max 100 iteration per instances, with 16 number of workers running in parallel
ALLHANDS_API_KEY="YOUR-API-KEY" RUNTIME=remote SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" EVAL_DOCKER_IMAGE_PREFIX="us-central1-docker.pkg.dev/evaluation-092424/swe-bench-images" \
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh llm.eval HEAD CodeActAgent 300 30 16 "princeton-nlp/SWE-bench_Lite" test
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh llm.eval HEAD CodeActAgent 300 100 16 "princeton-nlp/SWE-bench_Lite" test
```
To clean-up all existing runtime you've already started, run:
@@ -176,7 +176,7 @@ Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZj
# Example - This evaluates patches generated by CodeActAgent on Llama-3.1-70B-Instruct-Turbo on "princeton-nlp/SWE-bench_Lite"'s test set, with 16 number of workers running in parallel
ALLHANDS_API_KEY="YOUR-API-KEY" RUNTIME=remote SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" EVAL_DOCKER_IMAGE_PREFIX="us-central1-docker.pkg.dev/evaluation-092424/swe-bench-images" \
evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/swe-bench-lite/CodeActAgent/Llama-3.1-70B-Instruct-Turbo_maxiter_30_N_v1.9-no-hint/output.jsonl 16 "princeton-nlp/SWE-bench_Lite" "test"
evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/swe-bench-lite/CodeActAgent/Llama-3.1-70B-Instruct-Turbo_maxiter_100_N_v1.9-no-hint/output.jsonl 16 "princeton-nlp/SWE-bench_Lite" "test"
```
To clean-up all existing runtimes that you've already started, run:
@@ -714,6 +714,19 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
subset = dataset[dataset[filter_column].isin(selected_ids)]
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
return subset
if 'selected_repos' in data:
# repos for the swe-bench instances:
# ['astropy/astropy', 'django/django', 'matplotlib/matplotlib', 'mwaskom/seaborn', 'pallets/flask', 'psf/requests', 'pydata/xarray', 'pylint-dev/pylint', 'pytest-dev/pytest', 'scikit-learn/scikit-learn', 'sphinx-doc/sphinx', 'sympy/sympy']
selected_repos = data['selected_repos']
if isinstance(selected_repos, str): selected_repos = [selected_repos]
assert isinstance(selected_repos, list)
logger.info(
f'Filtering {selected_repos} tasks from "selected_repos"...'
)
subset = dataset[dataset["repo"].isin(selected_repos)]
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
return subset
skip_ids = os.environ.get('SKIP_IDS', '').split(',')
if len(skip_ids) > 0:
logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...')
@@ -26,8 +26,8 @@ if [ -z "$AGENT" ]; then
fi
if [ -z "$MAX_ITER" ]; then
echo "MAX_ITER not specified, use default 60"
MAX_ITER=60
echo "MAX_ITER not specified, use default 100"
MAX_ITER=100
fi
if [ -z "$RUN_WITH_BROWSING" ]; then
+1
View File
@@ -2,6 +2,7 @@
echo "Running frontend checks..."
cd frontend
npm run check-unlocalized-strings
npm run check-translation-completeness
npx lint-staged
# Run backend pre-commit
+1 -1
View File
@@ -61,7 +61,7 @@ make build
# Start the application
make run
```
Or to run backend and frontend seperately.
Or to run backend and frontend separately.
```sh
# Start the backend from the root directory
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { ConversationProvider } from "#/context/conversation-context";
// Mock dependencies
vi.mock("posthog-js", () => ({
@@ -38,12 +39,20 @@ vi.mock("react-i18next", () => ({
}),
}));
vi.mock("react-router", () => ({
useParams: () => ({
conversationId: "test-conversation-id",
}),
}));
const renderActionSuggestions = () =>
render(<ActionSuggestions onSuggestionsClick={() => {}} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
<ConversationProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</ConversationProvider>
),
});
@@ -65,6 +74,11 @@ describe("ActionSuggestions", () => {
});
it("should render both GitHub buttons when GitHub token is set and repository is selected", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
// @ts-expect-error - only required for testing
getConversationSpy.mockResolvedValue({
selected_repository: "test-repo",
});
renderActionSuggestions();
// Find all buttons with data-testid="suggestion"
@@ -20,7 +20,6 @@ describe("AccountSettingsContextMenu", () => {
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
@@ -35,7 +34,6 @@ describe("AccountSettingsContextMenu", () => {
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
@@ -45,19 +43,18 @@ describe("AccountSettingsContextMenu", () => {
expect(onLogoutMock).toHaveBeenCalledOnce();
});
test("onLogout should be disabled if the user is not logged in", async () => {
test("logout button is always enabled", async () => {
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn={false}
/>,
);
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).not.toHaveBeenCalled();
expect(onLogoutMock).toHaveBeenCalledOnce();
});
it("should call onClose when clicking outside of the element", async () => {
@@ -65,7 +62,6 @@ describe("AccountSettingsContextMenu", () => {
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
@@ -1,89 +0,0 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitRepositorySelector } from "#/components/features/git/git-repo-selector";
import OpenHands from "#/api/open-hands";
import { Provider } from "#/types/settings";
describe("GitRepositorySelector", () => {
const onInputChangeMock = vi.fn();
const onSelectMock = vi.fn();
it("should render the search input", () => {
renderWithProviders(
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
userRepositories={[]}
/>,
);
expect(
screen.getByPlaceholderText("LANDING$SELECT_GIT_REPO"),
).toBeInTheDocument();
});
it("should show the GitHub login button in OSS mode", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
APP_SLUG: "openhands",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderWithProviders(
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
userRepositories={[]}
/>,
);
expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument();
});
it("should show the search results", () => {
const mockSearchedRepos = [
{
id: 1,
full_name: "test/repo1",
git_provider: "github" as Provider,
stargazers_count: 100,
is_public: true,
pushed_at: "2023-01-01T00:00:00Z",
},
{
id: 2,
full_name: "test/repo2",
git_provider: "github" as Provider,
stargazers_count: 200,
is_public: true,
pushed_at: "2023-01-02T00:00:00Z",
},
];
const searchPublicRepositoriesSpy = vi.spyOn(
OpenHands,
"searchGitRepositories",
);
searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos);
renderWithProviders(
<GitRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
userRepositories={[]}
/>,
);
expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument();
});
});
@@ -43,7 +43,6 @@ describe("HomeHeader", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
"gui",
undefined,
undefined,
undefined,
@@ -173,7 +173,6 @@ describe("RepoConnector", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
"gui",
"rbren/polaris",
"github",
undefined,
@@ -0,0 +1,259 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, vi, beforeEach, it } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
// Create mock functions
const mockUseUserRepositories = vi.fn();
const mockUseCreateConversation = vi.fn();
const mockUseIsCreatingConversation = vi.fn();
const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseCreateConversation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
});
mockUseIsCreatingConversation.mockReturnValue(false);
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
useCreateConversation: () => mockUseCreateConversation(),
}));
vi.mock("#/hooks/use-is-creating-conversation", () => ({
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => mockUseTranslation(),
}));
vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock("#/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const mockOnRepoSelection = vi.fn();
const renderForm = () =>
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
}
>
{children}
</QueryClientProvider>
),
});
describe("RepositorySelectionForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading indicator when repositories are being fetched", () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
});
it("shows dropdown when repositories are loaded", async () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
});
it("shows error message when repository fetch fails", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockRejectedValue(
new Error("Failed to load"),
);
renderForm();
expect(
await screen.findByTestId("repo-dropdown-error"),
).toBeInTheDocument();
expect(
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
).toBeInTheDocument();
});
it("should call the search repos API when searching a URL", async () => {
const MOCK_REPOS: GitRepository[] = [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const MOCK_SEARCH_REPOS: GitRepository[] = [
{
id: 3,
full_name: "kubernetes/kubernetes",
git_provider: "github",
is_public: true,
},
];
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
const input = await screen.findByTestId("repo-dropdown");
await userEvent.click(input);
for (const repo of MOCK_REPOS) {
expect(screen.getByText(repo.full_name)).toBeInTheDocument();
}
expect(
screen.queryByText(MOCK_SEARCH_REPOS[0].full_name),
).not.toBeInTheDocument();
expect(searchGitReposSpy).not.toHaveBeenCalled();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
);
expect(
screen.getByText(MOCK_SEARCH_REPOS[0].full_name),
).toBeInTheDocument();
for (const repo of MOCK_REPOS) {
expect(screen.queryByText(repo.full_name)).not.toBeInTheDocument();
}
});
it("should call onRepoSelection when a searched repository is selected", async () => {
const MOCK_SEARCH_REPOS: GitRepository[] = [
{
id: 3,
full_name: "kubernetes/kubernetes",
git_provider: "github",
is_public: true,
},
];
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
renderForm();
const input = await screen.findByTestId("repo-dropdown");
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
);
const searchedRepo = screen.getByText(MOCK_SEARCH_REPOS[0].full_name);
expect(searchedRepo).toBeInTheDocument();
await userEvent.click(searchedRepo);
expect(mockOnRepoSelection).toHaveBeenCalledWith(
MOCK_SEARCH_REPOS[0].full_name,
);
});
});
@@ -85,7 +85,6 @@ describe("TaskCard", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
"suggested_task",
MOCK_RESPOSITORIES[0].full_name,
MOCK_RESPOSITORIES[0].git_provider,
undefined,
@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
// Mock the react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey, components }: { i18nKey: string; components: Record<string, React.ReactNode> }) => {
// Simplified Trans component that renders the link
if (i18nKey === "SETTINGS$API_KEYS_DESCRIPTION") {
return (
<span>
API keys allow you to authenticate with the OpenHands API programmatically.
Keep your API keys secure; anyone with your API key can access your account.
For more information on how to use the API, see our {components.a}
</span>
);
}
return <span>{i18nKey}</span>;
},
};
});
// Mock the API keys hook
vi.mock("#/hooks/query/use-api-keys", () => ({
useApiKeys: () => ({
data: [],
isLoading: false,
error: null,
}),
}));
describe("ApiKeysManager", () => {
const renderComponent = () => {
const queryClient = new QueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ApiKeysManager />
</QueryClientProvider>
);
};
it("should render the API documentation link", () => {
renderComponent();
// Find the link to the API documentation
const link = screen.getByRole("link");
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://docs.all-hands.dev/modules/usage/cloud/cloud-api");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
@@ -57,7 +57,7 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
test("onLogout should not be called when the user is not logged in", async () => {
test("logout button is always enabled", async () => {
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
@@ -66,6 +66,6 @@ describe("UserActions", () => {
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).not.toHaveBeenCalled();
expect(onLogoutMock).toHaveBeenCalledOnce();
});
});
+15 -3
View File
@@ -59,7 +59,11 @@ describe("useTerminal", () => {
it("should render", () => {
renderWithProviders(<TestTerminalComponent commands={[]} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
agent: {
curAgentState: AgentState.RUNNING,
currentAgentType: "CodeActAgent",
isDelegated: false
},
cmd: { commands: [] },
},
});
@@ -73,7 +77,11 @@ describe("useTerminal", () => {
renderWithProviders(<TestTerminalComponent commands={commands} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
agent: {
curAgentState: AgentState.RUNNING,
currentAgentType: "CodeActAgent",
isDelegated: false
},
cmd: { commands },
},
});
@@ -100,7 +108,11 @@ describe("useTerminal", () => {
/>,
{
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
agent: {
curAgentState: AgentState.RUNNING,
currentAgentType: "CodeActAgent",
isDelegated: false
},
cmd: { commands },
},
},
@@ -11,7 +11,6 @@ describe("Translations", () => {
<AccountSettingsContextMenu
onLogout={() => {}}
onClose={() => {}}
isLoggedIn
/>,
);
expect(
@@ -48,7 +48,7 @@ describe("Content", () => {
await waitFor(() => {
expect(provider).toHaveValue("Anthropic");
expect(model).toHaveValue("claude-3-5-sonnet-20241022");
expect(model).toHaveValue("claude-3-7-sonnet-20250219");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
@@ -135,7 +135,7 @@ describe("Content", () => {
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("anthropic/claude-3-5-sonnet-20241022");
expect(model).toHaveValue("anthropic/claude-3-7-sonnet-20250219");
expect(baseUrl).toHaveValue("");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
@@ -542,7 +542,7 @@ describe("Form submission", () => {
// select model
await userEvent.click(model);
const modelOption = screen.getByText("claude-3-5-sonnet-20241022");
const modelOption = screen.getByText("claude-3-7-sonnet-20250219");
await userEvent.click(modelOption);
const submitButton = screen.getByTestId("submit-button");
@@ -550,7 +550,7 @@ describe("Form submission", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "anthropic/claude-3-5-sonnet-20241022",
llm_model: "anthropic/claude-3-7-sonnet-20250219",
llm_base_url: "",
confirmation_mode: false,
}),
@@ -0,0 +1,565 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub, Outlet } from "react-router";
import SecretsSettingsScreen from "#/routes/secrets-settings";
import { SecretsService } from "#/api/secrets-service";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
{
name: "My_Secret_1",
description: "My first secret",
},
{
name: "My_Secret_2",
description: "My second secret",
},
];
const RouterStub = createRoutesStub([
{
Component: Outlet,
path: "/settings",
children: [
{
Component: SecretsSettingsScreen,
path: "/settings/secrets",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
],
},
]);
const renderSecretsSettings = () =>
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
{children}
</QueryClientProvider>
),
});
beforeEach(() => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
});
describe("Content", () => {
it("should render the secrets settings screen", () => {
renderSecretsSettings();
screen.getByTestId("secrets-settings-screen");
});
it("should NOT render a button to connect with git if they havent already in oss", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderSecretsSettings();
expect(getConfigSpy).toHaveBeenCalled();
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
});
it("should render a button to connect with git if they havent already in saas", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderSecretsSettings();
expect(getSecretsSpy).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);
const button = await screen.findByTestId("connect-git-button");
await userEvent.click(button);
screen.getByTestId("git-settings-screen");
});
it("should render a message if there are no existing secrets", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
await screen.findByTestId("no-secrets-message");
});
it("should render existing secrets", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
const secrets = await screen.findAllByTestId("secret-item");
expect(secrets).toHaveLength(2);
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
});
describe("Secret actions", () => {
it("should create a new secret", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
createSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
const secrets = screen.queryAllByTestId("secret-item");
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
expect(secretForm).toBeInTheDocument();
expect(secrets).toHaveLength(0);
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const descriptionInput =
within(secretForm).getByTestId("description-input");
const submitButton = within(secretForm).getByTestId("submit-button");
vi.clearAllMocks(); // reset mocks to check for upcoming calls
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.type(descriptionInput, "My custom secret description");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).toHaveBeenCalledWith(
"My_Custom_Secret",
"my-custom-secret-value",
"My custom secret description",
);
// hide form & render items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
expect(getSecretsSpy).toHaveBeenCalled();
});
it("should edit a secret", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const updateSecretSpy = vi.spyOn(SecretsService, "updateSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
updateSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// enter details
const nameInput = within(editForm).getByTestId("name-input");
const descriptionInput = within(editForm).getByTestId("description-input");
const submitButton = within(editForm).getByTestId("submit-button");
// should not show value input
const valueInput = within(editForm).queryByTestId("value-input");
expect(valueInput).not.toBeInTheDocument();
expect(nameInput).toHaveValue("My_Secret_1");
expect(descriptionInput).toHaveValue("My first secret");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Edited_Secret");
await userEvent.clear(descriptionInput);
await userEvent.type(descriptionInput, "My edited secret description");
await userEvent.click(submitButton);
// make POST request
expect(updateSecretSpy).toHaveBeenCalledWith(
"My_Secret_1",
"My_Edited_Secret",
"My edited secret description",
);
// hide form
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
// optimistic update
const updatedSecrets = await screen.findAllByTestId("secret-item");
expect(updatedSecrets).toHaveLength(2);
expect(updatedSecrets[0]).toHaveTextContent(/my_edited_secret/i);
});
it("should be able to cancel the create or edit form", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// cancel button
const cancelButton = within(secretForm).getByTestId("cancel-button");
await userEvent.click(cancelButton);
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-secret-button")).toBeInTheDocument();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// cancel button
const cancelEditButton = within(editForm).getByTestId("cancel-button");
await userEvent.click(cancelEditButton);
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
});
it("should undo the optimistic update if the request fails", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const updateSecretSpy = vi.spyOn(SecretsService, "updateSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
updateSecretSpy.mockRejectedValue(new Error("Failed to update secret"));
renderSecretsSettings();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// enter details
const nameInput = within(editForm).getByTestId("name-input");
const submitButton = within(editForm).getByTestId("submit-button");
// should not show value input
const valueInput = within(editForm).queryByTestId("value-input");
expect(valueInput).not.toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Edited_Secret");
await userEvent.click(submitButton);
// make POST request
expect(updateSecretSpy).toHaveBeenCalledWith(
"My_Secret_1",
"My_Edited_Secret",
"My first secret",
);
// hide form
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
// no optimistic update
const updatedSecrets = await screen.findAllByTestId("secret-item");
expect(updatedSecrets).toHaveLength(2);
expect(updatedSecrets[0]).not.toHaveTextContent(/my edited secret/i);
});
it("should remove the secret from the list after deletion", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const confirmButton =
within(confirmationModal).getByTestId("confirm-button");
await userEvent.click(confirmButton);
// make DELETE request
expect(deleteSecretSpy).toHaveBeenCalledWith("My_Secret_2");
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
// optimistic update
expect(screen.queryAllByTestId("secret-item")).toHaveLength(1);
expect(screen.queryByText("My_Secret_2")).not.toBeInTheDocument();
});
it("should be able to cancel the delete confirmation modal", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const cancelButton = within(confirmationModal).getByTestId("cancel-button");
await userEvent.click(cancelButton);
// no DELETE request
expect(deleteSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
});
it("should revert the optimistic update if the request fails", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockRejectedValue(new Error("Failed to delete secret"));
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const confirmButton =
within(confirmationModal).getByTestId("confirm-button");
await userEvent.click(confirmButton);
// make DELETE request
expect(deleteSecretSpy).toHaveBeenCalledWith("My_Secret_2");
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
// optimistic update
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
expect(screen.queryByText("My_Secret_2")).toBeInTheDocument();
});
it("should hide the no items message when in form view", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
it("should not allow spaces in secret names", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, "My Custom Secret With Spaces");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "MyCustomSecret");
await userEvent.click(submitButton);
expect(createSecretSpy).toHaveBeenCalledWith(
"MyCustomSecret",
"my-custom-secret-value",
undefined,
);
});
it("should not allow existing secret names", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE.slice(0, 1));
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, "My_Secret_1");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByText(/secret already exists/i)).toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.clear(valueInput);
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
expect(createSecretSpy).toHaveBeenCalledWith(
"My_Custom_Secret",
"my-custom-secret-value",
undefined,
);
expect(
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
).not.toBeInTheDocument();
});
it("should not submit whitespace secret names or values", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, " ");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.clear(valueInput);
await userEvent.type(valueInput, " ");
await userEvent.click(submitButton);
expect(createSecretSpy).not.toHaveBeenCalled();
expect(
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
).toBeInTheDocument();
});
it("should not reset ipout values on an invalid submit", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, MOCK_GET_SECRETS_RESPONSE[0].name);
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByText(/secret already exists/i)).toBeInTheDocument();
expect(nameInput).toHaveValue(MOCK_GET_SECRETS_RESPONSE[0].name);
expect(valueInput).toHaveValue("my-custom-secret-value");
});
});
+8 -2
View File
@@ -79,7 +79,7 @@ describe("Settings Screen", () => {
};
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "git", "application"];
const sectionsToInclude = ["llm", "git", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits"];
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
@@ -110,7 +110,13 @@ describe("Settings Screen", () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
const sectionsToInclude = ["git", "application", "credits", "api keys"];
const sectionsToInclude = [
"git",
"application",
"credits",
"secrets",
"api keys",
];
const sectionsToExclude = ["llm"];
renderSettingsScreen();
@@ -32,7 +32,7 @@ describe("handleObservationMessage", () => {
screenshot: "base64-screenshot-data",
},
};
handleObservationMessage(message);
// Check that setScreenshotSrc and setUrl were called with the correct values
@@ -52,11 +52,11 @@ describe("handleObservationMessage", () => {
screenshot: "base64-screenshot-data",
},
};
handleObservationMessage(message);
// Check that setScreenshotSrc and setUrl were called with the correct values
expect(store.dispatch).toHaveBeenCalledWith(setScreenshotSrc("base64-screenshot-data"));
expect(store.dispatch).toHaveBeenCalledWith(setUrl("https://example.com"));
});
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.37.0",
"version": "0.38.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.37.0",
"version": "0.38.0",
"dependencies": {
"@heroui/react": "2.7.8",
"@microlink/react-json-view": "^1.26.1",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.37.0",
"version": "0.38.0",
"private": true,
"type": "module",
"engines": {
@@ -68,7 +68,8 @@
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
"prepare": "cd .. && husky frontend/.husky",
"typecheck": "react-router typegen && tsc",
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs"
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs",
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
},
"lint-staged": {
"src/**/*.{ts,tsx,js}": [
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Pre-commit hook script to check for translation completeness
* This script ensures that all translation keys have entries for all supported languages
*/
const fs = require('fs');
const path = require('path');
// Load the translation file
const translationJsonPath = path.join(__dirname, '../src/i18n/translation.json');
const translationJson = require(translationJsonPath);
// Load the available languages from the i18n index file
const i18nIndexPath = path.join(__dirname, '../src/i18n/index.ts');
const i18nIndexContent = fs.readFileSync(i18nIndexPath, 'utf8');
// Extract the language codes from the AvailableLanguages array
const languageCodesRegex = /\{ label: "[^"]+", value: "([^"]+)" \}/g;
const supportedLanguageCodes = [];
let match;
while ((match = languageCodesRegex.exec(i18nIndexContent)) !== null) {
supportedLanguageCodes.push(match[1]);
}
// Track missing and extra translations
const missingTranslations = {};
const extraLanguages = {};
let hasErrors = false;
// Check each translation key
Object.entries(translationJson).forEach(([key, translations]) => {
// Get the languages available for this key
const availableLanguages = Object.keys(translations);
// Find missing languages for this key
const missing = supportedLanguageCodes.filter(
(langCode) => !availableLanguages.includes(langCode)
);
if (missing.length > 0) {
missingTranslations[key] = missing;
hasErrors = true;
}
// Find extra languages for this key
const extra = availableLanguages.filter(
(langCode) => !supportedLanguageCodes.includes(langCode)
);
if (extra.length > 0) {
extraLanguages[key] = extra;
hasErrors = true;
}
});
// Generate detailed error message if there are missing translations
if (Object.keys(missingTranslations).length > 0) {
console.error('\x1b[31m%s\x1b[0m', 'ERROR: Missing translations detected');
console.error(`Found ${Object.keys(missingTranslations).length} translation keys with missing languages:`);
Object.entries(missingTranslations).forEach(([key, langs]) => {
console.error(`- Key "${key}" is missing translations for: ${langs.join(', ')}`);
});
console.error('\nPlease add the missing translations before committing.');
}
// Generate detailed error message if there are extra languages
if (Object.keys(extraLanguages).length > 0) {
console.error('\x1b[31m%s\x1b[0m', 'ERROR: Extra languages detected');
console.error(`Found ${Object.keys(extraLanguages).length} translation keys with extra languages not in AvailableLanguages:`);
Object.entries(extraLanguages).forEach(([key, langs]) => {
console.error(`- Key "${key}" has translations for unsupported languages: ${langs.join(', ')}`);
});
console.error('\nPlease remove the extra languages before committing.');
}
// Exit with error code if there are issues
if (hasErrors) {
process.exit(1);
} else {
console.log('\x1b[32m%s\x1b[0m', 'All translation keys have complete language coverage!');
}
@@ -111,6 +111,8 @@ const EXCLUDED_TECHNICAL_STRINGS = [
"GitLab API", // Git provider specific terminology
"Pull Request", // Git provider specific terminology
"GitHub API", // Git provider specific terminology
"add-secret-form", // Test ID for secret form
"edit-secret-form", // Test ID for secret form
];
function isExcludedTechnicalString(str) {
+3 -6
View File
@@ -10,7 +10,6 @@ import {
GetTrajectoryResponse,
GitChangeDiff,
GitChange,
ConversationTrigger,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
@@ -77,9 +76,9 @@ class OpenHands {
): Promise<boolean> {
if (appMode === "oss") return true;
const response =
await openHands.post<AuthenticateResponse>("/api/authenticate");
return response.status === 200;
// Just make the request, if it succeeds (no exception thrown), return true
await openHands.post<AuthenticateResponse>("/api/authenticate");
return true;
}
/**
@@ -144,7 +143,6 @@ class OpenHands {
}
static async createConversation(
conversation_trigger: ConversationTrigger = "gui",
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
@@ -154,7 +152,6 @@ class OpenHands {
selected_branch?: string,
): Promise<Conversation> {
const body = {
conversation_trigger,
repository: selectedRepository,
git_provider,
selected_branch,
+37 -2
View File
@@ -1,8 +1,43 @@
import { Provider, ProviderToken } from "#/types/settings";
import { openHands } from "./open-hands-axios";
import { POSTProviderTokens } from "./secrets-service.types";
import {
CustomSecret,
GetSecretsResponse,
POSTProviderTokens,
} from "./secrets-service.types";
import { Provider, ProviderToken } from "#/types/settings";
export class SecretsService {
static async getSecrets() {
const { data } = await openHands.get<GetSecretsResponse>("/api/secrets");
return data.custom_secrets;
}
static async createSecret(name: string, value: string, description?: string) {
const secret: CustomSecret = {
name,
value,
description,
};
const { status } = await openHands.post("/api/secrets", secret);
return status === 201;
}
static async updateSecret(id: string, name: string, description?: string) {
const secret: Omit<CustomSecret, "value"> = {
name,
description,
};
const { status } = await openHands.put(`/api/secrets/${id}`, secret);
return status === 200;
}
static async deleteSecret(id: string) {
const { status } = await openHands.delete<boolean>(`/api/secrets/${id}`);
return status === 200;
}
static async addGitProvider(providers: Record<Provider, ProviderToken>) {
const tokens: POSTProviderTokens = {
provider_tokens: providers,
+10
View File
@@ -1,5 +1,15 @@
import { Provider, ProviderToken } from "#/types/settings";
export type CustomSecret = {
name: string;
value: string;
description?: string;
};
export interface GetSecretsResponse {
custom_secrets: Omit<CustomSecret, "value">[];
}
export interface POSTProviderTokens {
provider_tokens: Record<Provider, ProviderToken>;
}
@@ -1,11 +1,11 @@
import posthog from "posthog-js";
import React from "react";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import type { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useConversation } from "#/context/conversation-context";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@@ -16,17 +16,13 @@ export function ActionSuggestions({
}: ActionSuggestionsProps) {
const { t } = useTranslation();
const { providers } = useUserProviders();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
);
const { conversationId } = useConversation();
const { data: conversation } = useUserConversation(conversationId);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const providersAreSet = providers.length > 0;
const isGitLab =
selectedRepository !== null &&
selectedRepository.git_provider &&
selectedRepository.git_provider.toLowerCase() === "gitlab";
const isGitLab = providers.includes("gitlab");
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
@@ -45,7 +41,7 @@ export function ActionSuggestions({
return (
<div className="flex flex-col gap-2 mb-2">
{providersAreSet && selectedRepository && (
{providersAreSet && conversation?.selected_repository && (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
@@ -6,6 +6,7 @@ import { cn } from "#/utils/utils";
import { ul, ol } from "../markdown/list";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
import { anchor } from "../markdown/anchor";
import { paragraph } from "../markdown/paragraph";
interface ChatMessageProps {
type: "user" | "assistant";
@@ -64,6 +65,7 @@ export function ChatMessage({
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
>
@@ -15,6 +15,7 @@ import { OpenHandsObservation } from "#/types/core/observations";
import { cn } from "#/utils/utils";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { MonoComponent } from "./mono-component";
import { PathComponent } from "./path-component";
@@ -196,6 +197,7 @@ export function ExpandableMessage({
code,
ul,
ol,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
>
@@ -7,13 +7,11 @@ import { I18nKey } from "#/i18n/declaration";
interface AccountSettingsContextMenuProps {
onLogout: () => void;
onClose: () => void;
isLoggedIn: boolean;
}
export function AccountSettingsContextMenu({
onLogout,
onClose,
isLoggedIn,
}: AccountSettingsContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
@@ -24,7 +22,7 @@ export function AccountSettingsContextMenu({
ref={ref}
className="absolute right-full md:left-full -top-1 z-10 w-fit"
>
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
<ContextMenuListItem onClick={onLogout}>
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
</ContextMenu>
@@ -9,6 +9,7 @@ import { AgentState } from "#/types/agent-state";
import { useWsClient } from "#/context/ws-client-provider";
import { IGNORE_TASK_STATE_MAP } from "#/ignore-task-state-map.constant";
import { ActionButton } from "#/components/shared/buttons/action-button";
import { AgentModeToggle } from "./agent-mode-toggle";
export function AgentControlBar() {
const { t } = useTranslation();
@@ -23,25 +24,29 @@ export function AgentControlBar() {
return (
<div className="flex justify-between items-center gap-20">
<ActionButton
isDisabled={
curAgentState !== AgentState.RUNNING &&
curAgentState !== AgentState.PAUSED
}
content={
curAgentState === AgentState.PAUSED
? t(I18nKey.AGENT$RESUME_TASK)
: t(I18nKey.AGENT$PAUSE_TASK)
}
action={
curAgentState === AgentState.PAUSED
? AgentState.RUNNING
: AgentState.PAUSED
}
handleAction={handleAction}
>
{curAgentState === AgentState.PAUSED ? <PlayIcon /> : <PauseIcon />}
</ActionButton>
<div className="flex items-center gap-4">
<ActionButton
isDisabled={
curAgentState !== AgentState.RUNNING &&
curAgentState !== AgentState.PAUSED
}
content={
curAgentState === AgentState.PAUSED
? t(I18nKey.AGENT$RESUME_TASK)
: t(I18nKey.AGENT$PAUSE_TASK)
}
action={
curAgentState === AgentState.PAUSED
? AgentState.RUNNING
: AgentState.PAUSED
}
handleAction={handleAction}
>
{curAgentState === AgentState.PAUSED ? <PlayIcon /> : <PauseIcon />}
</ActionButton>
<AgentModeToggle />
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { Switch } from "@heroui/react";
import { useWsClient } from "#/context/ws-client-provider";
import { RootState } from "#/store";
import { cn } from "#/utils/utils";
import {
generateDelegateToReadOnlyAction,
generateFinishDelegationAction,
} from "#/services/agent-mode-service";
import { AgentState } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
export function AgentModeToggle() {
const { t } = useTranslation();
const { send } = useWsClient();
// Get agent type and state from Redux
const { currentAgentType, curAgentState } = useSelector(
(state: RootState) => state.agent,
);
// Compute if we're in read-only mode
const isReadOnly = currentAgentType === "ReadOnlyAgent";
// Check if toggle is disabled (should be disabled during certain agent states)
const isDisabled = [
AgentState.LOADING,
AgentState.INIT,
AgentState.ERROR,
AgentState.RATE_LIMITED,
].includes(curAgentState);
const handleToggle = () => {
if (isReadOnly) {
// Currently in read-only mode, switch back to execute mode
send(generateFinishDelegationAction());
} else {
// Currently in execute mode, switch to read-only mode
send(generateDelegateToReadOnlyAction());
}
};
return (
<div className="flex items-center gap-2">
<Switch
isDisabled={isDisabled}
name="agent-mode"
isSelected={isReadOnly}
onValueChange={handleToggle}
classNames={{
thumb: cn("bg-white w-3 h-3"),
wrapper: cn(
"border border-[#D4D4D4] bg-white px-[6px] w-12 h-6",
"group-data-[selected=true]:border-transparent",
isReadOnly
? "group-data-[selected=true]:bg-amber-600"
: "group-data-[selected=true]:bg-blue-600",
),
label: "text-[#A3A3A3] text-xs",
}}
>
<span className="sr-only">{t(I18nKey.AGENT$MODE_TOGGLE_LABEL)}</span>
<span className="text-sm font-medium ml-2">
{isReadOnly
? t(I18nKey.AGENT$MODE_READ_ONLY)
: t(I18nKey.AGENT$MODE_EXECUTE)}
</span>
</Switch>
</div>
);
}
@@ -24,7 +24,9 @@ const notificationStates = [
export function AgentStatusBar() {
const { t, i18n } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState, currentAgentType } = useSelector(
(state: RootState) => state.agent,
);
const { curStatusMessage } = useSelector((state: RootState) => state.status);
const { status } = useWsClient();
const { notify } = useNotification();
@@ -99,6 +101,10 @@ export function AgentStatusBar() {
}
}, [curAgentState, status, notify, t]);
// Determine agent mode badge color
const agentModeBadgeColor =
currentAgentType === "ReadOnlyAgent" ? "bg-amber-600" : "bg-blue-600";
return (
<div className="flex flex-col items-center">
<div className="flex items-center bg-base-secondary px-2 py-1 text-gray-400 rounded-[100px] text-sm gap-[6px]">
@@ -106,6 +112,15 @@ export function AgentStatusBar() {
className={`w-2 h-2 rounded-full animate-pulse ${indicatorColor}`}
/>
<span className="text-sm text-stone-400">{t(statusMessage)}</span>
{/* Agent Mode Badge */}
<div
className={`ml-2 px-2 py-0.5 rounded-full text-xs text-white ${agentModeBadgeColor}`}
>
{currentAgentType === "ReadOnlyAgent"
? t(I18nKey.AGENT$MODE_READ_ONLY)
: t(I18nKey.AGENT$MODE_EXECUTE)}
</div>
</div>
</div>
);
@@ -1,203 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
Spinner,
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { GitRepository } from "#/types/git";
import { Provider, ProviderOptions } from "#/types/settings";
interface GitRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
userRepositories: GitRepository[];
publicRepositories: GitRepository[];
isLoading?: boolean;
}
export function GitRepositorySelector({
onInputChange,
onSelect,
userRepositories,
publicRepositories,
isLoading = false,
}: GitRepositorySelectorProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const allRepositories: GitRepository[] = [
...publicRepositories.filter(
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
// Group repositories by provider
const groupedUserRepos = userRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const groupedPublicRepos = publicRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const dispatch = useDispatch();
const handleRepoSelection = (id: string | null) => {
const repo = allRepositories.find((r) => r.id.toString() === id);
if (repo) {
dispatch(setSelectedRepository(repo));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
const handleClearSelection = () => {
dispatch(setSelectedRepository(null));
};
const emptyContent = isLoading ? (
<div className="flex items-center justify-center py-2">
<Spinner size="sm" className="mr-2" />
<span>{t(I18nKey.GITHUB$LOADING_REPOSITORIES)}</span>
</div>
) : (
t(I18nKey.GITHUB$NO_RESULTS)
);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="Git Repository"
placeholder={t(I18nKey.LANDING$SELECT_GIT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
endContent: isLoading ? <Spinner size="sm" /> : undefined,
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const sanitizedInput = sanitizeQuery(inputValue);
const repo = allRepositories.find((r) => r.full_name === textValue);
if (!repo) return false;
const provider = repo.git_provider?.toLowerCase() as Provider;
const providerKeys = Object.keys(ProviderOptions) as Provider[];
// If input is exactly "git", show repos from any git-based provider
if (sanitizedInput === "git") {
return providerKeys.includes(provider);
}
// Provider based typeahead
for (const p of providerKeys) {
if (p.startsWith(sanitizedInput)) {
return provider === p;
}
}
// Default case: check if the repository name matches the input
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
>
{config?.APP_MODE === "saas" &&
config?.APP_SLUG &&
((
<AutocompleteItem key="install">
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{Object.entries(groupedUserRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`user-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$YOUR_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
{Object.entries(groupedPublicRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`public-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$PUBLIC_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
</Autocomplete>
);
}
@@ -1,77 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { I18nKey } from "#/i18n/declaration";
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
import { GitRepositorySelector } from "./git-repo-selector";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useDebounce } from "#/hooks/use-debounce";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubErrorReponse, GitUser } from "#/types/git";
interface GitRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
gitHubAuthUrl: string | null;
user: GitHubErrorReponse | GitUser | null;
}
export function GitRepositoriesSuggestionBox({
handleSubmit,
gitHubAuthUrl,
user,
}: GitRepositoriesSuggestionBoxProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = React.useState<string>("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// TODO: Use `useQueries` to fetch all repositories in parallel
const { data: userRepositories, isLoading: isUserReposLoading } =
useUserRepositories();
const { data: searchedRepos, isLoading: isSearchReposLoading } =
useSearchRepositories(sanitizeQuery(debouncedSearchQuery));
const isLoading = isUserReposLoading || isSearchReposLoading;
const handleConnectToGitHub = () => {
if (gitHubAuthUrl) {
window.location.href = gitHubAuthUrl;
} else {
navigate("/settings");
}
};
const isLoggedIn = !!user;
return (
<SuggestionBox
title={t(I18nKey.LANDING$OPEN_REPO)}
content={
isLoggedIn ? (
<GitRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
publicRepositories={searchedRepos || []}
userRepositories={userRepositories || []}
isLoading={isLoading}
/>
) : (
<BrandButton
testId="connect-to-github"
type="button"
variant="secondary"
className="w-full text-content border-content"
onClick={handleConnectToGitHub}
startContent={<GitHubLogo width={20} height={20} />}
>
{t(I18nKey.GITHUB$CONNECT)}
</BrandButton>
)
}
/>
);
}
@@ -28,7 +28,7 @@ export function HomeHeader() {
testId="header-launch-button"
variant="primary"
type="button"
onClick={() => createConversation({ conversation_trigger: "gui" })}
onClick={() => createConversation({})}
isDisabled={isCreatingConversation}
>
{!isCreatingConversation && "Launch from Scratch"}
@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RepositorySelectionForm } from "./repo-selection-form";
// Create mock functions
@@ -74,9 +75,16 @@ vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
describe("RepositorySelectionForm", () => {
const mockOnRepoSelection = vi.fn();
const renderRepositorySelectionForm = () =>
render(<RepositorySelectionForm onRepoSelection={vi.fn()} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
describe("RepositorySelectionForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -89,7 +97,7 @@ describe("RepositorySelectionForm", () => {
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
renderRepositorySelectionForm();
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
@@ -117,7 +125,7 @@ describe("RepositorySelectionForm", () => {
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
renderRepositorySelectionForm();
// Check if dropdown is displayed
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
@@ -132,7 +140,7 @@ describe("RepositorySelectionForm", () => {
error: new Error("Failed to fetch repositories"),
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
renderRepositorySelectionForm();
// Check if error message is displayed
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
@@ -6,6 +6,9 @@ import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useDebounce } from "#/hooks/use-debounce";
import { sanitizeQuery } from "#/utils/sanitize-query";
import {
RepositoryDropdown,
RepositoryLoadingState,
@@ -27,6 +30,8 @@ export function RepositorySelectionForm({
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = React.useRef<boolean>(false);
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -45,13 +50,18 @@ export function RepositorySelectionForm({
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
// Auto-select main or master branch if it exists
const [searchQuery, setSearchQuery] = React.useState("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
React.useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
!isLoadingBranches &&
!branchManuallyClearedRef.current // Only auto-select if not manually cleared
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
@@ -64,14 +74,15 @@ export function RepositorySelectionForm({
setSelectedBranch(masterBranch);
}
}
}, [branches, selectedBranch, isLoadingBranches]);
}, [branches, isLoadingBranches, selectedBranch]);
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
const repositoriesItems = repositories?.map((repo) => ({
const allRepositories = repositories?.concat(searchedRepos || []);
const repositoriesItems = allRepositories?.map((repo) => ({
key: repo.id,
label: repo.full_name,
}));
@@ -82,18 +93,21 @@ export function RepositorySelectionForm({
}));
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = repositories?.find(
const selectedRepo = allRepositories?.find(
(repo) => repo.id.toString() === key,
);
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
setSelectedRepository(selectedRepo || null);
setSelectedBranch(null); // Reset branch selection when repo changes
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleRepoInputChange = (value: string) => {
@@ -101,12 +115,22 @@ export function RepositorySelectionForm({
setSelectedRepository(null);
setSelectedBranch(null);
onRepoSelection(null);
} else if (value.startsWith("https://")) {
const repoName = sanitizeQuery(value);
setSearchQuery(repoName);
}
};
const handleBranchInputChange = (value: string) => {
if (value === "") {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
@@ -125,6 +149,15 @@ export function RepositorySelectionForm({
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const repo = allRepositories?.find((r) => r.full_name === textValue);
if (!repo) return false;
const sanitizedInput = sanitizeQuery(inputValue);
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
/>
);
};
@@ -180,7 +213,6 @@ export function RepositorySelectionForm({
onClick={() =>
createConversation({
selectedRepository,
conversation_trigger: "gui",
selected_branch: selectedBranch?.name,
})
}
@@ -5,12 +5,14 @@ export interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
}
export function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
defaultFilter,
}: RepositoryDropdownProps) {
return (
<SettingsDropdownInput
@@ -21,6 +23,7 @@ export function RepositoryDropdown({
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
defaultFilter={defaultFilter}
/>
);
}
@@ -40,7 +40,6 @@ export function TaskCard({ task }: TaskCardProps) {
const repo = getRepo(task.repo, task.git_provider);
return createConversation({
conversation_trigger: "suggested_task",
selectedRepository: repo,
suggested_task: task,
});
@@ -4,6 +4,7 @@ import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { JupyterLine } from "#/utils/parse-cell-content";
import { paragraph } from "../markdown/paragraph";
interface JupyterCellOutputProps {
lines: JupyterLine[];
@@ -25,7 +26,12 @@ export function JupyterCellOutput({ lines }: JupyterCellOutputProps) {
if (line.type === "image") {
return (
<div key={index}>
<Markdown urlTransform={(value: string) => value}>
<Markdown
components={{
p: paragraph,
}}
urlTransform={(value: string) => value}
>
{line.content}
</Markdown>
</div>
@@ -0,0 +1,11 @@
import React from "react";
import { ExtraProps } from "react-markdown";
// Custom component to render <p> in markdown with bottom padding
export function paragraph({
children,
}: React.ClassAttributes<HTMLParagraphElement> &
React.HTMLAttributes<HTMLParagraphElement> &
ExtraProps) {
return <p className="pb-[10px] last:pb-0">{children}</p>;
}
@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -64,7 +64,21 @@ export function ApiKeysManager() {
</div>
<p className="text-sm text-gray-300">
{t(I18nKey.SETTINGS$API_KEYS_DESCRIPTION)}
<Trans
i18nKey={I18nKey.SETTINGS$API_KEYS_DESCRIPTION}
components={{
a: (
<a
href="https://docs.all-hands.dev/modules/usage/cloud/cloud-api"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
API documentation
</a>
),
}}
/>
</p>
{isLoading && (
@@ -51,10 +51,9 @@ export function GitHubTokenInput({
placeholder="github.com"
defaultValue={githubHostSet || undefined}
startContent={
githubHostSet && githubHostSet.trim() !== "" ? (
githubHostSet &&
githubHostSet.trim() !== "" && (
<KeyStatusIcon testId="gh-set-host-indicator" isSet />
) : (
<KeyStatusIcon testId="gh-set-host-indicator" isSet={false} />
)
}
/>
@@ -51,10 +51,9 @@ export function GitLabTokenInput({
placeholder="gitlab.com"
defaultValue={gitlabHostSet || undefined}
startContent={
gitlabHostSet && gitlabHostSet.trim() !== "" ? (
gitlabHostSet &&
gitlabHostSet.trim() !== "" && (
<KeyStatusIcon testId="gl-set-host-indicator" isSet />
) : (
<KeyStatusIcon testId="gl-set-host-indicator" isSet={false} />
)
}
/>
@@ -0,0 +1,202 @@
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
import { useTranslation } from "react-i18next";
import { useCreateSecret } from "#/hooks/mutation/use-create-secret";
import { useUpdateSecret } from "#/hooks/mutation/use-update-secret";
import { SettingsInput } from "../settings-input";
import { cn } from "#/utils/utils";
import { BrandButton } from "../brand-button";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import { OptionalTag } from "../optional-tag";
interface SecretFormProps {
mode: "add" | "edit";
selectedSecret: string | null;
onCancel: () => void;
}
export function SecretForm({
mode,
selectedSecret,
onCancel,
}: SecretFormProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { data: secrets } = useGetSecrets();
const { mutate: createSecret } = useCreateSecret();
const { mutate: updateSecret } = useUpdateSecret();
const [error, setError] = React.useState<string | null>(null);
const secretDescription =
(mode === "edit" &&
selectedSecret &&
secrets
?.find((secret) => secret.name === selectedSecret)
?.description?.trim()) ||
"";
const handleCreateSecret = (
name: string,
value: string,
description?: string,
) => {
createSecret(
{ name, value, description },
{
onSettled: onCancel,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["secrets"] });
},
},
);
};
const updateSecretOptimistically = (
oldName: string,
name: string,
description?: string,
) => {
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
["secrets"],
(oldSecrets) => {
if (!oldSecrets) return [];
return oldSecrets.map((secret) => {
if (secret.name === oldName) {
return {
...secret,
name,
description,
};
}
return secret;
});
},
);
};
const revertOptimisticUpdate = () => {
queryClient.invalidateQueries({ queryKey: ["secrets"] });
};
const handleEditSecret = (
secretToEdit: string,
name: string,
description?: string,
) => {
updateSecretOptimistically(secretToEdit, name, description);
updateSecret(
{ secretToEdit, name, description },
{
onSettled: onCancel,
onError: revertOptimisticUpdate,
},
);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get("secret-name")?.toString();
const value = formData.get("secret-value")?.toString().trim();
const description = formData.get("secret-description")?.toString();
if (name) {
setError(null);
const isNameAlreadyUsed = secrets?.some(
(secret) => secret.name === name && secret.name !== selectedSecret,
);
if (isNameAlreadyUsed) {
setError("Secret already exists");
return;
}
if (mode === "add") {
if (!value) {
setError(t("SECRETS$SECRET_VALUE_REQUIRED"));
return;
}
handleCreateSecret(name, value, description || undefined);
} else if (mode === "edit" && selectedSecret) {
handleEditSecret(selectedSecret, name, description || undefined);
}
}
};
const formTestId = mode === "add" ? "add-secret-form" : "edit-secret-form";
return (
<form
data-testid={formTestId}
onSubmit={handleSubmit}
className="flex flex-col items-start gap-6"
>
<SettingsInput
testId="name-input"
name="secret-name"
type="text"
label="Name"
className="w-[350px]"
required
defaultValue={mode === "edit" && selectedSecret ? selectedSecret : ""}
placeholder="e.g. OpenAI_API_Key"
pattern="^\S*$"
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
{mode === "add" && (
<label className="flex flex-col gap-2.5 w-fit">
<span className="text-sm">Value</span>
<textarea
data-testid="value-input"
name="secret-value"
required
className={cn(
"resize-none w-[680px]",
"bg-tertiary border border-[#717888] rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
rows={8}
/>
</label>
)}
<label className="flex flex-col gap-2.5 w-fit">
<div className="flex items-center gap-2">
<span className="text-sm">Description</span>
<OptionalTag />
</div>
<input
data-testid="description-input"
name="secret-description"
defaultValue={secretDescription}
className={cn(
"resize-none w-[680px]",
"bg-tertiary border border-[#717888] rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</label>
<div className="flex items-center gap-4">
<BrandButton
testId="cancel-button"
type="button"
variant="secondary"
onClick={onCancel}
>
Cancel
</BrandButton>
<BrandButton testId="submit-button" type="submit" variant="primary">
{mode === "add" && t("SECRETS$ADD_SECRET")}
{mode === "edit" && t("SECRETS$EDIT_SECRET")}
</BrandButton>
</div>
</form>
);
}
@@ -0,0 +1,63 @@
import { FaPencil, FaTrash } from "react-icons/fa6";
export function SecretListItemSkeleton() {
return (
<div className="border-t border-[#717888] last-of-type:border-b max-w-[830px] pr-2.5 py-[13px] flex items-center justify-between">
<div className="flex items-center justify-between w-1/3">
<span className="skeleton h-4 w-1/2" />
<span className="skeleton h-4 w-1/4" />
</div>
<div className="flex items-center gap-8">
<span className="skeleton h-4 w-4" />
<span className="skeleton h-4 w-4" />
</div>
</div>
);
}
interface SecretListItemProps {
title: string;
description?: string;
onEdit: () => void;
onDelete: () => void;
}
export function SecretListItem({
title,
description,
onEdit,
onDelete,
}: SecretListItemProps) {
return (
<tr
data-testid="secret-item"
className="border-t border-[#717888] last-of-type:border-b max-w-[830px] py-[13px] flex w-full items-center"
>
<td className="w-1/4 text-sm text-content-2">{title}</td>
<td className="w-1/2 truncate overflow-hidden whitespace-nowrap text-sm text-content-2 opacity-80 italic">
{description || "-"}
</td>
<td className="w-1/4 flex items-center justify-end gap-4">
<button
data-testid="edit-secret-button"
type="button"
onClick={onEdit}
aria-label={`Edit ${title}`}
>
<FaPencil size={16} />
</button>
<button
data-testid="delete-secret-button"
type="button"
onClick={onDelete}
aria-label={`Delete ${title}`}
>
<FaTrash size={16} />
</button>
</td>
</tr>
);
}
@@ -17,6 +17,7 @@ interface SettingsDropdownInputProps {
isClearable?: boolean;
onSelectionChange?: (key: React.Key | null) => void;
onInputChange?: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
}
export function SettingsDropdownInput({
@@ -33,6 +34,7 @@ export function SettingsDropdownInput({
isClearable,
onSelectionChange,
onInputChange,
defaultFilter,
}: SettingsDropdownInputProps) {
return (
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
@@ -64,6 +66,7 @@ export function SettingsDropdownInput({
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
},
}}
defaultFilter={defaultFilter}
>
{(item) => (
<AutocompleteItem key={item.key}>{item.label}</AutocompleteItem>
@@ -14,9 +14,11 @@ interface SettingsInputProps {
startContent?: React.ReactNode;
className?: string;
onChange?: (value: string) => void;
required?: boolean;
min?: number;
max?: number;
step?: number;
pattern?: string;
}
export function SettingsInput({
@@ -32,9 +34,11 @@ export function SettingsInput({
startContent,
className,
onChange,
required,
min,
max,
step,
pattern,
}: SettingsInputProps) {
return (
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
@@ -55,6 +59,8 @@ export function SettingsInput({
min={min}
max={max}
step={step}
required={required}
pattern={pattern}
className={cn(
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
@@ -35,7 +35,6 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
{accountContextMenuIsVisible && (
<AccountSettingsContextMenu
isLoggedIn={!!user}
onLogout={handleLogout}
onClose={closeAccountMenu}
/>
@@ -0,0 +1,45 @@
import { BrandButton } from "#/components/features/settings/brand-button";
import { ModalBackdrop } from "./modal-backdrop";
interface ConfirmationModalProps {
text: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmationModal({
text,
onConfirm,
onCancel,
}: ConfirmationModalProps) {
return (
<ModalBackdrop onClose={onCancel}>
<div
data-testid="confirmation-modal"
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
>
<p>{text}</p>
<div className="w-full flex gap-2">
<BrandButton
testId="cancel-button"
type="button"
onClick={onCancel}
variant="secondary"
className="grow"
>
Cancel
</BrandButton>
<BrandButton
testId="confirm-button"
type="button"
onClick={onConfirm}
variant="primary"
className="grow"
>
Confirm
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function TaskForm({ ref }: TaskFormProps) {
const formData = new FormData(event.currentTarget);
const q = formData.get("q")?.toString();
createConversation({ q, conversation_trigger: "gui" });
createConversation({ q });
};
return (
@@ -6,7 +6,6 @@ import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
import { ConversationTrigger } from "#/api/open-hands.types";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
export const useCreateConversation = () => {
@@ -21,7 +20,6 @@ export const useCreateConversation = () => {
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: {
conversation_trigger: ConversationTrigger;
q?: string;
selectedRepository?: GitRepository | null;
selected_branch?: string;
@@ -30,7 +28,6 @@ export const useCreateConversation = () => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
variables.conversation_trigger,
variables.selectedRepository
? variables.selectedRepository.full_name
: undefined,
@@ -0,0 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
export const useCreateSecret = () =>
useMutation({
mutationFn: ({
name,
value,
description,
}: {
name: string;
value: string;
description?: string;
}) => SecretsService.createSecret(name, value, description),
});

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