Compare commits

...

46 Commits

Author SHA1 Message Date
Xingyao Wang
264d24f750 add debug statement 2025-02-18 16:25:42 -05:00
Xingyao Wang
1a7003a705 Add sysbox support to remote runtime for eval; Add memory monitor, stress tests to help debug memory issue (#6684)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-02-18 20:02:28 +00:00
Calvin Smith
8d097efb4f enh: Refactor Event -> Message pipeline outside of CodeActAgent (#6715)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-18 11:23:06 -07:00
sp.wack
2e98fc8fb3 feat(SaaS): Billing settings screen (#6495)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-02-18 18:56:10 +01:00
sp.wack
e3e00ed70a fix(frontend): Hide modal when in settings page if first time (#6792) 2025-02-18 21:28:59 +04:00
sp.wack
96d1992823 hotfix: Conversation panel toggle should change color given state (#6791) 2025-02-18 11:38:26 -05:00
dependabot[bot]
7a3a0d8c0c chore(deps): bump the version-all group across 1 directory with 9 updates (#6783)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-02-18 16:09:30 +00:00
sp.wack
fdffca18e0 hotfix(frontend): Input set/unset state and disable runtime input (#6788) 2025-02-18 20:01:39 +04:00
sp.wack
b10416e0a3 hotfix: Consistent background color (#6786) 2025-02-18 19:38:01 +04:00
nottherealironman
1f462d2417 docs: add guide for minimum computing and storage requirements (#6575) 2025-02-18 14:20:35 +00:00
tofarr
0a6ff463db CSS Fixes (#6770) 2025-02-18 12:10:07 +00:00
Rohit Malhotra
9ff15bf94f Add selected branch to convo metadata (#6773) 2025-02-17 17:27:13 -05:00
mamoodi
6c48013601 Update OpenHands Cloud docs with correct permissions and instructions (#6774) 2025-02-17 21:48:57 +00:00
Graham Neubig
07fcb786af Upgrade tree sitter (#6740)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-17 20:14:26 +01:00
Rohit Malhotra
ce42e22105 [Docs]: Cloud Openhands (#6747)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-02-17 18:39:49 +00:00
Rohit Malhotra
14ee6d7afe hotfix(Secrets): Add event stream filter for refreshed secret (#6764) 2025-02-17 18:27:32 +00:00
tofarr
57391d6e66 Enable the multi conversation UI for all users (#6374) 2025-02-17 11:07:47 -07:00
Xingyao Wang
a7bb73ded2 fix: disable prlimit since limiting --vm breaks nodejs (#6765) 2025-02-17 17:53:02 +00:00
tofarr
f4b123f73b Improve SensitiveDataFilter and add comprehensive tests (#6755)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-02-17 10:23:43 -07:00
Robert Brennan
ae31a24c29 Fix caps in status message (#6761) 2025-02-17 16:14:19 +00:00
Robert Brennan
3a478c2303 Better LLM retry behavior (#6557)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-17 10:36:59 -05:00
tofarr
82b5325792 Added iterate method and additional tests for search functions (#6756) 2025-02-17 08:11:13 -07:00
tofarr
265e8ae5f4 feat: implement optimistic updates for conversation deletion (#6745)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-02-17 07:33:36 -07:00
李师胡
0cbf50576d docs(runtime): fix broken links of benchmarks (#6744)
Co-authored-by: jianhao1 <jianhao1@taobao.com>
2025-02-17 14:11:32 +00:00
dependabot[bot]
745038b394 chore(deps): bump the version-all group in /frontend with 4 updates (#6725)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-02-17 11:35:50 +00:00
Arpan Koirala
b018567d53 fix: no interaction when clearing poetry cache (#6752) 2025-02-17 12:18:27 +01:00
Christopher Pereira
30e39e85d0 Show docker build errors (#6695) 2025-02-15 06:58:16 +01:00
Boxuan Li
4443417c75 A few fixes for TAC evaluation harness (#6586) 2025-02-14 21:01:57 -08:00
Boxuan Li
efbff2e655 Add a sanity test for load_app_config and get_agent_config_arg (#6723) 2025-02-14 21:01:42 -08:00
Cheng Yang
63565982aa docs: improve docstrings for CLI and config utils (#5398)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-15 01:51:59 +00:00
dependabot[bot]
b07fddcb71 chore(deps): bump the version-all group across 1 directory with 12 updates (#6736)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-15 01:17:17 +00:00
Ryan H. Tran
99b50d038e chore: upgrade openhands-aci to 0.2.2 (#6731) 2025-02-15 03:21:40 +08:00
Rohit Malhotra
1ddfa99c57 [Resolver]: Prep env in expectation of release (#6735) 2025-02-14 19:11:01 +00:00
sp.wack
0c03e257b7 feat(frontend): Settings screen (#6550) 2025-02-14 15:11:18 +04:00
Rohit Malhotra
85e3a00d9d hotfix(Resolver): Workflow definition is out of sync with released package (#6719) 2025-02-14 04:31:22 +00:00
Rohit Malhotra
edd51102ad fix: Simplify nested f-string to fix pydoc-markdown parsing (#6717) 2025-02-13 19:29:51 -05:00
wtiger9218
f5fccab1f6 feat(resolver): implement gitlab resolver (#6458)
Signed-off-by: José Luis Di Biase <josx@interorganic.com.ar>
Co-authored-by: José Luis Di Biase <josx@interorganic.com.ar>
Co-authored-by: Oriana <oriana@camba.coop>
Co-authored-by: Charlie <charlie@camba.coop>
Co-authored-by: Juan Manuel Daza <61162223+juanmanueldaza@users.noreply.github.com>
Co-authored-by: Juan Manuel Daza <juandaza@camba.coop>
Co-authored-by: Cody Kociemba <cody@symbaventures.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-13 18:21:23 -05:00
Boxuan Li
ef12bc5381 Evaluation harness: Add agent config option (#6662) 2025-02-13 15:05:03 -05:00
dependabot[bot]
b197e0af47 chore(deps): bump the version-all group across 1 directory with 5 updates (#6712)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-02-13 19:09:50 +00:00
Calvin Smith
341b695ad3 fix: Filter AgentCondensationObservation events from agent state (#6705)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-02-13 11:50:05 -07:00
tofarr
d46d99a35e More effective remote runtime identification (#6714) 2025-02-13 09:39:22 -07:00
mamoodi
653168fc3d Release 0.24.0 (#6689) 2025-02-13 10:22:05 -05:00
tofarr
cb5e7f0130 Agent session no longer stuck in starting on raised exception (#6703) 2025-02-13 05:24:44 -07:00
Rohit Malhotra
312b9fbfb1 Feat: Add selected branch param to backend (#6508) 2025-02-12 15:39:10 -05:00
sp.wack
ba599c7dd6 chore: Throw a 404 instead of returning defaults if settings does not exist (#6704) 2025-02-12 22:46:15 +04:00
tofarr
7e359eda4a Fix log formatting error (#6699) 2025-02-12 08:28:10 -07:00
216 changed files with 14489 additions and 5802 deletions

View File

@@ -232,6 +232,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
@@ -268,6 +269,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}

View File

@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.23-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.24-nikolaik`
## Develop inside Docker container

View File

@@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
docker.all-hands.dev/all-hands-ai/openhands:0.24
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

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.23-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.24-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

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.23-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.24-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:

View File

@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
python -m openhands.core.cli
```

View File

@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.23
docker.all-hands.dev/all-hands-ai/openhands:0.24
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
python -m openhands.core.cli
```

View File

@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.23
docker.all-hands.dev/all-hands-ai/openhands:0.24
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -54,14 +54,13 @@ graph TD
6. Action Execution: The runtime client receives actions from the backend, executes them in the sandboxed environment, and sends back observations
7. Observation Return: The action execution server sends execution results back to the OpenHands backend as observations
The role of the client:
- It acts as an intermediary between the OpenHands backend and the sandboxed environment
- It executes various types of actions (shell commands, file operations, Python code, etc.) safely within the container
- It manages the state of the sandboxed environment, including the current working directory and loaded plugins
- It formats and returns observations to the backend, ensuring a consistent interface for processing results
## How OpenHands builds and maintains OH Runtime images
OpenHands' approach to building and managing runtime images ensures efficiency, consistency, and flexibility in creating and maintaining Docker images for both production and development environments.
@@ -78,16 +77,15 @@ Tags may be in one of 2 formats:
- **Source Tag**: `oh_v{openhands_version}_{16_digit_lock_hash}_{16_digit_source_hash}`
(e.g.: `oh_v0.9.9_1234567890abcdef_1234567890abcdef`)
#### Source Tag - Most Specific
This is the first 16 digits of the MD5 of the directory hash for the source directory. This gives a hash
for only the openhands source
#### Lock Tag
This hash is built from the first 16 digits of the MD5 of:
- The name of the base image upon which the image was built (e.g.: `nikolaik/python-nodejs:python3.12-nodejs22`)
- The content of the `pyproject.toml` included in the image.
- The content of the `poetry.lock` included in the image.

View File

@@ -0,0 +1,21 @@
# Cloud GitHub Resolver
The GitHub Resolver automates code fixes and provides intelligent assistance for your repositories.
## Setup
The Cloud Github Resolver is available automatically when you
[grant OpenHands Cloud repository access](./openhands-cloud.md#adding-repositories).
## Usage
### Issues
On your repository, label an issue with `openhands`. OpenHands will attempt to fix the issue.
### Pull Requests
In order to get OpenHands to work on pull requests, use `@openhands` in top level or single inline comments to:
- Ask questions
- Request updates
- Get code explanations

View File

@@ -0,0 +1,39 @@
# Openhands Cloud
OpenHands Cloud is the cloud hosted version of OpenHands by All Hands AI.
## Accessing OpenHands Cloud
Currently, users are being admitted to access OpenHands Cloud in waves. To sign up,
[join the waitlist](https://www.all-hands.dev/join-waitlist). Once you are approved, you will get an email with
instructions on how to access it.
## Getting Started
After visiting OpenHands Cloud, you will be asked to connect with your GitHub account:
1. After reading and accepting the terms of service, click `Connect to GitHub`.
2. Review the permissions requested by OpenHands and then click `Authorize OpenHands by All Hands AI`.
- OpenHands will require some permissions from your GitHub account. To read more about these permissions,
you can click the `Learn more` link on the GitHub authorize page.
## Adding Repositories
You can grant OpenHands specific repository access:
1. Under the `Select a GitHub project` dropdown, select `Add more repositories...`.
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
- Openhands requests short-lived tokens (8-hour expiry) with these permissions:
- Actions: Read and write
- Administration: Read-only
- Commit statuses: Read and write
- Contents: Read and write
- Issues: Read and write
- Metadata: Read-only
- Pull requests: Read and write
- Webhooks: Read and write
- Workflows: Read and write
- Repository access for a user is granted based on:
- Granted permission for the repository.
- User's GitHub permissions (owner/collaborator).
You can manage repository access any time by following the above workflow or visiting the Settings page and selecting
`Configure GitHub Repositories` under the `GitHub Settings` section.

View File

@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,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.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -6,6 +6,8 @@
- Linux
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
## Prerequisites
<details>
@@ -54,17 +56,17 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
docker.all-hands.dev/all-hands-ai/openhands:0.24
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -63,22 +63,22 @@ We have a few guides for running OpenHands with specific model providers:
### API retries and rate limits
LLM providers typically have rate limits, sometimes very low, and may require retries. OpenHands will automatically
retry requests if it receives a Rate Limit Error (429 error code), API connection error, or other transient errors.
retry requests if it receives a Rate Limit Error (429 error code).
You can customize these options as you need for the provider you're using. Check their documentation, and set the
following environment variables to control the number of retries and the time between retries:
- `LLM_NUM_RETRIES` (Default of 8)
- `LLM_RETRY_MIN_WAIT` (Default of 15 seconds)
- `LLM_RETRY_MAX_WAIT` (Default of 120 seconds)
- `LLM_NUM_RETRIES` (Default of 4 times)
- `LLM_RETRY_MIN_WAIT` (Default of 5 seconds)
- `LLM_RETRY_MAX_WAIT` (Default of 30 seconds)
- `LLM_RETRY_MULTIPLIER` (Default of 2)
If you are running OpenHands in development mode, you can also set these options in the `config.toml` file:
```toml
[llm]
num_retries = 8
retry_min_wait = 15
retry_max_wait = 120
num_retries = 4
retry_min_wait = 5
retry_max_wait = 30
retry_multiplier = 2
```

View File

@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -42,7 +42,7 @@ const sidebars: SidebarsConfig = {
id: 'usage/prompting/microagents-public',
},
],
}
},
],
},
{
@@ -69,6 +69,23 @@ const sidebars: SidebarsConfig = {
label: 'Github Actions',
id: 'usage/how-to/github-action',
},
{
type: 'category',
label: 'Cloud',
items: [
{
type: 'doc',
label: 'Openhands Cloud',
id: 'usage/cloud/openhands-cloud',
},
{
type: 'doc',
label: 'Cloud GitHub Resolver',
id: 'usage/cloud/cloud-github-resolver',
},
],
},
],
},
{
@@ -185,7 +202,7 @@ const sidebars: SidebarsConfig = {
type: 'doc',
label: 'About',
id: 'usage/about',
}
},
],
};

View File

@@ -9,6 +9,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -17,7 +18,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -60,17 +60,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=False,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -25,7 +26,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -40,21 +40,15 @@ from openhands.utils.async_utils import call_async_from_sync
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-slim'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-slim',
enable_auto_lint=True,
use_host_network=False,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -24,7 +25,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
load_from_toml,
parse_arguments,
@@ -47,22 +47,14 @@ SKIP_NUM = (
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.11-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.11-bookworm',
enable_auto_lint=True,
use_host_network=False,
timeout=100,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=1800,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -22,7 +23,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -57,18 +57,15 @@ def get_config(
metadata: EvalMetadata,
) -> AppConfig:
BIOCODER_BENCH_CONTAINER_IMAGE = 'public.ecr.aws/i5g0m1f6/eval_biocoder:v1.0'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = BIOCODER_BENCH_CONTAINER_IMAGE
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image=BIOCODER_BENCH_CONTAINER_IMAGE,
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -25,7 +26,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -71,17 +71,15 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -18,7 +19,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -36,17 +36,14 @@ def get_config(
assert (
metadata.max_iterations == 1
), 'max_iterations must be 1 for browsing delegation evaluation.'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=False,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
workspace_base=None,
workspace_mount_path=None,
)

View File

@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
EvalOutput,
assert_and_raise,
codeact_user_response,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -25,7 +26,6 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -105,9 +105,7 @@ def get_config(
instance: pd.Series,
metadata: EvalMetadata,
) -> AppConfig:
# COMMIT0_CONTAINER_IMAGE = 'wentingzhao/'
assert USE_INSTANCE_IMAGE
# We use a different instance image for the each instance of commit0 eval
repo_name = instance['repo'].split('/')[1]
base_container_image = get_instance_docker_image(repo_name)
logger.info(
@@ -115,28 +113,16 @@ def get_config(
f'Please make sure this image exists. '
f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
)
# else:
# raise
# base_container_image = SWE_BENCH_CONTAINER_IMAGE
# logger.info(f'Using swe-bench container image: {base_container_image}')
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -25,7 +26,6 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -62,17 +62,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -21,10 +22,10 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
from openhands.core.config.utils import get_agent_config_arg
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
@@ -47,24 +48,25 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)
agent_config.enable_prompt_extensions = False
if metadata.agent_config:
config.set_agent_config(metadata.agent_config, metadata.agent_class)
else:
logger.info('Agent config not provided, using default settings')
agent_config = config.get_agent_config(metadata.agent_class)
agent_config.enable_prompt_extensions = False
return config
@@ -238,6 +240,10 @@ if __name__ == '__main__':
)
args, _ = parser.parse_known_args()
agent_config = None
if args.agent_config:
agent_config = get_agent_config_arg(args.agent_config)
llm_config = None
if args.llm_config:
llm_config = get_llm_config_arg(args.llm_config)
@@ -256,6 +262,7 @@ if __name__ == '__main__':
eval_output_dir=args.eval_output_dir,
data_split=args.data_split,
details={'gaia-level': args.level},
agent_config=agent_config,
)
dataset = load_dataset('gaia-benchmark/GAIA', args.level)

View File

@@ -9,6 +9,7 @@ AGENT=$3
EVAL_LIMIT=$4
LEVELS=$5
NUM_WORKERS=$6
AGENT_CONFIG=$7
if [ -z "$NUM_WORKERS" ]; then
NUM_WORKERS=1
@@ -49,5 +50,9 @@ if [ -n "$EVAL_LIMIT" ]; then
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
if [ -n "$AGENT_CONFIG" ]; then
echo "AGENT_CONFIG: $AGENT_CONFIG"
COMMAND="$COMMAND --agent-config $AGENT_CONFIG"
# Run the command
eval $COMMAND

View File

@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -19,7 +20,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -40,17 +40,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -29,6 +29,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -37,7 +38,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -61,17 +61,14 @@ ACTION_FORMAT = """
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -22,6 +22,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -30,7 +31,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -82,17 +82,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -9,6 +9,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -17,7 +18,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -45,18 +45,18 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'xingyaoww/od-eval-logic-reasoning:v1.0'
sandbox_config.runtime_extra_deps = (
'$OH_INTERPRETER_PATH -m pip install scitools-pyke'
)
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-logic-reasoning:v1.0',
enable_auto_lint=True,
use_host_network=False,
runtime_extra_deps='$OH_INTERPRETER_PATH -m pip install scitools-pyke',
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -12,6 +12,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -21,7 +22,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -55,23 +55,14 @@ def get_config(
metadata: EvalMetadata,
env_id: str,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'xingyaoww/od-eval-miniwob:v1.0'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
enable_auto_lint=True,
use_host_network=False,
browsergym_eval_env=env_id,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=1800,
keep_runtime_alive=False,
timeout=120,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -22,7 +23,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -103,18 +103,18 @@ def load_incontext_example(task_name: str, with_tool: bool = True):
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'xingyaoww/od-eval-mint:v1.0'
sandbox_config.runtime_extra_deps = (
f'$OH_INTERPRETER_PATH -m pip install {" ".join(MINT_DEPENDENCIES)}'
)
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-mint:v1.0',
enable_auto_lint=True,
use_host_network=False,
runtime_extra_deps=f'$OH_INTERPRETER_PATH -m pip install {" ".join(MINT_DEPENDENCIES)}',
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -25,6 +25,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -33,7 +34,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
load_app_config,
@@ -77,16 +77,14 @@ ID2CONDA = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'public.ecr.aws/i5g0m1f6/ml-bench'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='public.ecr.aws/i5g0m1f6/ml-bench',
enable_auto_lint=True,
use_host_network=False,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -20,7 +21,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -59,22 +59,17 @@ def get_config(
metadata: EvalMetadata,
instance_id: str,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = (
'docker.io/xingyaoww/openhands-eval-scienceagentbench'
)
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_budget_per_task=4,
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='docker.io/xingyaoww/openhands-eval-scienceagentbench',
enable_auto_lint=True,
use_host_network=False,
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -1,5 +1,6 @@
import json
import os
import subprocess
import tempfile
import time
from functools import partial
@@ -21,13 +22,14 @@ from evaluation.benchmarks.swe_bench.run_infer import get_instance_docker_image
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_default_sandbox_config_for_eval,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
)
from openhands.core.config import (
AppConfig,
SandboxConfig,
LLMConfig,
get_parser,
)
from openhands.core.logger import openhands_logger as logger
@@ -79,22 +81,16 @@ def get_config(metadata: EvalMetadata, instance: pd.Series) -> AppConfig:
f'Please make sure this image exists. '
f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
)
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
sandbox_config.remote_runtime_resource_factor = get_instance_resource_factor(
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
)
config = AppConfig(
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=600,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=3600,
remote_runtime_resource_factor=get_instance_resource_factor(
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
),
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -415,13 +411,17 @@ if __name__ == '__main__':
else:
# Initialize with a dummy metadata when file doesn't exist
metadata = EvalMetadata(
agent_class="dummy_agent", # Placeholder agent class
llm_config=LLMConfig(model="dummy_model"), # Minimal LLM config
agent_class='dummy_agent', # Placeholder agent class
llm_config=LLMConfig(model='dummy_model'), # Minimal LLM config
max_iterations=1, # Minimal iterations
eval_output_dir=os.path.dirname(args.input_file), # Use input file dir as output dir
eval_output_dir=os.path.dirname(
args.input_file
), # Use input file dir as output dir
start_time=time.strftime('%Y-%m-%d %H:%M:%S'), # Current time
git_commit=subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8').strip(), # Current commit
dataset=args.dataset # Dataset name from args
git_commit=subprocess.check_output(['git', 'rev-parse', 'HEAD'])
.decode('utf-8')
.strip(), # Current commit
dataset=args.dataset, # Dataset name from args
)
# The evaluation harness constrains the signature of `process_instance_func` but we need to

View File

@@ -1 +0,0 @@
{"pydata__xarray-6721": 8, "pytest-dev__pytest-7236": 8, "matplotlib__matplotlib-24627": 4, "django__django-15561": 4, "django__django-15098": 4, "django__django-14771": 4, "sympy__sympy-21612": 4, "sympy__sympy-15345": 4, "psf__requests-5414": 4, "astropy__astropy-14508": 2, "django__django-11451": 2, "django__django-11477": 2, "django__django-10880": 2, "django__django-11163": 2, "django__django-11815": 2, "astropy__astropy-14369": 2, "django__django-10097": 2, "django__django-10554": 2, "django__django-12304": 2, "django__django-12325": 2, "django__django-11551": 2, "django__django-11734": 2, "django__django-13109": 2, "django__django-13089": 2, "django__django-13343": 2, "django__django-13363": 2, "django__django-13809": 2, "django__django-13810": 2, "django__django-13786": 2, "django__django-13807": 2, "django__django-14493": 2, "django__django-11820": 2, "django__django-11951": 2, "django__django-11964": 2, "astropy__astropy-14309": 2, "astropy__astropy-14365": 2, "astropy__astropy-12907": 2, "astropy__astropy-14182": 2, "django__django-15161": 2, "django__django-15128": 2, "django__django-14999": 2, "django__django-14915": 2, "django__django-14752": 2, "django__django-14765": 2, "django__django-14089": 2, "django__django-15252": 2, "django__django-15380": 2, "django__django-15382": 2, "django__django-15499": 2, "django__django-15467": 2, "django__django-15280": 2, "django__django-15315": 2, "django__django-15277": 2, "django__django-15268": 2, "django__django-15629": 2, "django__django-15695": 2, "django__django-15732": 2, "django__django-15863": 2, "django__django-16082": 2, "django__django-16145": 2, "django__django-16256": 2, "django__django-16429": 2, "django__django-16454": 2, "django__django-16493": 2, "matplotlib__matplotlib-13989": 2, "matplotlib__matplotlib-20488": 2, "django__django-15503": 2, "django__django-15525": 2, "django__django-15375": 2, "django__django-15278": 2, "matplotlib__matplotlib-21568": 2, "matplotlib__matplotlib-20859": 2, "matplotlib__matplotlib-20826": 2, "matplotlib__matplotlib-20676": 2, "matplotlib__matplotlib-23412": 2, "matplotlib__matplotlib-22719": 2, "matplotlib__matplotlib-23299": 2, "matplotlib__matplotlib-22865": 2, "matplotlib__matplotlib-24149": 2, "matplotlib__matplotlib-24177": 2, "matplotlib__matplotlib-24570": 2, "matplotlib__matplotlib-24637": 2, "matplotlib__matplotlib-24970": 2, "matplotlib__matplotlib-23476": 2, "matplotlib__matplotlib-24026": 2, "matplotlib__matplotlib-23314": 2, "matplotlib__matplotlib-25332": 2, "matplotlib__matplotlib-25311": 2, "matplotlib__matplotlib-25122": 2, "matplotlib__matplotlib-25479": 2, "matplotlib__matplotlib-26342": 2, "psf__requests-2317": 2, "matplotlib__matplotlib-25960": 2, "matplotlib__matplotlib-25775": 2, "pydata__xarray-4356": 2, "pydata__xarray-4075": 2, "pydata__xarray-6461": 2, "pydata__xarray-4687": 2, "pydata__xarray-6599": 2, "pylint-dev__pylint-4661": 2, "django__django-15554": 2, "django__django-15563": 2, "pytest-dev__pytest-5262": 2, "pytest-dev__pytest-10081": 2, "scikit-learn__scikit-learn-12973": 2, "scikit-learn__scikit-learn-13124": 2, "scikit-learn__scikit-learn-13779": 2, "scikit-learn__scikit-learn-14141": 2, "scikit-learn__scikit-learn-13439": 2, "scikit-learn__scikit-learn-13496": 2, "scikit-learn__scikit-learn-15100": 2, "scikit-learn__scikit-learn-25102": 2, "scikit-learn__scikit-learn-25232": 2, "scikit-learn__scikit-learn-25747": 2, "scikit-learn__scikit-learn-26323": 2, "scikit-learn__scikit-learn-9288": 2, "scikit-learn__scikit-learn-14496": 2, "scikit-learn__scikit-learn-14629": 2, "sphinx-doc__sphinx-8265": 2, "sphinx-doc__sphinx-8548": 2, "sphinx-doc__sphinx-8593": 2, "sphinx-doc__sphinx-8595": 2, "sphinx-doc__sphinx-8621": 2, "sphinx-doc__sphinx-8638": 2, "sphinx-doc__sphinx-9229": 2, "sphinx-doc__sphinx-9281": 2, "sphinx-doc__sphinx-9461": 2, "sphinx-doc__sphinx-9591": 2, "sphinx-doc__sphinx-9658": 2, "sphinx-doc__sphinx-9673": 2, "sympy__sympy-12096": 2, "sympy__sympy-12481": 2, "sphinx-doc__sphinx-10323": 2, "sphinx-doc__sphinx-7590": 2, "sympy__sympy-13877": 2, "sympy__sympy-12489": 2, "sympy__sympy-15809": 2, "sympy__sympy-14711": 2, "sympy__sympy-16597": 2, "sympy__sympy-16766": 2, "sympy__sympy-16792": 2, "sympy__sympy-15875": 2, "sympy__sympy-17655": 2, "sympy__sympy-18189": 2, "sympy__sympy-18763": 2, "sympy__sympy-19040": 2, "sympy__sympy-19495": 2, "sympy__sympy-19637": 2, "sympy__sympy-19783": 2, "sympy__sympy-17630": 2, "sympy__sympy-20428": 2, "sympy__sympy-20590": 2, "sympy__sympy-20801": 2, "sympy__sympy-21379": 2, "sympy__sympy-21847": 2, "sympy__sympy-22456": 2, "sympy__sympy-22714": 2, "sympy__sympy-22914": 2, "sympy__sympy-23262": 2, "sympy__sympy-23413": 2, "sympy__sympy-23534": 2, "sympy__sympy-24066": 2, "sympy__sympy-24213": 2, "sympy__sympy-24443": 2, "sympy__sympy-24562": 2, "sympy__sympy-24661": 2}

View File

@@ -18,6 +18,7 @@ from evaluation.utils.shared import (
EvalOutput,
assert_and_raise,
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
is_fatal_evaluation_error,
make_metadata,
@@ -30,7 +31,6 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -122,30 +122,23 @@ def get_config(
base_container_image = SWE_BENCH_CONTAINER_IMAGE
logger.info(f'Using swe-bench container image: {base_container_image}')
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
sandbox_config.enable_auto_lint = True
sandbox_config.use_host_network = False
# Add platform to the sandbox config to solve issue 4401
sandbox_config.platform = 'linux/amd64'
sandbox_config.remote_runtime_resource_factor = get_instance_resource_factor(
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
)
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=300,
# Add platform to the sandbox config to solve issue 4401
platform='linux/amd64',
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
remote_runtime_api_timeout=120,
remote_runtime_resource_factor=get_instance_resource_factor(
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
),
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
@@ -331,6 +324,22 @@ def complete_runtime(
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
if obs.exit_code == -1:
# The previous command is still running
# We need to kill previous command
logger.info('The previous command is still running, trying to kill it...')
action = CmdRunAction(command='C-c')
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Then run the command again
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
isinstance(obs, CmdOutputObservation) and obs.exit_code == 0,
f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',

View File

@@ -267,7 +267,9 @@ def pre_login(
obs: BrowserOutputObservation = runtime.run_action(browser_action)
logger.debug(obs, extra={'msg_type': 'OBSERVATION'})
if save_screenshots:
image_data = base64.b64decode(obs.screenshot)
image_data = base64.b64decode(
obs.screenshot.replace('data:image/png;base64,', '')
)
with open(os.path.join(directory, f'{image_id}.png'), 'wb') as file:
file.write(image_data)
image_id += 1

View File

@@ -13,14 +13,16 @@ from typing import List
import yaml
from browsing import pre_login
from evaluation.utils.shared import get_default_sandbox_config_for_eval
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
LLMConfig,
SandboxConfig,
get_agent_config_arg,
get_llm_config_arg,
get_parser,
)
from openhands.core.config.agent_config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import CmdRunAction, MessageAction
@@ -34,7 +36,10 @@ def get_config(
task_short_name: str,
mount_path_on_host: str,
llm_config: LLMConfig,
agent_config: AgentConfig | None,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
config = AppConfig(
run_as_openhands=False,
max_budget_per_task=4,
@@ -42,22 +47,21 @@ def get_config(
save_trajectory_path=os.path.join(
mount_path_on_host, f'traj_{task_short_name}.json'
),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
# using host network to access the host machine from the container
use_host_network=True,
# large enough timeout, since some testcases take very long to run
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# we mount trajectories path so that trajectories, generated by OpenHands
# controller, can be accessible to the evaluator file in the runtime container
workspace_mount_path=mount_path_on_host,
workspace_mount_path_in_sandbox='/outputs',
)
config.set_llm_config(llm_config)
if agent_config:
config.set_agent_config(agent_config)
else:
logger.info('Agent config not provided, using default settings')
agent_config = AgentConfig(
enable_prompt_extensions=False,
)
config.set_agent_config(agent_config)
return config
@@ -148,11 +152,21 @@ def run_solver(
os.makedirs(screenshots_dir, exist_ok=True)
for image_id, obs in enumerate(state.history):
if isinstance(obs, BrowserOutputObservation):
image_data = base64.b64decode(obs.screenshot)
image_data = base64.b64decode(
obs.screenshot.replace('data:image/png;base64,', '')
)
with open(
os.path.join(screenshots_dir, f'{image_id}.png'), 'wb'
) as file:
file.write(image_data)
if obs.set_of_marks:
som_image_data = base64.b64decode(
obs.set_of_marks.replace('data:image/png;base64,', '')
)
with open(
os.path.join(screenshots_dir, f'{image_id}_som.png'), 'wb'
) as file:
file.write(som_image_data)
if save_final_state:
os.makedirs(state_dir, exist_ok=True)
@@ -215,6 +229,10 @@ if __name__ == '__main__':
)
args, _ = parser.parse_known_args()
agent_config: AgentConfig | None = None
if args.agent_config:
agent_config = get_agent_config_arg(args.agent_config)
agent_llm_config: LLMConfig | None = None
if args.agent_llm_config:
agent_llm_config = get_llm_config_arg(args.agent_llm_config)
@@ -255,7 +273,7 @@ if __name__ == '__main__':
else:
temp_dir = tempfile.mkdtemp()
config: AppConfig = get_config(
args.task_image_name, task_short_name, temp_dir, agent_llm_config
args.task_image_name, task_short_name, temp_dir, agent_llm_config, agent_config
)
runtime: Runtime = create_runtime(config)
call_async_from_sync(runtime.connect)

View File

@@ -44,6 +44,10 @@ while [[ $# -gt 0 ]]; do
ENV_LLM_CONFIG="$2"
shift 2
;;
--agent-config)
AGENT_CONFIG="$2"
shift 2
;;
--outputs-path)
OUTPUTS_PATH="$2"
shift 2
@@ -125,8 +129,6 @@ temp_file="tasks_${START_PERCENTILE}_${END_PERCENTILE}.md"
sed -n "${start_line},${end_line}p" tasks.md > "$temp_file"
while IFS= read -r task_image; do
docker pull $task_image
# Remove prefix using ## to remove longest matching pattern from start
task_name=${task_image##ghcr.io/theagentcompany/}
@@ -140,13 +142,23 @@ while IFS= read -r task_image; do
continue
fi
export PYTHONPATH=evaluation/benchmarks/the_agent_company:\$PYTHONPATH && \
poetry run python run_infer.py \
--agent-llm-config "$AGENT_LLM_CONFIG" \
--env-llm-config "$ENV_LLM_CONFIG" \
--outputs-path "$OUTPUTS_PATH" \
--server-hostname "$SERVER_HOSTNAME" \
--task-image-name "$task_image"
docker pull $task_image
# Build the Python command
COMMAND="poetry run python run_infer.py \
--agent-llm-config \"$AGENT_LLM_CONFIG\" \
--env-llm-config \"$ENV_LLM_CONFIG\" \
--outputs-path \"$OUTPUTS_PATH\" \
--server-hostname \"$SERVER_HOSTNAME\" \
--task-image-name \"$task_image\""
# Add agent-config if it's defined
if [ -n "$AGENT_CONFIG" ]; then
COMMAND="$COMMAND --agent-config $AGENT_CONFIG"
fi
export PYTHONPATH=evaluation/benchmarks/the_agent_company:$PYTHONPATH && \
eval "$COMMAND"
# Prune unused images and volumes
docker image rm "$task_image"

View File

@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
EvalOutput,
codeact_user_response,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -18,7 +19,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
get_parser,
)
@@ -41,17 +41,14 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -20,7 +21,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -55,32 +55,29 @@ def get_config(
assert base_url is not None, 'VISUALWEBARENA_BASE_URL must be set'
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
assert openai_base_url is not None, 'OPENAI_BASE_URL must be set'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
sandbox_config.browsergym_eval_env = env_id
sandbox_config.runtime_startup_env_vars = {
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'OPENAI_BASE_URL': openai_base_url,
'VWA_CLASSIFIEDS': f'{base_url}:9980',
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
'VWA_SHOPPING': f'{base_url}:7770',
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
'VWA_REDDIT': f'{base_url}:9999',
'VWA_GITLAB': f'{base_url}:8023',
'VWA_WIKIPEDIA': f'{base_url}:8888',
'VWA_HOMEPAGE': f'{base_url}:4399',
}
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
browsergym_eval_env=env_id,
runtime_startup_env_vars={
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'OPENAI_BASE_URL': openai_base_url,
'VWA_CLASSIFIEDS': f'{base_url}:9980',
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
'VWA_SHOPPING': f'{base_url}:7770',
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
'VWA_REDDIT': f'{base_url}:9999',
'VWA_GITLAB': f'{base_url}:8023',
'VWA_WIKIPEDIA': f'{base_url}:8888',
'VWA_HOMEPAGE': f'{base_url}:4399',
},
timeout=300,
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -19,7 +20,6 @@ from evaluation.utils.shared import (
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -50,29 +50,26 @@ def get_config(
assert base_url is not None, 'WEBARENA_BASE_URL must be set'
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
sandbox_config.browsergym_eval_env = env_id
sandbox_config.runtime_startup_env_vars = {
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'SHOPPING': f'{base_url}:7770/',
'SHOPPING_ADMIN': f'{base_url}:7780/admin',
'REDDIT': f'{base_url}:9999',
'GITLAB': f'{base_url}:8023',
'WIKIPEDIA': f'{base_url}:8888/wikipedia_en_all_maxi_2022-05/A/User:The_other_Kiwix_guy/Landing',
'MAP': f'{base_url}:3000',
'HOMEPAGE': f'{base_url}:4399',
}
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
browsergym_eval_env=env_id,
runtime_startup_env_vars={
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'SHOPPING': f'{base_url}:7770/',
'SHOPPING_ADMIN': f'{base_url}:7780/admin',
'REDDIT': f'{base_url}:9999',
'GITLAB': f'{base_url}:8023',
'WIKIPEDIA': f'{base_url}:8888/wikipedia_en_all_maxi_2022-05/A/User:The_other_Kiwix_guy/Landing',
'MAP': f'{base_url}:3000',
'HOMEPAGE': f'{base_url}:4399',
},
remote_runtime_enable_retries=True,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -8,6 +8,7 @@ from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestRes
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_default_sandbox_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -21,7 +22,6 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
@@ -43,23 +43,14 @@ def get_config(
metadata: EvalMetadata,
instance_id: str,
) -> AppConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.platform = 'linux/amd64'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
# use default base_container_image
enable_auto_lint=True,
use_host_network=False,
timeout=300,
# Add platform to the sandbox config to solve issue 4401
platform='linux/amd64',
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,

View File

@@ -16,7 +16,8 @@ from pydantic import BaseModel
from tqdm import tqdm
from openhands.controller.state.state import State
from openhands.core.config import LLMConfig
from openhands.core.config import LLMConfig, SandboxConfig
from openhands.core.config.agent_config import AgentConfig
from openhands.core.config.condenser_config import (
CondenserConfig,
NoOpCondenserConfig,
@@ -43,6 +44,7 @@ from openhands.memory.condenser import get_condensation_metadata
class EvalMetadata(BaseModel):
agent_class: str
llm_config: LLMConfig
agent_config: AgentConfig | None = None
max_iterations: int
eval_output_dir: str
start_time: str
@@ -167,6 +169,7 @@ def make_metadata(
eval_output_dir: str,
data_split: str | None = None,
details: dict[str, Any] | None = None,
agent_config: AgentConfig | None = None,
condenser_config: CondenserConfig | None = None,
) -> EvalMetadata:
model_name = llm_config.model.split('/')[-1]
@@ -189,6 +192,7 @@ def make_metadata(
metadata = EvalMetadata(
agent_class=agent_class,
llm_config=llm_config,
agent_config=agent_config,
max_iterations=max_iterations,
eval_output_dir=eval_output_path,
start_time=time.strftime('%Y-%m-%d %H:%M:%S'),
@@ -551,3 +555,18 @@ def get_metrics(state: State) -> dict[str, Any]:
metrics = state.metrics.get() if state.metrics else {}
metrics['condenser'] = get_condensation_metadata(state)
return metrics
def get_default_sandbox_config_for_eval() -> SandboxConfig:
return SandboxConfig(
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
remote_runtime_api_timeout=120,
remote_runtime_enable_retries=True,
remote_runtime_class='sysbox',
)

View File

@@ -18,7 +18,6 @@ describe("AccountSettingsContextMenu", () => {
it("should always render the right options", () => {
render(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
@@ -28,30 +27,12 @@ describe("AccountSettingsContextMenu", () => {
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
expect(screen.getByText("ACCOUNT_SETTINGS$SETTINGS")).toBeInTheDocument();
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
});
it("should call onClickAccountSettings when the account settings option is clicked", async () => {
render(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
await user.click(accountSettingsOption);
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
});
it("should call onLogout when the logout option is clicked", async () => {
render(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
@@ -67,7 +48,6 @@ describe("AccountSettingsContextMenu", () => {
test("onLogout should be disabled if the user is not logged in", async () => {
render(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn={false}
@@ -83,14 +63,13 @@ describe("AccountSettingsContextMenu", () => {
it("should call onClose when clicking outside of the element", async () => {
render(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(accountSettingsButton);
await user.click(document.body);

View File

@@ -1,6 +1,6 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import OpenHands from "#/api/open-hands";
@@ -8,7 +8,7 @@ import { SettingsProvider } from "#/context/settings-context";
import { AuthProvider } from "#/context/auth-context";
describe("AnalyticsConsentFormModal", () => {
it("should call saveUserSettings with default settings on confirm reset settings", async () => {
it("should call saveUserSettings with consent", async () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
@@ -26,20 +26,9 @@ describe("AnalyticsConsentFormModal", () => {
const confirmButton = screen.getByTestId("confirm-preferences");
await user.click(confirmButton);
expect(saveUserSettingsSpy).toHaveBeenCalledWith({
user_consents_to_analytics: true,
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
github_token: undefined,
language: "en",
llm_api_key: undefined,
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
unset_github_token: undefined,
});
expect(onCloseMock).toHaveBeenCalled();
expect(saveUserSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ user_consents_to_analytics: true }),
);
await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,166 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import OpenHands from "#/api/open-hands";
import { PaymentForm } from "#/components/features/payment/payment-form";
describe("PaymentForm", () => {
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const renderPaymentForm = () =>
render(<PaymentForm />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
beforeEach(() => {
// useBalance hook will return the balance only if the APP_MODE is "saas"
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render the users current balance", async () => {
getBalanceSpy.mockResolvedValue("100.50");
renderPaymentForm();
await waitFor(() => {
const balance = screen.getByTestId("user-balance");
expect(balance).toHaveTextContent("$100.50");
});
});
it("should render the users current balance to two decimal places", async () => {
getBalanceSpy.mockResolvedValue("100");
renderPaymentForm();
await waitFor(() => {
const balance = screen.getByTestId("user-balance");
expect(balance).toHaveTextContent("$100.00");
});
});
test("the user can top-up a specific amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.12");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12);
});
it("should round the top-up amount to two decimal places", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.125456");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13);
});
it("should render the payment method link", async () => {
renderPaymentForm();
screen.getByTestId("payment-methods-link");
});
it("should disable the top-up button if the user enters an invalid amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpButton = screen.getByText("Add credit");
expect(topUpButton).toBeDisabled();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, " ");
expect(topUpButton).toBeDisabled();
});
it("should disable the top-up button after submission", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "50.12");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(topUpButton).toBeDisabled();
});
describe("prevent submission if", () => {
test("user enters a negative amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "-50.12");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters an empty string", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, " ");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters a non-numeric value", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "abc");
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
test("user enters less than the minimum amount", async () => {
const user = userEvent.setup();
renderPaymentForm();
const topUpInput = await screen.findByTestId("top-up-input");
await user.type(topUpInput, "20"); // test assumes the minimum is 25
const topUpButton = screen.getByText("Add credit");
await user.click(topUpButton);
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,9 +1,6 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { AxiosError } from "axios";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import OpenHands from "#/api/open-hands";
@@ -21,161 +18,14 @@ const renderSidebar = () =>
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
describe("Sidebar", () => {
describe("Settings", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
afterEach(() => {
vi.clearAllMocks();
});
it("should fetch settings data on mount", () => {
renderSidebar();
expect(getSettingsSpy).toHaveBeenCalledOnce();
});
it("should send all settings data when saving AI configuration", async () => {
const user = userEvent.setup();
renderSidebar();
const settingsButton = screen.getByTestId("settings-button");
await user.click(settingsButton);
const settingsModal = screen.getByTestId("ai-config-modal");
const saveButton = within(settingsModal).getByTestId(
"save-settings-button",
);
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
});
});
it("should not reset AI configuration when saving account settings", async () => {
const user = userEvent.setup();
renderSidebar();
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const menu = screen.getByTestId("account-settings-context-menu");
const accountSettingsButton = within(menu).getByTestId(
"account-settings-button",
);
await user.click(accountSettingsButton);
const accountSettingsModal = screen.getByTestId("account-settings-form");
const languageInput =
within(accountSettingsModal).getByLabelText(/language/i);
await user.click(languageInput);
const norskOption = screen.getByText(/norsk/i);
await user.click(norskOption);
const tokenInput =
within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_LABEL/i);
await user.type(tokenInput, "new-token");
const analyticsConsentInput =
within(accountSettingsModal).getByTestId("analytics-consent");
await user.click(analyticsConsentInput);
const saveButton =
within(accountSettingsModal).getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
github_token: "new-token",
language: "no",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
user_consents_to_analytics: true,
});
});
it("should not send the api key if its SET", async () => {
const user = userEvent.setup();
renderSidebar();
const settingsButton = screen.getByTestId("settings-button");
await user.click(settingsButton);
const settingsModal = screen.getByTestId("ai-config-modal");
// Click the advanced options switch to show the API key input
const advancedOptionsSwitch = within(settingsModal).getByTestId(
"advanced-option-switch",
);
await user.click(advancedOptionsSwitch);
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
await user.type(apiKeyInput, "**********");
const saveButton = within(settingsModal).getByTestId(
"save-settings-button",
);
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Settings Modal", () => {
it("should open the settings modal if the user clicks the settings button", async () => {
const user = userEvent.setup();
renderSidebar();
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
const settingsButton = screen.getByTestId("settings-button");
await user.click(settingsButton);
const settingsModal = screen.getByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 404,
statusText: "Not Found",
data: { message: "Settings not found" },
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);
vi.spyOn(OpenHands, "getSettings").mockRejectedValue(error);
renderSidebar();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should fetch settings data on mount", () => {
renderSidebar();
expect(getSettingsSpy).toHaveBeenCalled();
});
});

View File

@@ -1,174 +0,0 @@
import { screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import OpenHands from "#/api/open-hands";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
describe("AccountSettingsModal", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
afterEach(() => {
vi.clearAllMocks();
});
it.skip("should set the appropriate user analytics consent default", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const analyticsConsentInput = screen.getByTestId("analytics-consent");
await waitFor(() => expect(analyticsConsentInput).toBeChecked());
});
it("should save the users consent to analytics when saving account settings", async () => {
const user = userEvent.setup();
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const analyticsConsentInput = screen.getByTestId("analytics-consent");
await user.click(analyticsConsentInput);
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
user_consents_to_analytics: true,
});
});
it("should call handleCaptureConsent with the analytics consent value if the save is successful", async () => {
const user = userEvent.setup();
const handleCaptureConsentSpy = vi.spyOn(
ConsentHandlers,
"handleCaptureConsent",
);
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const analyticsConsentInput = screen.getByTestId("analytics-consent");
await user.click(analyticsConsentInput);
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
await user.click(analyticsConsentInput);
await user.click(saveButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
});
it("should send all settings data when saving account settings", async () => {
const user = userEvent.setup();
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const languageInput = screen.getByLabelText(/language/i);
await user.click(languageInput);
const norskOption = screen.getByText(/norsk/i);
await user.click(norskOption);
const tokenInput = screen.getByTestId("github-token-input");
await user.type(tokenInput, "new-token");
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "no",
github_token: "new-token",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
user_consents_to_analytics: false,
});
});
it("should render a checkmark and not the input if the github token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
await waitFor(() => {
const checkmark = screen.queryByTestId("github-token-set-checkmark");
const input = screen.queryByTestId("github-token-input");
expect(checkmark).toBeInTheDocument();
expect(input).not.toBeInTheDocument();
});
});
it("should send an unset github token property when pressing disconnect", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const disconnectButton = await screen.findByTestId("disconnect-github");
await user.click(disconnectButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
github_token: undefined,
language: "en",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
unset_github_token: true,
});
});
it("should not unset the github token when changing the language", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const languageInput = screen.getByLabelText(/language/i);
await user.click(languageInput);
const norskOption = screen.getByText(/norsk/i);
await user.click(norskOption);
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "no",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
user_consents_to_analytics: false,
});
});
});

View File

@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { BrandButton } from "#/components/features/settings/brand-button";
describe("BrandButton", () => {
const onClickMock = vi.fn();
it("should set a test id", () => {
render(
<BrandButton testId="brand-button" type="button" variant="primary">
Test Button
</BrandButton>,
);
expect(screen.getByTestId("brand-button")).toBeInTheDocument();
});
it("should call onClick when clicked", async () => {
const user = userEvent.setup();
render(
<BrandButton type="button" variant="primary" onClick={onClickMock}>
Test Button
</BrandButton>,
);
await user.click(screen.getByText("Test Button"));
});
it("should be disabled if isDisabled is true", () => {
render(
<BrandButton type="button" variant="primary" isDisabled>
Test Button
</BrandButton>,
);
expect(screen.getByText("Test Button")).toBeDisabled();
});
});

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { I18nKey } from "#/i18n/declaration";
// Mock react-i18next
vi.mock("react-i18next", () => ({

View File

@@ -0,0 +1,109 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { SettingsInput } from "#/components/features/settings/settings-input";
describe("SettingsInput", () => {
it("should render an optional tag if showOptionalTag is true", async () => {
const { rerender } = render(
<SettingsInput testId="test-input" label="Test Input" type="text" />,
);
expect(screen.queryByText(/optional/i)).not.toBeInTheDocument();
rerender(
<SettingsInput
testId="test-input"
showOptionalTag
label="Test Input"
type="text"
/>,
);
expect(screen.getByText(/optional/i)).toBeInTheDocument();
});
it("should disable the input if isDisabled is true", async () => {
const { rerender } = render(
<SettingsInput testId="test-input" label="Test Input" type="text" />,
);
expect(screen.getByTestId("test-input")).toBeEnabled();
rerender(
<SettingsInput
testId="test-input"
label="Test Input"
type="text"
isDisabled
/>,
);
expect(screen.getByTestId("test-input")).toBeDisabled();
});
it("should set a placeholder on the input", async () => {
render(
<SettingsInput
testId="test-input"
label="Test Input"
type="text"
placeholder="Test Placeholder"
/>,
);
expect(screen.getByTestId("test-input")).toHaveAttribute(
"placeholder",
"Test Placeholder",
);
});
it("should set a default value on the input", async () => {
render(
<SettingsInput
testId="test-input"
label="Test Input"
type="text"
defaultValue="Test Value"
/>,
);
expect(screen.getByTestId("test-input")).toHaveValue("Test Value");
});
it("should render start content", async () => {
const startContent = <div>Start Content</div>;
render(
<SettingsInput
testId="test-input"
label="Test Input"
type="text"
defaultValue="Test Value"
startContent={startContent}
/>,
);
expect(screen.getByText("Start Content")).toBeInTheDocument();
});
it("should call onChange with the input value", async () => {
const onChangeMock = vi.fn();
const user = userEvent.setup();
render(
<SettingsInput
testId="test-input"
label="Test Input"
type="text"
onChange={onChangeMock}
/>,
);
const input = screen.getByTestId("test-input");
await user.type(input, "Test");
expect(onChangeMock).toHaveBeenCalledTimes(4);
expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test");
});
});

View File

@@ -0,0 +1,64 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
describe("SettingsSwitch", () => {
it("should call the onChange handler when the input is clicked", async () => {
const user = userEvent.setup();
const onToggleMock = vi.fn();
render(
<SettingsSwitch testId="test-switch" onToggle={onToggleMock}>
Test Switch
</SettingsSwitch>,
);
const switchInput = screen.getByTestId("test-switch");
await user.click(switchInput);
expect(onToggleMock).toHaveBeenCalledWith(true);
await user.click(switchInput);
expect(onToggleMock).toHaveBeenCalledWith(false);
});
it("should render a beta tag if isBeta is true", () => {
const { rerender } = render(
<SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta={false}>
Test Switch
</SettingsSwitch>,
);
expect(screen.queryByText(/beta/i)).not.toBeInTheDocument();
rerender(
<SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta>
Test Switch
</SettingsSwitch>,
);
expect(screen.getByText(/beta/i)).toBeInTheDocument();
});
it("should be able to set a default toggle state", async () => {
const user = userEvent.setup();
const onToggleMock = vi.fn();
render(
<SettingsSwitch
testId="test-switch"
onToggle={onToggleMock}
defaultIsToggled
>
Test Switch
</SettingsSwitch>,
);
expect(screen.getByTestId("test-switch")).toBeChecked();
const switchInput = screen.getByTestId("test-switch");
await user.click(switchInput);
expect(onToggleMock).toHaveBeenCalledWith(false);
expect(screen.getByTestId("test-switch")).not.toBeChecked();
});
});

View File

@@ -1,36 +1,22 @@
import { screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import userEvent from "@testing-library/user-event";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { screen } from "@testing-library/react";
import OpenHands from "#/api/open-hands";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { DEFAULT_SETTINGS } from "#/services/settings";
describe("SettingsForm", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const onCloseMock = vi.fn();
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const onCloseMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "123",
});
const RouterStub = createRoutesStub([
const RouteStub = createRoutesStub([
{
Component: () => (
<SettingsForm
settings={DEFAULT_SETTINGS}
models={["anthropic/claude-3-5-sonnet-20241022", "model2"]}
agents={["CodeActAgent", "agent2"]}
securityAnalyzers={["analyzer1", "analyzer2"]}
models={[DEFAULT_SETTINGS.LLM_MODEL]}
onClose={onCloseMock}
/>
),
@@ -38,39 +24,17 @@ describe("SettingsForm", () => {
},
]);
it("should not show runtime size selector by default", () => {
renderWithProviders(<RouterStub />);
expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument();
});
it("should show runtime size selector when advanced options are enabled", async () => {
it("should save the user settings and close the modal when the form is submitted", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub />);
renderWithProviders(<RouteStub />);
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
await user.click(toggleAdvancedMode);
await screen.findByTestId("runtime-size");
});
it("should not submit the form if required fields are empty", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub />);
expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument();
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
await user.click(toggleAdvancedMode);
const customModelInput = screen.getByTestId("custom-model-input");
expect(customModelInput).toBeInTheDocument();
await user.clear(customModelInput);
const saveButton = screen.getByTestId("save-settings-button");
const saveButton = screen.getByRole("button", { name: /save/i });
await user.click(saveButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
expect(onCloseMock).not.toHaveBeenCalled();
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
}),
);
});
});

View File

@@ -14,24 +14,14 @@ describe("UserActions", () => {
});
it("should render", () => {
render(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
/>,
);
render(<UserActions onLogout={onLogoutMock} />);
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should toggle the user menu when the user avatar is clicked", async () => {
render(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
/>,
);
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -47,30 +37,9 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
it("should call onClickAccountSettings and close the menu when the account settings option is clicked", async () => {
render(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
await user.click(accountSettingsOption);
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should call onLogout and close the menu when the logout option is clicked", async () => {
render(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
@@ -89,12 +58,7 @@ describe("UserActions", () => {
});
test("onLogout should not be called when the user is not logged in", async () => {
render(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
/>,
);
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -104,21 +68,4 @@ describe("UserActions", () => {
expect(onLogoutMock).not.toHaveBeenCalled();
});
// FIXME: Spinner now provided through useQuery
it.skip("should display the loading spinner", () => {
render(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
user.click(userAvatar);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
describe("useSaveSettings", () => {
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
result.current.mutate({ LLM_API_KEY: "" });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "",
}),
);
});
result.current.mutate({ LLM_API_KEY: null });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: undefined,
}),
);
});
});
});

View File

@@ -1,20 +1,21 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import i18n from '../../src/i18n';
import { AccountSettingsContextMenu } from '../../src/components/features/context-menu/account-settings-context-menu';
import { renderWithProviders } from '../../test-utils';
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import i18n from "../../src/i18n";
import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu";
import { renderWithProviders } from "../../test-utils";
describe('Translations', () => {
it('should render translated text', () => {
i18n.changeLanguage('en');
describe("Translations", () => {
it("should render translated text", () => {
i18n.changeLanguage("en");
renderWithProviders(
<AccountSettingsContextMenu
onClickAccountSettings={() => {}}
onLogout={() => {}}
onClose={() => {}}
isLoggedIn={true}
/>
isLoggedIn
/>,
);
expect(screen.getByTestId('account-settings-context-menu')).toBeInTheDocument();
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
});

View File

@@ -5,7 +5,6 @@ import { screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import App from "#/routes/_oh.app/route";
import OpenHands from "#/api/open-hands";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
describe("App", () => {
const RouteStub = createRoutesStub([
@@ -35,7 +34,7 @@ describe("App", () => {
await screen.findByTestId("app-route");
});
it.skipIf(!MULTI_CONVERSATION_UI)(
it(
"should call endSession if the user does not have permission to view conversation",
async () => {
const errorToastSpy = vi.spyOn(toast, "error");

View File

@@ -0,0 +1,117 @@
import { createRoutesStub } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import userEvent from "@testing-library/user-event";
import { screen } from "@testing-library/react";
import { AxiosError } from "axios";
import MainApp from "#/routes/_oh/route";
import SettingsScreen from "#/routes/settings";
import Home from "#/routes/_oh._index/route";
import OpenHands from "#/api/open-hands";
const createAxiosNotFoundErrorObject = () =>
new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 404,
statusText: "Not Found",
data: { message: "Settings not found" },
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);
describe("Home Screen", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const RouterStub = createRoutesStub([
{
// layout route
Component: MainApp,
path: "/",
children: [
{
// home route
Component: Home,
path: "/",
},
{
Component: SettingsScreen,
path: "/settings",
},
],
},
]);
afterEach(() => {
vi.clearAllMocks();
});
it("should render the home screen", () => {
renderWithProviders(<RouterStub initialEntries={["/"]} />);
});
it("should navigate to the settings screen when the settings button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsButton = await screen.findByTestId("settings-button");
await user.click(settingsButton);
const settingsScreen = await screen.findByTestId("settings-screen");
expect(settingsScreen).toBeInTheDocument();
});
it("should navigate to the settings when pressing 'Connect to GitHub' if the user isn't authenticated", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const connectToGitHubButton =
await screen.findByTestId("connect-to-github");
await user.click(connectToGitHubButton);
const settingsScreen = await screen.findByTestId("settings-screen");
expect(settingsScreen).toBeInTheDocument();
});
describe("Settings 404", () => {
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsScreen = screen.queryByTestId("settings-screen");
expect(settingsScreen).not.toBeInTheDocument();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const advancedSettingsButton = await screen.findByTestId(
"advanced-settings-link",
);
await user.click(advancedSettingsButton);
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,83 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as FeatureFlags from "#/utils/feature-flags";
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true);
const RoutesStub = createRoutesStub([
{
Component: SettingsScreen,
path: "/settings",
children: [
{
Component: () => <PaymentForm />,
path: "/settings/billing",
},
],
},
]);
const renderSettingsScreen = () =>
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
afterEach(() => {
vi.clearAllMocks();
});
it("should not render the navbar if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
});
});
it("should render the navbar if SaaS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.getByTestId("settings-navbar");
within(navbar).getByText("Account");
within(navbar).getByText("Credits");
});
});
it("should render the billing settings if clicking the credits item", async () => {
const user = userEvent.setup();
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).getByText("Credits");
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");
within(billingSection).getByText("Manage Credits");
});
});

View File

@@ -0,0 +1,956 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { afterEach, describe, expect, it, test, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent, { UserEvent } from "@testing-library/user-event";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import SettingsScreen from "#/routes/settings";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { PostApiSettings } from "#/types/settings";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";
const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
await user.click(advancedSwitch);
};
describe("Settings Screen", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const { handleLogoutMock } = vi.hoisted(() => ({
handleLogoutMock: vi.fn(),
}));
vi.mock("#/hooks/use-app-logout", () => ({
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
}));
afterEach(() => {
vi.clearAllMocks();
});
const RouterStub = createRoutesStub([
{
Component: SettingsScreen,
path: "/settings",
children: [{ Component: AccountSettings, path: "/settings" }],
},
]);
const renderSettingsScreen = () => {
const queryClient = new QueryClient();
return render(<RouterStub initialEntries={["/settings"]} />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</AuthProvider>
),
});
};
it("should render", async () => {
renderSettingsScreen();
await waitFor(() => {
screen.getByText("LLM Settings");
screen.getByText("GitHub Settings");
screen.getByText("Additional Settings");
screen.getByText("Reset to defaults");
screen.getByText("Save Changes");
});
});
describe("Account Settings", () => {
it("should render the account settings", async () => {
renderSettingsScreen();
await waitFor(() => {
screen.getByTestId("github-token-input");
screen.getByTestId("github-token-help-anchor");
screen.getByTestId("language-input");
screen.getByTestId("enable-analytics-switch");
});
});
// TODO: Set a better unset indicator
it.skip("should render an indicator if the GitHub token is not set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
});
renderSettingsScreen();
await waitFor(() => {
const input = screen.getByTestId("github-token-input");
const inputParent = input.parentElement;
if (inputParent) {
const badge = within(inputParent).getByTestId("unset-indicator");
expect(badge).toBeInTheDocument();
} else {
throw new Error("GitHub token input parent not found");
}
});
});
it("should set asterik placeholder if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderSettingsScreen();
await waitFor(() => {
const input = screen.getByTestId("github-token-input");
expect(input).toHaveProperty("placeholder", "**********");
});
});
it("should render an indicator if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderSettingsScreen();
const input = await screen.findByTestId("github-token-input");
const inputParent = input.parentElement;
if (inputParent) {
const badge = await within(inputParent).findByTestId("set-indicator");
expect(badge).toBeInTheDocument();
} else {
throw new Error("GitHub token input parent not found");
}
});
it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect from GitHub");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
});
it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect from GitHub");
expect(button).toBeInTheDocument();
expect(button).toBeEnabled();
// input should still be rendered
const input = await screen.findByTestId("github-token-input");
expect(input).toBeInTheDocument();
});
it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect from GitHub");
await user.click(button);
expect(handleLogoutMock).toHaveBeenCalled();
});
it("should not render the 'Configure GitHub Repositories' button if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
const button = screen.queryByText("Configure GitHub Repositories");
expect(button).not.toBeInTheDocument();
});
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
APP_SLUG: "test-app",
});
renderSettingsScreen();
await screen.findByText("Configure GitHub Repositories");
});
it("should not render the GitHub token input if SaaS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await waitFor(() => {
const input = screen.queryByTestId("github-token-input");
const helpAnchor = screen.queryByTestId("github-token-help-anchor");
expect(input).not.toBeInTheDocument();
expect(helpAnchor).not.toBeInTheDocument();
});
});
it.skip("should not reset LLM Provider and Model if GitHub token is invalid", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
llm_model: "anthropic/claude-3-5-sonnet-20241022",
});
saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token"));
renderSettingsScreen();
let llmProviderInput = await screen.findByTestId("llm-provider-input");
let llmModelInput = await screen.findByTestId("llm-model-input");
expect(llmProviderInput).toHaveValue("Anthropic");
expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022");
const input = await screen.findByTestId("github-token-input");
await user.type(input, "invalid-token");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
llmProviderInput = await screen.findByTestId("llm-provider-input");
llmModelInput = await screen.findByTestId("llm-model-input");
expect(llmProviderInput).toHaveValue("Anthropic");
expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022");
});
test("enabling advanced, enabling confirmation mode, and then disabling + enabling advanced should not render the security analyzer input", async () => {
const user = userEvent.setup();
renderSettingsScreen();
await toggleAdvancedSettings(user);
const confirmationModeSwitch = await screen.findByTestId(
"enable-confirmation-mode-switch",
);
await user.click(confirmationModeSwitch);
let securityAnalyzerInput = screen.queryByTestId(
"security-analyzer-input",
);
expect(securityAnalyzerInput).toBeInTheDocument();
await toggleAdvancedSettings(user);
securityAnalyzerInput = screen.queryByTestId("security-analyzer-input");
expect(securityAnalyzerInput).not.toBeInTheDocument();
await toggleAdvancedSettings(user);
securityAnalyzerInput = screen.queryByTestId("security-analyzer-input");
expect(securityAnalyzerInput).not.toBeInTheDocument();
});
});
describe("LLM Settings", () => {
it("should render the basic LLM settings by default", async () => {
renderSettingsScreen();
await waitFor(() => {
screen.getByTestId("advanced-settings-switch");
screen.getByTestId("llm-provider-input");
screen.getByTestId("llm-model-input");
screen.getByTestId("llm-api-key-input");
screen.getByTestId("llm-api-key-help-anchor");
});
});
it("should render the advanced LLM settings if the advanced switch is toggled", async () => {
const user = userEvent.setup();
renderSettingsScreen();
// Should not render the advanced settings by default
expect(
screen.queryByTestId("llm-custom-model-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
const advancedSwitch = await screen.findByTestId(
"advanced-settings-switch",
);
await user.click(advancedSwitch);
// Should render the advanced settings
expect(
screen.queryByTestId("llm-provider-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("llm-model-input")).not.toBeInTheDocument();
screen.getByTestId("llm-custom-model-input");
screen.getByTestId("base-url-input");
screen.getByTestId("agent-input");
// "Invariant" security analyzer
screen.getByTestId("enable-confirmation-mode-switch");
// Not rendered until the switch is toggled
// screen.getByTestId("security-analyzer-input");
});
// TODO: Set a better unset indicator
it.skip("should render an indicator if the LLM API key is not set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: null,
});
renderSettingsScreen();
await waitFor(() => {
const input = screen.getByTestId("llm-api-key-input");
const inputParent = input.parentElement;
if (inputParent) {
const badge = within(inputParent).getByTestId("unset-indicator");
expect(badge).toBeInTheDocument();
} else {
throw new Error("LLM API Key input parent not found");
}
});
});
it("should render an indicator if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: "**********",
});
renderSettingsScreen();
await waitFor(() => {
const input = screen.getByTestId("llm-api-key-input");
const inputParent = input.parentElement;
if (inputParent) {
const badge = within(inputParent).getByTestId("set-indicator");
expect(badge).toBeInTheDocument();
} else {
throw new Error("LLM API Key input parent not found");
}
});
});
it("should set asterik placeholder if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: "**********",
});
renderSettingsScreen();
await waitFor(() => {
const input = screen.getByTestId("llm-api-key-input");
expect(input).toHaveProperty("placeholder", "**********");
});
});
describe("Basic Model Selector", () => {
it("should set the provider and model", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "anthropic/claude-3-5-sonnet-20241022",
});
renderSettingsScreen();
await waitFor(() => {
const providerInput = screen.getByTestId("llm-provider-input");
const modelInput = screen.getByTestId("llm-model-input");
expect(providerInput).toHaveValue("Anthropic");
expect(modelInput).toHaveValue("claude-3-5-sonnet-20241022");
});
});
it.todo("should change the model values if the provider is changed");
it.todo("should clear the model values if the provider is cleared");
});
describe("Advanced LLM Settings", () => {
it("should not render the runtime settings input if OSS mode", async () => {
const user = userEvent.setup();
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await toggleAdvancedSettings(user);
const input = screen.queryByTestId("runtime-settings-input");
expect(input).not.toBeInTheDocument();
});
it("should render the runtime settings input if SaaS mode", async () => {
const user = userEvent.setup();
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await toggleAdvancedSettings(user);
screen.getByTestId("runtime-settings-input");
});
it("should set the default runtime setting set", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
remote_runtime_resource_factor: 1,
});
renderSettingsScreen();
await toggleAdvancedSettings(userEvent.setup());
const input = await screen.findByTestId("runtime-settings-input");
expect(input).toHaveValue("1x (2 core, 8G)");
});
it("should always have the runtime input disabled", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
renderSettingsScreen();
await toggleAdvancedSettings(userEvent.setup());
const input = await screen.findByTestId("runtime-settings-input");
expect(input).toBeDisabled();
});
it.skip("should save the runtime settings when the 'Save Changes' button is clicked", async () => {
const user = userEvent.setup();
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
await toggleAdvancedSettings(user);
const input = await screen.findByTestId("runtime-settings-input");
await user.click(input);
const option = await screen.findByText("2x (4 core, 16G)");
await user.click(option);
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
remote_runtime_resource_factor: 2,
}),
);
});
test("saving with no changes but having advanced enabled should hide the advanced items", async () => {
const user = userEvent.setup();
renderSettingsScreen();
await toggleAdvancedSettings(user);
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
await waitFor(() => {
expect(
screen.queryByTestId("llm-custom-model-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("base-url-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
});
});
test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => {
const user = userEvent.setup();
renderSettingsScreen();
await toggleAdvancedSettings(user);
const resetButton = screen.getByText("Reset to defaults");
await user.click(resetButton);
// show modal
const modal = await screen.findByTestId("reset-modal");
expect(modal).toBeInTheDocument();
// confirm reset
const confirmButton = within(modal).getByText("Reset");
await user.click(confirmButton);
await waitFor(() => {
expect(
screen.queryByTestId("llm-custom-model-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("base-url-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
});
});
it("should save if only confirmation mode is enabled", async () => {
const user = userEvent.setup();
renderSettingsScreen();
await toggleAdvancedSettings(user);
const confirmationModeSwitch = await screen.findByTestId(
"enable-confirmation-mode-switch",
);
await user.click(confirmationModeSwitch);
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
confirmation_mode: true,
}),
);
});
});
it("should toggle advanced if user had set a custom model", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "some/custom-model",
});
renderSettingsScreen();
await waitFor(() => {
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).toBeChecked();
const llmCustomInput = screen.getByTestId("llm-custom-model-input");
expect(llmCustomInput).toBeInTheDocument();
expect(llmCustomInput).toHaveValue("some/custom-model");
});
});
it("should have advanced settings enabled if the user previously had them enabled", async () => {
const hasAdvancedSettingsSetSpy = vi.spyOn(
AdvancedSettingsUtlls,
"hasAdvancedSettingsSet",
);
hasAdvancedSettingsSetSpy.mockReturnValue(true);
renderSettingsScreen();
await waitFor(() => {
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).toBeChecked();
const llmCustomInput = screen.getByTestId("llm-custom-model-input");
expect(llmCustomInput).toBeInTheDocument();
});
});
it("should have confirmation mode enabled if the user previously had it enabled", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
confirmation_mode: true,
});
renderSettingsScreen();
await waitFor(() => {
const confirmationModeSwitch = screen.getByTestId(
"enable-confirmation-mode-switch",
);
expect(confirmationModeSwitch).toBeChecked();
});
});
// FIXME: security analyzer is not found for some reason...
it.skip("should have the values set if the user previously had them set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
github_token_is_set: true,
user_consents_to_analytics: true,
llm_base_url: "https://test.com",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
agent: "CoActAgent",
security_analyzer: "mock-invariant",
});
renderSettingsScreen();
await waitFor(() => {
expect(screen.getByTestId("language-input")).toHaveValue("Norsk");
expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument();
expect(screen.getByTestId("enable-analytics-switch")).toBeChecked();
expect(screen.getByTestId("advanced-settings-switch")).toBeChecked();
expect(screen.getByTestId("base-url-input")).toHaveValue(
"https://test.com",
);
expect(screen.getByTestId("llm-custom-model-input")).toHaveValue(
"anthropic/claude-3-5-sonnet-20241022",
);
expect(screen.getByTestId("agent-input")).toHaveValue("CoActAgent");
expect(
screen.getByTestId("enable-confirmation-mode-switch"),
).toBeChecked();
expect(screen.getByTestId("security-analyzer-input")).toHaveValue(
"mock-invariant",
);
});
});
it("should save the settings when the 'Save Changes' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
const languageInput = await screen.findByTestId("language-input");
await user.click(languageInput);
const norskOption = await screen.findByText("Norsk");
await user.click(norskOption);
expect(languageInput).toHaveValue("Norsk");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "", // empty because it's not set previously
github_token: undefined,
language: "no",
}),
);
});
it("should properly save basic LLM model settings", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
});
renderSettingsScreen();
// disable advanced mode
const advancedSwitch = await screen.findByTestId(
"advanced-settings-switch",
);
await user.click(advancedSwitch);
const providerInput = await screen.findByTestId("llm-provider-input");
await user.click(providerInput);
const openaiOption = await screen.findByText("OpenAI");
await user.click(openaiOption);
const modelInput = await screen.findByTestId("llm-model-input");
await user.click(modelInput);
const gpt4Option = await screen.findByText("gpt-4o");
await user.click(gpt4Option);
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
github_token: undefined,
llm_api_key: "", // empty because it's not set previously
llm_model: "openai/gpt-4o",
}),
);
});
it("should reset the settings when the 'Reset to defaults' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderSettingsScreen();
const languageInput = await screen.findByTestId("language-input");
await user.click(languageInput);
const norskOption = await screen.findByText("Norsk");
await user.click(norskOption);
expect(languageInput).toHaveValue("Norsk");
const resetButton = screen.getByText("Reset to defaults");
await user.click(resetButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
// show modal
const modal = await screen.findByTestId("reset-modal");
expect(modal).toBeInTheDocument();
// confirm reset
const confirmButton = within(modal).getByText("Reset");
await user.click(confirmButton);
const mockCopy: Partial<PostApiSettings> = {
...MOCK_DEFAULT_USER_SETTINGS,
};
delete mockCopy.github_token_is_set;
delete mockCopy.unset_github_token;
delete mockCopy.user_consents_to_analytics;
expect(saveSettingsSpy).toHaveBeenCalledWith({
...mockCopy,
github_token: undefined, // not set
llm_api_key: "", // reset as well
});
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
});
it("should cancel the reset when the 'Cancel' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderSettingsScreen();
const resetButton = await screen.findByText("Reset to defaults");
await user.click(resetButton);
const modal = await screen.findByTestId("reset-modal");
expect(modal).toBeInTheDocument();
const cancelButton = within(modal).getByText("Cancel");
await user.click(cancelButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
});
it("should call handleCaptureConsent with true if the save is successful", async () => {
const user = userEvent.setup();
const handleCaptureConsentSpy = vi.spyOn(
ConsentHandlers,
"handleCaptureConsent",
);
renderSettingsScreen();
const analyticsConsentInput = await screen.findByTestId(
"enable-analytics-switch",
);
expect(analyticsConsentInput).not.toBeChecked();
await user.click(analyticsConsentInput);
expect(analyticsConsentInput).toBeChecked();
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
});
it("should call handleCaptureConsent with false if the save is successful", async () => {
const user = userEvent.setup();
const handleCaptureConsentSpy = vi.spyOn(
ConsentHandlers,
"handleCaptureConsent",
);
renderSettingsScreen();
const saveButton = await screen.findByText("Save Changes");
await user.click(saveButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
});
it("should not reset analytics consent when resetting to defaults", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
});
renderSettingsScreen();
const analyticsConsentInput = await screen.findByTestId(
"enable-analytics-switch",
);
expect(analyticsConsentInput).toBeChecked();
const resetButton = await screen.findByText("Reset to defaults");
await user.click(resetButton);
const modal = await screen.findByTestId("reset-modal");
const confirmButton = within(modal).getByText("Reset");
await user.click(confirmButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ user_consents_to_analytics: undefined }),
);
});
it("should render the security analyzer input if the confirmation mode is enabled", async () => {
const user = userEvent.setup();
renderSettingsScreen();
let securityAnalyzerInput = screen.queryByTestId(
"security-analyzer-input",
);
expect(securityAnalyzerInput).not.toBeInTheDocument();
const confirmationModeSwitch = await screen.findByTestId(
"enable-confirmation-mode-switch",
);
await user.click(confirmationModeSwitch);
securityAnalyzerInput = await screen.findByTestId(
"security-analyzer-input",
);
expect(securityAnalyzerInput).toBeInTheDocument();
});
// FIXME: localStorage isn't being set
it.skip("should save with ENABLE_DEFAULT_CONDENSER with true if user set the feature flag in local storage", async () => {
localStorage.setItem("ENABLE_DEFAULT_CONDENSER", "true");
const user = userEvent.setup();
renderSettingsScreen();
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
enable_default_condenser: true,
}),
);
});
it("should send an empty LLM API Key if the user submits an empty string", async () => {
const user = userEvent.setup();
renderSettingsScreen();
const input = await screen.findByTestId("llm-api-key-input");
expect(input).toHaveValue("");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ llm_api_key: "" }),
);
});
it("should not send an empty LLM API Key if the user submits an empty string but already has it set", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: "**********",
});
renderSettingsScreen();
const input = await screen.findByTestId("llm-api-key-input");
expect(input).toHaveValue("");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ llm_api_key: undefined }),
);
});
it("should submit the LLM API Key if it is the first time the user sets it", async () => {
const user = userEvent.setup();
renderSettingsScreen();
const input = await screen.findByTestId("llm-api-key-input");
await user.type(input, "new-api-key");
const saveButton = screen.getByText("Save Changes");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ llm_api_key: "new-api-key" }),
);
});
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { amountIsValid } from "#/utils/amount-is-valid";
describe("amountIsValid", () => {
describe("fails", () => {
test("when the amount is negative", () => {
expect(amountIsValid("-5")).toBe(false);
expect(amountIsValid("-25")).toBe(false);
});
test("when the amount is zero", () => {
expect(amountIsValid("0")).toBe(false);
});
test("when an empty string is passed", () => {
expect(amountIsValid("")).toBe(false);
expect(amountIsValid(" ")).toBe(false);
});
test("when a non-numeric value is passed", () => {
expect(amountIsValid("abc")).toBe(false);
expect(amountIsValid("1abc")).toBe(false);
expect(amountIsValid("abc1")).toBe(false);
});
test("when an amount less than the minimum is passed", () => {
// test assumes the minimum is 25
expect(amountIsValid("24")).toBe(false);
expect(amountIsValid("24.99")).toBe(false);
});
});
});

View File

@@ -0,0 +1,56 @@
import { describe, expect, it, test } from "vitest";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { DEFAULT_SETTINGS } from "#/services/settings";
describe("hasAdvancedSettingsSet", () => {
it("should return false by default", () => {
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
});
describe("should be true if", () => {
test("LLM_BASE_URL is set", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
LLM_BASE_URL: "test",
}),
).toBe(true);
});
test("AGENT is not default value", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
AGENT: "test",
}),
).toBe(true);
});
test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
REMOTE_RUNTIME_RESOURCE_FACTOR: 999,
}),
).toBe(true);
});
test("CONFIRMATION_MODE is true", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
CONFIRMATION_MODE: true,
}),
).toBe(true);
});
test("SECURITY_ANALYZER is set", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
SECURITY_ANALYZER: "test",
}),
).toBe(true);
});
});
});

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { isCustomModel } from "#/utils/is-custom-model";
describe("isCustomModel", () => {
const models = ["anthropic/claude-3.5", "openai/gpt-3.5-turbo", "gpt-4o"];
it("should return false by default", () => {
expect(isCustomModel(models, "")).toBe(false);
});
it("should be true if it is a custom model", () => {
expect(isCustomModel(models, "some/model")).toBe(true);
});
it("should be false if it is not a custom model", () => {
expect(isCustomModel(models, "anthropic/claude-3.5")).toBe(false);
expect(isCustomModel(models, "openai/gpt-3.5-turbo")).toBe(false);
expect(isCustomModel(models, "openai/gpt-4o")).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.23.0",
"version": "0.24.0",
"private": true,
"type": "module",
"engines": {
@@ -13,26 +13,28 @@
"@react-router/serve": "^7.1.5",
"@react-types/shared": "^3.27.0",
"@reduxjs/toolkit": "^2.5.1",
"@tanstack/react-query": "^5.66.0",
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0",
"@tanstack/react-query": "^5.66.7",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.4.2",
"framer-motion": "^12.4.4",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-browser-languagedetector": "^8.0.3",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.22",
"jose": "^5.9.4",
"jose": "^5.10.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.216.0",
"posthog-js": "^1.219.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.4.0",
"react-i18next": "^15.4.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-redux": "^9.2.0",
@@ -40,7 +42,7 @@
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.7",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.0",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.0.1",
"vite": "^6.1.0",
@@ -49,7 +51,8 @@
},
"scripts": {
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router dev",
"dev:mock:saas": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=true react-router dev",
"build": "npm run make-i18n && npm run typecheck && react-router build",
"start": "npx sirv-cli build/ --single",
"test": "vitest run",
@@ -85,7 +88,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.13.1",
"@types/node": "^22.13.4",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-highlight": "^0.12.8",
@@ -93,7 +96,7 @@
"@types/ws": "^8.5.14",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/coverage-v8": "^3.0.6",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
@@ -109,8 +112,9 @@
"jsdom": "^26.0.0",
"lint-staged": "^15.4.3",
"msw": "^2.6.6",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"postcss": "^8.5.2",
"prettier": "^3.5.1",
"stripe": "^17.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite-plugin-svgr": "^4.2.0",

View File

@@ -13,7 +13,7 @@ import {
GetTrajectoryResponse,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings } from "#/types/settings";
import { ApiSettings, PostApiSettings } from "#/types/settings";
class OpenHands {
/**
@@ -229,6 +229,7 @@ class OpenHands {
): Promise<Conversation> {
const body = {
selected_repository: selectedRepository,
selected_branch: undefined,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
};
@@ -266,11 +267,30 @@ class OpenHands {
* Save the settings to the server. Only valid settings are saved.
* @param settings - the settings to save
*/
static async saveSettings(settings: Partial<ApiSettings>): Promise<boolean> {
static async saveSettings(
settings: Partial<PostApiSettings>,
): Promise<boolean> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
static async createCheckoutSession(amount: number): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-checkout-session",
{
amount,
},
);
return data.redirect_url;
}
static async getBalance(): Promise<string> {
const { data } = await openHands.get<{ credits: string }>(
"/api/billing/credits",
);
return data.credits;
}
static async getGitHubUser(): Promise<GitHubUser> {
const response = await openHands.get<GitHubUser>("/api/github/user");

View File

@@ -27,11 +27,10 @@ export function AnalyticsConsentFormModal({
{
onSuccess: () => {
handleCaptureConsent(analytics);
onClose();
},
},
);
onClose();
};
return (

View File

@@ -1,19 +1,16 @@
import { useTranslation } from "react-i18next";
import { ContextMenu } from "./context-menu";
import { ContextMenuListItem } from "./context-menu-list-item";
import { ContextMenuSeparator } from "./context-menu-separator";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { I18nKey } from "#/i18n/declaration";
interface AccountSettingsContextMenuProps {
onClickAccountSettings: () => void;
onLogout: () => void;
onClose: () => void;
isLoggedIn: boolean;
}
export function AccountSettingsContextMenu({
onClickAccountSettings,
onLogout,
onClose,
isLoggedIn,
@@ -25,15 +22,8 @@ export function AccountSettingsContextMenu({
<ContextMenu
testId="account-settings-context-menu"
ref={ref}
className="absolute left-full -top-1 z-10"
className="absolute right-full md:left-full -top-1 z-10"
>
<ContextMenuListItem
testId="account-settings-button"
onClick={onClickAccountSettings}
>
{t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
</ContextMenuListItem>
<ContextMenuSeparator />
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>

View File

@@ -22,7 +22,10 @@ export function ConfirmDeleteModal({
<BaseModalTitle title="Are you sure you want to delete this project?" />
<BaseModalDescription description="All data associated with this project will be lost." />
</div>
<div className="flex flex-col gap-2 w-full">
<div
className="flex flex-col gap-2 w-full"
onClick={(event) => event.stopPropagation()}
>
<ModalButton
onClick={onConfirm}
className="bg-danger font-bold"

View File

@@ -68,6 +68,7 @@ export function ConversationCard({
event.preventDefault();
event.stopPropagation();
onDelete?.();
setContextMenuVisible(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {

View File

@@ -44,12 +44,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const handleConfirmDelete = () => {
if (selectedConversationId) {
deleteConversation({ conversationId: selectedConversationId });
setConfirmDeleteModalVisible(false);
if (cid === selectedConversationId) {
endSession();
}
deleteConversation(
{ conversationId: selectedConversationId },
{
onSuccess: () => {
if (cid === selectedConversationId) {
endSession();
}
},
},
);
}
};
@@ -110,7 +114,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
{confirmDeleteModalVisible && (
<ConfirmDeleteModal
onConfirm={handleConfirmDelete}
onConfirm={() => {
handleConfirmDelete();
setConfirmDeleteModalVisible(false);
}}
onCancel={() => setConfirmDeleteModalVisible(false)}
/>
)}

View File

@@ -1,5 +1,6 @@
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 GitHubLogo from "#/assets/branding/github-logo.svg?react";
@@ -10,7 +11,6 @@ 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 { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
@@ -24,8 +24,7 @@ export function GitHubRepositoriesSuggestionBox({
user,
}: GitHubRepositoriesSuggestionBoxProps) {
const { t } = useTranslation();
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = React.useState<string>("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
@@ -45,39 +44,33 @@ export function GitHubRepositoriesSuggestionBox({
if (gitHubAuthUrl) {
window.location.href = gitHubAuthUrl;
} else {
setConnectToGitHubModalOpen(true);
navigate("/settings");
}
};
const isLoggedIn = !!user;
return (
<>
<SuggestionBox
title={t(I18nKey.LANDING$OPEN_REPO)}
content={
isLoggedIn ? (
<GitHubRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
publicRepositories={searchedRepos || []}
userRepositories={repositories}
/>
) : (
<ModalButton
text={t(I18nKey.GITHUB$CONNECT)}
icon={<GitHubLogo width={20} height={20} />}
className="bg-[#791B80] w-full"
onClick={handleConnectToGitHub}
/>
)
}
/>
{connectToGitHubModalOpen && (
<AccountSettingsModal
onClose={() => setConnectToGitHubModalOpen(false)}
/>
)}
</>
<SuggestionBox
title={t(I18nKey.LANDING$OPEN_REPO)}
content={
isLoggedIn ? (
<GitHubRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
publicRepositories={searchedRepos || []}
userRepositories={repositories}
/>
) : (
<ModalButton
testId="connect-to-github"
text={t(I18nKey.GITHUB$CONNECT)}
icon={<GitHubLogo width={20} height={20} />}
className="bg-[#791B80] w-full"
onClick={handleConnectToGitHub}
/>
)
}
/>
);
}

View File

@@ -0,0 +1,92 @@
import React from "react";
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
import { useBalance } from "#/hooks/query/use-balance";
import { cn } from "#/utils/utils";
import MoneyIcon from "#/icons/money.svg?react";
import { SettingsInput } from "../settings/settings-input";
import { BrandButton } from "../settings/brand-button";
import { HelpLink } from "../settings/help-link";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { amountIsValid } from "#/utils/amount-is-valid";
export function PaymentForm() {
const { data: balance, isLoading } = useBalance();
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
const billingFormAction = async (formData: FormData) => {
const amount = formData.get("top-up-input")?.toString();
if (amount?.trim()) {
if (!amountIsValid(amount)) return;
const float = parseFloat(amount);
addBalance({ amount: Number(float.toFixed(2)) });
}
setButtonIsDisabled(true);
};
const handleTopUpInputChange = (value: string) => {
setButtonIsDisabled(!amountIsValid(value));
};
return (
<form
action={billingFormAction}
data-testid="billing-settings"
className="flex flex-col gap-6 px-11 py-9"
>
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
Manage Credits
</h2>
<div
className={cn(
"flex items-center justify-between w-[680px] bg-[#7F7445] rounded px-3 py-2",
"text-[28px] leading-8 -tracking-[0.02em] font-bold",
)}
>
<div className="flex items-center gap-2">
<MoneyIcon width={22} height={14} />
<span>Balance</span>
</div>
{!isLoading && (
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>
)}
{isLoading && <LoadingSpinner size="small" />}
</div>
<div className="flex flex-col gap-3">
<SettingsInput
testId="top-up-input"
name="top-up-input"
onChange={handleTopUpInputChange}
type="text"
label="Top-up amount"
placeholder="Specify an amount to top up your credits"
className="w-[680px]"
/>
<div className="flex items-center w-[680px] gap-2">
<BrandButton
variant="primary"
type="submit"
isDisabled={isPending || buttonIsDisabled}
>
Add credit
</BrandButton>
{isPending && <LoadingSpinner size="small" />}
</div>
</div>
<HelpLink
testId="payment-methods-link"
href="https://stripe.com/"
text="Manage payment methods on"
linkText="Stripe"
/>
</form>
);
}

View File

@@ -0,0 +1,39 @@
import { cn } from "#/utils/utils";
interface BrandButtonProps {
testId?: string;
variant: "primary" | "secondary";
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
isDisabled?: boolean;
className?: string;
onClick?: () => void;
}
export function BrandButton({
testId,
children,
variant,
type,
isDisabled,
className,
onClick,
}: React.PropsWithChildren<BrandButtonProps>) {
return (
<button
data-testid={testId}
disabled={isDisabled}
// The type is alreadt passed as a prop to the button component
// eslint-disable-next-line react/button-has-type
type={type}
onClick={onClick}
className={cn(
"w-fit p-2 rounded disabled:opacity-30 disabled:cursor-not-allowed",
variant === "primary" && "bg-[#C9B974] text-[#0D0F11]",
variant === "secondary" && "border border-[#C9B974] text-[#C9B974]",
className,
)}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,22 @@
interface HelpLinkProps {
testId: string;
text: string;
linkText: string;
href: string;
}
export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
return (
<p data-testid={testId} className="text-xs">
{text}{" "}
<a
href={href}
rel="noreferrer noopener"
target="_blank"
className="underline underline-offset-2"
>
{linkText}
</a>
</p>
);
}

View File

@@ -0,0 +1,16 @@
import SuccessIcon from "#/icons/success.svg?react";
import { cn } from "#/utils/utils";
interface KeyStatusIconProps {
isSet: boolean;
}
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
return (
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
<SuccessIcon
className={cn(isSet ? "text-[#A5E75E]" : "text-[#E76A5E]")}
/>
</span>
);
}

View File

@@ -0,0 +1,3 @@
export function OptionalTag() {
return <span className="text-xs text-[#B7BDC2]">(Optional)</span>;
}

View File

@@ -0,0 +1,56 @@
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import { OptionalTag } from "./optional-tag";
interface SettingsDropdownInputProps {
testId: string;
label: string;
name: string;
items: { key: React.Key; label: string }[];
showOptionalTag?: boolean;
isDisabled?: boolean;
defaultSelectedKey?: string;
isClearable?: boolean;
}
export function SettingsDropdownInput({
testId,
label,
name,
items,
showOptionalTag,
isDisabled,
defaultSelectedKey,
isClearable,
}: SettingsDropdownInputProps) {
return (
<label className="flex flex-col gap-2.5 w-[680px]">
<div className="flex items-center gap-1">
<span className="text-sm">{label}</span>
{showOptionalTag && <OptionalTag />}
</div>
<Autocomplete
aria-label={label}
data-testid={testId}
name={name}
defaultItems={items}
defaultSelectedKey={defaultSelectedKey}
isClearable={isClearable}
isDisabled={isDisabled}
className="w-full"
classNames={{
popoverContent: "bg-[#454545] rounded-xl border border-[#717888]",
}}
inputProps={{
classNames: {
inputWrapper:
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
},
}}
>
{(item) => (
<AutocompleteItem key={item.key}>{item.label}</AutocompleteItem>
)}
</Autocomplete>
</label>
);
}

View File

@@ -0,0 +1,53 @@
import { cn } from "#/utils/utils";
import { OptionalTag } from "./optional-tag";
interface SettingsInputProps {
testId?: string;
name?: string;
label: string;
type: React.HTMLInputTypeAttribute;
defaultValue?: string;
placeholder?: string;
showOptionalTag?: boolean;
isDisabled?: boolean;
startContent?: React.ReactNode;
className?: string;
onChange?: (value: string) => void;
}
export function SettingsInput({
testId,
name,
label,
type,
defaultValue,
placeholder,
showOptionalTag,
isDisabled,
startContent,
className,
onChange,
}: SettingsInputProps) {
return (
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
<div className="flex items-center gap-2">
{startContent}
<span className="text-sm">{label}</span>
{showOptionalTag && <OptionalTag />}
</div>
<input
data-testid={testId}
onChange={(e) => onChange?.(e.target.value)}
name={name}
disabled={isDisabled}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
className={cn(
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-[#B7BDC2]",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</label>
);
}

View File

@@ -0,0 +1,50 @@
import React from "react";
import { StyledSwitchComponent } from "./styled-switch-component";
interface SettingsSwitchProps {
testId?: string;
name?: string;
onToggle?: (value: boolean) => void;
defaultIsToggled?: boolean;
isBeta?: boolean;
}
export function SettingsSwitch({
children,
testId,
name,
onToggle,
defaultIsToggled,
isBeta,
}: React.PropsWithChildren<SettingsSwitchProps>) {
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
const handleToggle = (value: boolean) => {
setIsToggled(value);
onToggle?.(value);
};
return (
<label className="flex items-center gap-2 w-fit">
<input
hidden
data-testid={testId}
name={name}
type="checkbox"
onChange={(e) => handleToggle(e.target.checked)}
defaultChecked={defaultIsToggled}
/>
<StyledSwitchComponent isToggled={isToggled} />
<div className="flex items-center gap-1">
<span className="text-sm">{children}</span>
{isBeta && (
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-[#C9B974] px-1 rounded-full">
Beta
</span>
)}
</div>
</label>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from "#/utils/utils";
interface StyledSwitchComponentProps {
isToggled: boolean;
}
export function StyledSwitchComponent({
isToggled,
}: StyledSwitchComponentProps) {
return (
<div
className={cn(
"w-12 h-6 rounded-xl flex items-center p-1.5 cursor-pointer",
isToggled && "justify-end bg-[#C9B974]",
!isToggled && "justify-start bg-[#1F2228] border border-[#B7BDC2]",
)}
>
<div
className={cn(
"bg-[#1F2228] w-3 h-3 rounded-xl",
isToggled ? "bg-[#1F2228]" : "bg-[#B7BDC2]",
)}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import toast from "react-hot-toast";
import { NavLink, useLocation } from "react-router";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
@@ -10,12 +11,10 @@ import { DocsButton } from "#/components/shared/buttons/docs-button";
import { ExitProjectButton } from "#/components/shared/buttons/exit-project-button";
import { SettingsButton } from "#/components/shared/buttons/settings-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
import { useCurrentSettings } from "#/context/settings-context";
import { useSettings } from "#/hooks/query/use-settings";
import { ConversationPanel } from "../conversation-panel/conversation-panel";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { useEndSession } from "#/hooks/use-end-session";
import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
@@ -23,58 +22,55 @@ import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
import { cn } from "#/utils/utils";
export function Sidebar() {
const location = useLocation();
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitHubUser();
const { data: config } = useConfig();
const {
data: settings,
error: settingsError,
isError: settingsIsError,
isFetching: isFetchingSettings,
} = useSettings();
const { mutateAsync: logout } = useLogout();
const { saveUserSettings } = useCurrentSettings();
const { settings, saveUserSettings } = useCurrentSettings();
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [conversationPanelIsOpen, setConversationPanelIsOpen] =
React.useState(false);
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
if (user.isError) {
setAccountSettingsModalOpen(true);
}
}, [user.isError]);
React.useEffect(() => {
// We don't show toast errors for settings in the global error handler
// because we have a special case for 404 errors
if (
if (location.pathname === "/settings") {
setSettingsModalIsOpen(false);
} else if (
!isFetchingSettings &&
settingsIsError &&
settingsError?.status !== 404
) {
// We don't show toast errors for settings in the global error handler
// because we have a special case for 404 errors
toast.error(
"Something went wrong while fetching settings. Please reload the page.",
);
} else if (settingsError?.status === 404) {
setSettingsModalIsOpen(true);
}
}, [settingsError?.status, settingsError, isFetchingSettings]);
}, [
settingsError?.status,
settingsError,
isFetchingSettings,
location.pathname,
]);
const handleEndSession = () => {
dispatch(setCurrentAgentState(AgentState.LOADING));
endSession();
};
const handleAccountSettingsModalClose = () => {
setAccountSettingsModalOpen(false);
};
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else await saveUserSettings({ unset_github_token: true });
@@ -84,33 +80,47 @@ export function Sidebar() {
return (
<>
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
<div className="w-[34px] h-[34px] flex items-center justify-center mb-7">
<AllHandsLogoButton onClick={handleEndSession} />
</div>
{user.isLoading && <LoadingSpinner size="small" />}
<ExitProjectButton onClick={handleEndSession} />
{MULTI_CONVERSATION_UI && (
<nav className="flex flex-row md:flex-col items-center justify-between w-full h-auto md:w-auto md:h-full">
<div className="flex flex-row md:flex-col items-center gap-[26px]">
<div className="flex items-center justify-center">
<AllHandsLogoButton onClick={handleEndSession} />
</div>
<ExitProjectButton onClick={handleEndSession} />
<TooltipButton
testId="toggle-conversation-panel"
tooltip="Conversations"
ariaLabel="Conversations"
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
>
<FaListUl size={22} />
<FaListUl
size={22}
className={cn(
conversationPanelIsOpen ? "text-white" : "text-[#9099AC]",
)}
/>
</TooltipButton>
)}
<DocsButton />
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
{!user.isLoading && (
<UserActions
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined
<DocsButton />
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<NavLink
to="/settings"
className={({ isActive }) =>
`${isActive ? "text-white" : "text-[#9099AC]"} mt-0.5 md:mt-0`
}
onLogout={handleLogout}
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
)}
>
<SettingsButton />
</NavLink>
{!user.isLoading && (
<UserActions
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined
}
onLogout={handleLogout}
/>
)}
{user.isLoading && <LoadingSpinner size="small" />}
</div>
</nav>
{conversationPanelIsOpen && (
@@ -122,10 +132,7 @@ export function Sidebar() {
)}
</aside>
{accountSettingsModalOpen && (
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
)}
{(settingsError?.status === 404 || settingsModalIsOpen) && (
{settingsModalIsOpen && (
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}

View File

@@ -3,16 +3,11 @@ import { UserAvatar } from "./user-avatar";
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
interface UserActionsProps {
onClickAccountSettings: () => void;
onLogout: () => void;
user?: { avatar_url: string };
}
export function UserActions({
onClickAccountSettings,
onLogout,
user,
}: UserActionsProps) {
export function UserActions({ onLogout, user }: UserActionsProps) {
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
React.useState(false);
@@ -24,11 +19,6 @@ export function UserActions({
setAccountContextMenuIsVisible(false);
};
const handleClickAccountSettings = () => {
onClickAccountSettings();
closeAccountMenu();
};
const handleLogout = () => {
onLogout();
closeAccountMenu();
@@ -41,7 +31,6 @@ export function UserActions({
{accountContextMenuIsVisible && (
<AccountSettingsContextMenu
isLoggedIn={!!user}
onClickAccountSettings={handleClickAccountSettings}
onLogout={handleLogout}
onClose={closeAccountMenu}
/>

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import DefaultUserAvatar from "#/icons/default-user.svg?react";
import ProfileIcon from "#/icons/profile.svg?react";
import { cn } from "#/utils/utils";
import { Avatar } from "./avatar";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
@@ -21,16 +21,17 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)}
onClick={onClick}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200",
"w-8 h-8 rounded-full flex items-center justify-center",
isLoading && "bg-transparent",
)}
>
{!isLoading && avatarUrl && <Avatar src={avatarUrl} />}
{!isLoading && !avatarUrl && (
<DefaultUserAvatar
<ProfileIcon
aria-label={t(I18nKey.USER$AVATAR_PLACEHOLDER)}
width={20}
height={20}
width={28}
height={28}
className="text-[#9099AC]"
/>
)}
{isLoading && <LoadingSpinner size="small" />}

View File

@@ -23,7 +23,7 @@ export function ActionButton({
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
type="button"
>
<span className="relative z-10 group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
<span className="relative group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
{children}
</span>
<span className="absolute -inset-[5px] border-2 border-red-400/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-in-out" />

View File

@@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
ariaLabel="All Hands Logo"
onClick={onClick}
>
<AllHandsLogo width={44} height={30} />
<AllHandsLogo width={34} height={23} />
</TooltipButton>
);
}

View File

@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import DocsIcon from "#/icons/docs.svg?react";
import DocsIcon from "#/icons/academy.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
@@ -11,7 +11,7 @@ export function DocsButton() {
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
href="https://docs.all-hands.dev"
>
<DocsIcon width={28} height={28} />
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
</TooltipButton>
);
}

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import NewProjectIcon from "#/icons/new-project.svg?react";
import PlusIcon from "#/icons/plus.svg?react";
import { TooltipButton } from "./tooltip-button";
interface ExitProjectButtonProps {
@@ -17,7 +17,7 @@ export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
onClick={onClick}
testId="new-project-button"
>
<NewProjectIcon width={26} height={26} />
<PlusIcon width={28} height={28} className="text-[#9099AC]" />
</TooltipButton>
);
}

View File

@@ -1,14 +1,15 @@
import { FaCog } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import SettingsIcon from "#/icons/settings.svg?react";
import { TooltipButton } from "./tooltip-button";
import { I18nKey } from "#/i18n/declaration";
interface SettingsButtonProps {
onClick: () => void;
onClick?: () => void;
}
export function SettingsButton({ onClick }: SettingsButtonProps) {
const { t } = useTranslation();
return (
<TooltipButton
testId="settings-button"
@@ -16,7 +17,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
>
<FaCog size={24} />
<SettingsIcon width={28} height={28} />
</TooltipButton>
);
}

View File

@@ -1,160 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import {
BaseModalDescription,
BaseModalTitle,
} from "../confirmation-modals/base-modal";
import { ModalBody } from "../modal-body";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { ModalButton } from "../../buttons/modal-button";
import { FormFieldset } from "../../form-fieldset";
import { useConfig } from "#/hooks/query/use-config";
import { useCurrentSettings } from "#/context/settings-context";
import { GitHubTokenInput } from "./github-token-input";
import { PostSettings } from "#/types/settings";
import { useGitHubUser } from "#/hooks/query/use-github-user";
interface AccountSettingsFormProps {
onClose: () => void;
}
export function AccountSettingsForm({ onClose }: AccountSettingsFormProps) {
const { isError: isGitHubError } = useGitHubUser();
const { data: config } = useConfig();
const { saveUserSettings, settings } = useCurrentSettings();
const { t } = useTranslation();
const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET;
const analyticsConsentValue = !!settings?.USER_CONSENTS_TO_ANALYTICS;
const selectedLanguage = settings?.LANGUAGE || "en";
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const ghToken = formData.get("ghToken")?.toString();
const language = formData.get("language")?.toString();
const analytics = formData.get("analytics")?.toString() === "on";
const newSettings: Partial<PostSettings> = {};
newSettings.user_consents_to_analytics = analytics;
if (ghToken) newSettings.github_token = ghToken;
// The form returns the language label, so we need to find the corresponding
// language key to save it in the settings
if (language) {
const languageKey = AvailableLanguages.find(
({ label }) => label === language,
)?.value;
if (languageKey) newSettings.LANGUAGE = languageKey;
}
await saveUserSettings(newSettings, {
onSuccess: () => {
handleCaptureConsent(analytics);
},
});
onClose();
};
const onDisconnect = async () => {
await saveUserSettings({ unset_github_token: true });
posthog.reset();
onClose();
};
return (
<ModalBody testID="account-settings-form">
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
<div className="w-full flex flex-col gap-2">
<BaseModalTitle title={t(I18nKey.ACCOUNT_SETTINGS$TITLE)} />
{config?.APP_MODE === "saas" && config?.APP_SLUG && (
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="underline"
>
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</a>
)}
<FormFieldset
id="language"
label={t(I18nKey.LANGUAGE$LABEL)}
defaultSelectedKey={selectedLanguage}
isClearable={false}
items={AvailableLanguages.map(({ label, value: key }) => ({
key,
value: label,
}))}
/>
{config?.APP_MODE !== "saas" && (
<>
<GitHubTokenInput githubTokenIsSet={githubTokenIsSet} />
{!githubTokenIsSet && (
<BaseModalDescription>
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.COMMON$HERE)}
</a>
</BaseModalDescription>
)}
</>
)}
{isGitHubError && (
<p className="text-danger text-xs">
{t(I18nKey.GITHUB$TOKEN_INVALID)}
</p>
)}
{githubTokenIsSet && !isGitHubError && (
<ModalButton
testId="disconnect-github"
variant="text-like"
text={t(I18nKey.BUTTON$DISCONNECT)}
onClick={onDisconnect}
className="text-danger self-start"
/>
)}
</div>
<label className="flex gap-2 items-center self-start">
<input
data-testid="analytics-consent"
name="analytics"
type="checkbox"
defaultChecked={analyticsConsentValue}
/>
{t(I18nKey.ANALYTICS$ENABLE)}
</label>
<div className="flex flex-col gap-2 w-full">
<ModalButton
testId="save-settings"
type="submit"
intent="account"
text={t(I18nKey.BUTTON$SAVE)}
className="bg-[#4465DB]"
/>
<ModalButton
text={t(I18nKey.BUTTON$CLOSE)}
onClick={onClose}
className="bg-[#737373]"
/>
</div>
</form>
</ModalBody>
);
}

View File

@@ -1,14 +0,0 @@
import { ModalBackdrop } from "../modal-backdrop";
import { AccountSettingsForm } from "./account-settings-form";
interface AccountSettingsModalProps {
onClose: () => void;
}
export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
return (
<ModalBackdrop onClose={onClose}>
<AccountSettingsForm onClose={onClose} />
</ModalBackdrop>
);
}

View File

@@ -1,39 +0,0 @@
import { useTranslation } from "react-i18next";
import { FaCheckCircle } from "react-icons/fa";
import { I18nKey } from "#/i18n/declaration";
interface GitHubTokenInputProps {
githubTokenIsSet: boolean;
}
export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<label htmlFor="ghToken" className="flex flex-col gap-2">
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3] flex items-center gap-1">
{githubTokenIsSet && (
<FaCheckCircle
data-testid="github-token-set-checkmark"
size={12}
className="text-[#00D1B2]"
/>
)}
{t(I18nKey.GITHUB$TOKEN_LABEL)}
<span className="text-[#A3A3A3]">
{" "}
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
</span>
</span>
{!githubTokenIsSet && (
<input
data-testid="github-token-input"
id="ghToken"
name="ghToken"
type="password"
className="bg-[#27272A] text-xs py-[10px] px-3 rounded"
/>
)}
</label>
);
}

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