Compare commits

..

37 Commits

Author SHA1 Message Date
openhands 87380c7405 Add openhands provider with support for claude-sonnet-4-20250514, claude-opus-4-20250514, gemini-2.5-pro, and o4-mini 2025-07-03 13:33:40 +00:00
Hiep Le 63ead2a638 fix(frontend): The "available microagents" modal does not show the latest agents after adding a new agent or updating the current agents (#9502) 2025-07-03 13:11:06 +00:00
Hiep Le be0049c76e fix(frontend): Some strings are not included in the translation file. (#9524) 2025-07-03 12:55:13 +00:00
Hiep Le bafd1596dd fix(frontend): The secret settings layout will be broken if the secret name is too long. (#9522) 2025-07-03 12:54:47 +00:00
Hiep Le ce58ccab8a fix(frontend): Changing languages on the settings page does not work for some languages. (#9515) 2025-07-03 16:35:52 +04:00
sp.wack b3c8b7c089 Fix WebSocket disconnection when uploading large files (#9504)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-03 16:28:30 +04:00
Engel Nyst ac2947b7ff Fix /init on CLI Runtime (#9474)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-03 08:28:18 -04:00
mamoodi 91cd647f20 Add item to troubleshooting guide (#9490) 2025-07-02 16:31:26 -04:00
mamoodi c521fb7a8f Release 0.48.0 (#9491) 2025-07-02 16:21:45 -04:00
Rohit Malhotra f049411631 (Hotfix): Microagent won't load depending on version number format (#9508)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 18:06:36 +00:00
Tim O'Farrell 606ec59b33 Fix CLI confirmation input to handle invalid input properly (#9503)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 10:48:43 -06:00
Graham Neubig d2fc5679ad Improve rate limit message to indicate automatic retry (#9281)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 12:27:35 -04:00
Hiep Le 7bfa05d38a refactor(frontend): Show branch name and git provider on the conversation cards (#9480)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-07-02 16:04:55 +00:00
dependabot[bot] 12a95fb548 chore(deps): bump the version-all group in /frontend with 7 updates (#9506)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 15:08:05 +00:00
llamantino ae03c4eb80 chore: bump openhands-aci to 0.3.1 to fix ffmpeg warning (#9500) 2025-07-02 13:49:51 +00:00
mindflow-cn 8e486dfd6b Replace libtmux's deprecated methods in bash.py (#9463)
Co-authored-by: jianchuanli <jianchuanli@langcode.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-07-01 21:07:48 -04:00
Rohit Malhotra 48ee5659c9 Conditionally render 'Add GitHub repos' link based on provider (#9499)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-01 20:56:00 -04:00
Graham Neubig b7613d7529 Fix feedback endpoint calls in OSS mode (#9476)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-01 16:31:05 -04:00
Graham Neubig e05e627957 Add ArcticInference doc (#9492) 2025-07-01 14:15:13 -04:00
mamoodi 6da7e051be Make roadmap labels exempt from going stale (#9484) 2025-07-01 12:56:36 -04:00
dependabot[bot] 002e12a049 chore(deps): bump the version-all group in /frontend with 5 updates (#9486)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 20:09:02 +04:00
Graham Neubig ed58858e03 Add setup.sh script execution to event stream (#9427)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-01 10:37:21 -04:00
Hiep Le 11ae4f96c2 fix(frontend): The "logout" action is still shown even if there is no associated account. (#9478) 2025-07-01 16:51:59 +04:00
Hiep Le c2acf4e07e fix(frontend): Updated LLM settings are not applied to existing conversations. (#9460) 2025-06-30 16:52:59 +00:00
sp.wack e9bdf761b7 hotfix(frontend): Fix action button cutoff (#9465) 2025-06-30 20:32:52 +04:00
Hiep Le 04b93069b4 feat(frontend): Stop conversation (#9458) 2025-06-30 20:31:37 +04:00
Hiep Le ec03ce1ca0 feat(frontend): Tooltip for "suggested tasks" (#9447) 2025-06-30 14:46:39 +00:00
Hiep Le 46157a85d8 fix(frontend): Response issue - the content of the “Agent Tools & Metadata” modal is overflow. (#9449) 2025-06-30 14:44:04 +00:00
Hiep Le a691e3148a fix(frontend): Responsive issue - the horizontal scrollbar is showing when resizing the browser window (#9446) 2025-06-30 18:40:17 +04:00
Hiep Le 4674e0b77a refactor(frontend): When users hover over the buttons, the pointer will not be displayed (#9442) 2025-06-30 13:54:29 +00:00
dependabot[bot] d7d0329d25 chore(deps): bump node from 22.16.0-bookworm-slim to 24.2.0-bookworm-slim in /containers/app (#9040)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 17:40:14 +04:00
Graham Neubig 17853cd5bd Change default max_output_tokens to None and add comprehensive model tests (#9366)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-29 21:57:34 -04:00
Boxuan Li c992b6d2a0 Fix CLI runtime not disabling jupyter plugin by default (#9452) 2025-06-29 17:04:16 -07:00
llamantino 34bf645d64 fix(cli): fix terminal input lag on Windows by start&stopping pause task (#9436) 2025-06-29 10:21:40 -07:00
Graham Neubig 1ae1c16b26 docs: Add repository support and missing options to headless mode documentation (#9311)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-06-29 01:05:52 +00:00
Boxuan Li 5099413729 Complete browsing unit tests (#9428) 2025-06-28 09:52:52 -07:00
AY b06a3bdb7c Fixes #9394 - Improve CLI exit messaging to distinguish intentional exits and inter… (#9432) 2025-06-28 18:51:25 +02:00
92 changed files with 4187 additions and 1319 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 30
exempt-issue-labels: 'tracked'
exempt-issue-labels: 'roadmap'
close-issue-message: 'This issue was closed because it has been stalled for over 30 days with no activity.'
close-pr-message: 'This PR was closed because it has been stalled for over 30 days with no activity.'
days-before-close: 7
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.47-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.48-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.47
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.47
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.47
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
-156
View File
@@ -1,156 +0,0 @@
# Resolver Runtime Refactoring Plan
## Task Overview
Refactor the resolver component to reuse setup.py functions for runtime initialization, connection, and completion instead of reinventing the wheel.
## Repository Cloning Patterns Analysis
### Repository Cloning Patterns Across OpenHands Entry Points
#### 1. **Resolver (issue_resolver.py)** - DIFFERENT PATTERN (Legacy)
```python
# Step 1: Clone to separate location
subprocess.check_output(['git', 'clone', url, f'{output_dir}/repo'])
# Step 2: Later, copy repo to workspace
shutil.copytree(os.path.join(self.output_dir, 'repo'), self.workspace_base)
# Step 3: Create and connect runtime
runtime = create_runtime(config)
await runtime.connect()
# Step 4: Initialize runtime (git config, setup scripts)
self.initialize_runtime(runtime)
```
#### 2. **Main.py** - STANDARD PATTERN
```python
# Step 1: Create and connect runtime
runtime = create_runtime(config)
await runtime.connect()
# Step 2: Clone directly into runtime workspace + setup
repo_directory = initialize_repository_for_runtime(runtime, selected_repository)
```
#### 3. **Server/Session** - STANDARD PATTERN
```python
# Step 1: Create and connect runtime
# Step 2: Clone directly into runtime workspace
await runtime.clone_or_init_repo(tokens, repo, branch)
# Step 3: Run setup scripts
await runtime.maybe_run_setup_script()
await runtime.maybe_setup_git_hooks()
```
#### 4. **Setup.py's initialize_repository_for_runtime()** - STANDARD PATTERN
```python
# Calls runtime.clone_or_init_repo() + setup scripts
repo_directory = runtime.clone_or_init_repo(tokens, repo, branch)
runtime.maybe_run_setup_script()
runtime.maybe_setup_git_hooks()
```
### The Issue
The **resolver is the odd one out** - it uses a 2-step process (clone to temp location, then copy to workspace) due to **legacy reasons** (it was originally developed as a separate app built on OH, not a component of OH). All other entry points use the standard pattern (clone directly into runtime workspace).
## Current State Analysis
### ✅ What Resolver Already Does Right:
- [x] Uses `create_runtime()` from setup.py for runtime creation
### ❌ What Needs to be Fixed:
- [ ] **Resolver uses legacy 2-step cloning instead of standard runtime.clone_or_init_repo()**
- [ ] Resolver has custom `initialize_runtime()` method that duplicates setup.py logic
- [ ] Resolver has custom `complete_runtime()` method with no setup.py equivalent
- [ ] Resolver doesn't follow proper runtime cleanup patterns like main.py
- [ ] Runtime connection pattern is inconsistent across codebase
## Refactoring Steps
### Phase 1: Fix Repository Cloning Pattern (PRIORITY)
**Goal**: Make resolver use the same repository cloning pattern as all other OpenHands entry points.
- [ ] **Step 1.1**: Replace resolver's legacy 2-step cloning with standard pattern
- Remove `subprocess.check_output(['git', 'clone', ...])` from `resolve_issue()`
- Remove `shutil.copytree()` from `process_issue()`
- Use `initialize_repository_for_runtime()` instead
- This will clone directly into runtime workspace AND run setup scripts
- [ ] **Step 1.2**: Update resolver workflow to match standard pattern
- Create and connect runtime first
- Then call `initialize_repository_for_runtime()` for cloning + setup
- Remove the manual repo copying step entirely
- Ensure base_commit is still captured correctly
### Phase 2: Refactor Runtime Initialization and Completion
**Goal**: Remove code duplication between resolver and setup.py for runtime operations.
- [ ] **Step 2.1**: Create missing functions in setup.py
- Create `setup_runtime_environment()` for git config and platform-specific setup
- Create `complete_runtime_session()` for git patch generation
- Create `cleanup_runtime()` for proper resource cleanup
- [ ] **Step 2.2**: Replace resolver's `initialize_runtime()`
- Use setup.py's `setup_runtime_environment()` instead
- Remove duplicate git configuration code
- Maintain platform-specific behavior (GitLab CI)
- [ ] **Step 2.3**: Replace resolver's `complete_runtime()`
- Use setup.py's `complete_runtime_session()` instead
- Move git patch generation logic to setup.py
- Ensure return values match resolver's expectations
- [ ] **Step 2.4**: Add proper runtime cleanup to resolver
- Use setup.py's `cleanup_runtime()` function
- Ensure resources are properly released in try/finally blocks
### Phase 3: Testing and Validation
- [ ] **Step 3.1**: Test resolver functionality with refactored code
- Verify git operations work correctly
- Verify setup scripts are executed
- Verify git hooks are set up
- [ ] **Step 3.2**: Test runtime lifecycle (create → connect → clone → initialize → complete → cleanup)
- Ensure no resource leaks
- Verify proper error handling
- [ ] **Step 3.3**: Verify resolver output remains consistent
- Git patches are generated correctly
- Issue resolution works as before
- No regression in functionality
### Phase 4: Code Quality and Documentation
- [ ] **Step 4.1**: Add proper documentation to new setup.py functions
- Document parameters and return values
- Add usage examples
- Document platform-specific behavior
- [ ] **Step 4.2**: Remove obsolete code from resolver
- Delete old `initialize_runtime()` method
- Delete old `complete_runtime()` method
- Clean up imports and unused code
- [ ] **Step 4.3**: Update any other components that might benefit from these functions
- Check if other entry points could use the same patterns
- Ensure consistency across the codebase
## Success Criteria
- [ ] **Resolver uses standard repository cloning pattern (runtime.clone_or_init_repo)**
- [ ] Resolver uses setup.py functions for all runtime operations
- [ ] No code duplication between resolver and setup.py
- [ ] Proper runtime lifecycle management (connect → initialize → complete → cleanup)
- [ ] All existing resolver functionality preserved
- [ ] Consistent patterns across all OpenHands entry points
- [ ] Proper error handling and resource cleanup
## Files to Modify
1. `/openhands/core/setup.py` - Add new runtime management functions
2. `/openhands/resolver/issue_resolver.py` - Refactor to use setup.py functions
3. Any tests related to resolver functionality
## Risk Mitigation
- Maintain backward compatibility during refactoring
- Test thoroughly before removing old code
- Keep git patch generation logic identical to avoid breaking issue resolution
- Ensure platform-specific behavior (GitLab CI) is preserved
+1 -1
View File
@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:22.16.0-bookworm-slim AS frontend-builder
FROM node:24.3.0-bookworm-slim AS frontend-builder
WORKDIR /app
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.47-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.48-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+2 -2
View File
@@ -64,7 +64,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--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.47 \
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
python -m openhands.cli.main --override-cli-mode true
```
+52 -16
View File
@@ -18,42 +18,78 @@ poetry run python -m openhands.core.main -t "write a bash script that prints hi"
You'll need to be sure to set your model, API key, and other settings via environment variables
[or the `config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml).
## With Docker
### Working with Repositories
To run OpenHands in Headless mode with Docker:
You can specify a repository for OpenHands to work with using `--selected-repo` or the `SANDBOX_SELECTED_REPO` environment variable:
1. Set the following environment variables in your terminal:
- `SANDBOX_VOLUMES` to specify the directory you want OpenHands to access ([See using SANDBOX_VOLUMES for more info](../runtimes/docker#using-sandbox_volumes))
- `LLM_MODEL` - the LLM model to use (e.g. `export LLM_MODEL="anthropic/claude-sonnet-4-20250514"`)
- `LLM_API_KEY` - your API key (e.g. `export LLM_API_KEY="sk_test_12345"`)
2. Run the following Docker command:
> **Note**: Currently, authentication tokens (GITHUB_TOKEN, GITLAB_TOKEN, or BITBUCKET_TOKEN) are required for all repository operations, including public repositories. This is a known limitation that may be addressed in future versions to allow tokenless access to public repositories.
```bash
# Using command-line argument
poetry run python -m openhands.core.main \
--selected-repo "owner/repo-name" \
-t "analyze the codebase and suggest improvements"
# Using environment variable
export SANDBOX_SELECTED_REPO="owner/repo-name"
poetry run python -m openhands.core.main -t "fix any linting issues"
# Authentication tokens are currently required for ALL repository operations (public and private)
# This includes GitHub, GitLab, and Bitbucket repositories
export GITHUB_TOKEN="your-token" # or GITLAB_TOKEN, BITBUCKET_TOKEN
poetry run python -m openhands.core.main \
--selected-repo "owner/repo-name" \
-t "review the security implementation"
# Using task files instead of inline task
echo "Review the README and suggest improvements" > task.txt
poetry run python -m openhands.core.main -f task.txt --selected-repo "owner/repo"
```
## With Docker
Set environment variables and run the Docker command:
```bash
# Set required environment variables
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
export LLM_API_KEY="your-api-key"
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
-e LLM_MODEL=$LLM_MODEL \
-e SANDBOX_SELECTED_REPO=$SANDBOX_SELECTED_REPO \
-e GITHUB_TOKEN=$GITHUB_TOKEN \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
--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.47 \
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
> **Note**: If you used OpenHands before version 0.44, run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
## Advanced Headless Configurations
## Additional Options
To view all available configuration options for headless mode, run the Python command with the `--help` flag.
Common command-line options:
- `-d "/path/to/workspace"` - Set working directory
- `-f task.txt` - Load task from file
- `-i 50` - Set max iterations
- `-b 10.0` - Set budget limit (USD)
- `--no-auto-continue` - Interactive mode
### Additional Logs
Run `poetry run python -m openhands.core.main --help` for all options, or use a [`config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) for more flexibility.
For the headless mode to log all the agent actions, in the terminal run: `export LOG_ALL_EVENTS=true`
Set `export LOG_ALL_EVENTS=true` to log all agent actions.
+8 -4
View File
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.47
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.47
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.48
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
@@ -175,6 +175,10 @@ vllm serve mistralai/Devstral-Small-2505 \
--enable-prefix-caching
```
If you are interested in further improved inference speed, you can also try Snowflake's version
of vLLM, [ArcticInference](https://www.snowflake.com/en/engineering-blog/fast-speculative-decoding-vllm-arctic/),
which can achieve up to 2x speedup in some cases.
### Run OpenHands (Alternative Backends)
#### Using Docker
+3 -3
View File
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.47
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
@@ -38,6 +38,21 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
OpenHands.
### Internal Server Error. Ports are not available
**Description**
When running on Windows, the error `Internal Server Error ("ports are not available: exposing port TCP
...: bind: An attempt was made to access a socket in a
way forbidden by its access permissions.")` is encountered.
**Resolution**
* Run the following command in PowerShell, as Administrator to reset the NAT service and release the ports:
```
Restart-Service -Name "winnat"
```
### Unable to access VS Code tab via local IP
**Description**
@@ -27,9 +27,9 @@ vi.mock("react-i18next", async () => {
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"CONVERSATION$CREATED": "Created",
"CONVERSATION$AGO": "ago",
"CONVERSATION$UPDATED": "Updated"
CONVERSATION$CREATED: "Created",
CONVERSATION$AGO: "ago",
CONVERSATION$UPDATED: "Updated",
};
return translations[key] || key;
},
@@ -82,7 +82,9 @@ describe("ConversationCard", () => {
expect(card).toHaveTextContent("ago");
// Use a regex to match the time part since it might have whitespace
const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
const timeRegex = new RegExp(
formatTimeDelta(new Date("2021-10-01T12:00:00Z")),
);
expect(card).toHaveTextContent(timeRegex);
});
@@ -108,7 +110,11 @@ describe("ConversationCard", () => {
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository="org/selectedRepository"
selectedRepository={{
selected_repository: "org/selectedRepository",
selected_branch: "main",
git_provider: "github",
}}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
@@ -173,7 +179,11 @@ describe("ConversationCard", () => {
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository="org/selectedRepository"
selectedRepository={{
selected_repository: "org/selectedRepository",
selected_branch: "main",
git_provider: "github",
}}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
@@ -295,4 +295,238 @@ describe("ConversationPanel", () => {
const newCards = await screen.findAllByTestId("conversation-card");
expect(newCards).toHaveLength(3);
});
it("should cancel stopping a conversation", async () => {
const user = userEvent.setup();
// Create mock data with a RUNNING conversation
const mockRunningConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Stopped Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Click ellipsis on the first card (RUNNING status)
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Stop button should be available for RUNNING conversation
const stopButton = screen.getByTestId("stop-button");
expect(stopButton).toBeInTheDocument();
// Click the stop button
await user.click(stopButton);
// Cancel the stopping action
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
expect(
screen.queryByRole("button", { name: /cancel/i }),
).not.toBeInTheDocument();
// Ensure the conversation status hasn't changed
const updatedCards = await screen.findAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(3);
});
it("should stop a conversation", async () => {
const user = userEvent.setup();
const mockData: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
stopConversationSpy.mockImplementation(async (id: string) => {
const conversation = mockData.find((conv) => conv.conversation_id === id);
if (conversation) {
conversation.status = "STOPPED";
return conversation;
}
return null;
});
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(2);
// Click ellipsis on the first card (RUNNING status)
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const stopButton = screen.getByTestId("stop-button");
// Click the stop button
await user.click(stopButton);
// Confirm the stopping action
const confirmButton = screen.getByRole("button", { name: /confirm/i });
await user.click(confirmButton);
expect(
screen.queryByRole("button", { name: /confirm/i }),
).not.toBeInTheDocument();
// Verify the API was called
expect(stopConversationSpy).toHaveBeenCalledWith("1");
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
});
it("should only show stop button for STARTING or RUNNING conversations", async () => {
const user = userEvent.setup();
const mockMixedStatusConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Stopped Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Test RUNNING conversation - should show stop button
const runningEllipsisButton = within(cards[0]).getByTestId(
"ellipsis-button",
);
await user.click(runningEllipsisButton);
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
// Click outside to close the menu
await user.click(document.body);
// Test STARTING conversation - should show stop button
const startingEllipsisButton = within(cards[1]).getByTestId(
"ellipsis-button",
);
await user.click(startingEllipsisButton);
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
// Click outside to close the menu
await user.click(document.body);
// Test STOPPED conversation - should NOT show stop button
const stoppedEllipsisButton = within(cards[2]).getByTestId(
"ellipsis-button",
);
await user.click(stoppedEllipsisButton);
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
});
});
@@ -119,18 +119,48 @@ describe("RepoConnector", () => {
expect(launchButton).toBeEnabled();
});
it("should render the 'add git(hub|lab) repos' links if saas mode", async () => {
it("should render the 'add github repos' link if saas mode and github provider is set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
gitlab: null,
},
});
renderRepoConnector();
await screen.findByText("HOME$ADD_GITHUB_REPOS");
});
it("should not render the 'add github repos' link if github provider is not set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: "some-token",
github: null,
},
});
renderRepoConnector();
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
});
it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "react-redux";
@@ -7,6 +7,21 @@ import { setupStore } from "test-utils";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
import userEvent from "@testing-library/user-event";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...(actual as object),
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
const renderTaskSuggestions = () => {
const RouterStub = createRoutesStub([
@@ -93,4 +108,26 @@ describe("TaskSuggestions", () => {
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
});
it("should render the tooltip button", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toBeInTheDocument();
});
it("should have the correct aria-label", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toHaveAttribute(
"aria-label",
"TASKS$TASK_SUGGESTIONS_INFO",
);
});
it("should render the info icon", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
const icon = tooltipButton.querySelector("svg");
expect(icon).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import OpenHands from "#/api/open-hands";
describe("MicroagentsModal - Refresh Button", () => {
const mockOnClose = vi.fn();
const conversationId = "test-conversation-id";
const defaultProps = {
onClose: mockOnClose,
conversationId,
};
const mockMicroagents = [
{
name: "Test Agent 1",
type: "repo" as const,
triggers: ["test", "example"],
content: "This is test content for agent 1",
},
{
name: "Test Agent 2",
type: "knowledge" as const,
triggers: ["help", "support"],
content: "This is test content for agent 2",
},
];
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
// Setup default mock for getUserConversations
vi.spyOn(OpenHands, "getMicroagents").mockResolvedValue({
microagents: mockMicroagents,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Refresh Button Rendering", () => {
it("should render the refresh button with correct text and test ID", () => {
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshButton = screen.getByTestId("refresh-microagents");
expect(refreshButton).toBeInTheDocument();
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
});
});
describe("Refresh Button Functionality", () => {
it("should call refetch when refresh button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshSpy = vi.spyOn(OpenHands, "getMicroagents");
const refreshButton = screen.getByTestId("refresh-microagents");
await user.click(refreshButton);
expect(refreshSpy).toHaveBeenCalledTimes(1);
});
});
});
@@ -21,7 +21,12 @@ describe("UserActions", () => {
});
it("should toggle the user menu when the user avatar is clicked", async () => {
render(<UserActions onLogout={onLogoutMock} />);
render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -57,15 +62,102 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
test("logout button is always enabled", async () => {
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
// Context menu should NOT appear because user is undefined
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
expect(onLogoutMock).toHaveBeenCalledOnce();
it("should show context menu even when user has no avatar_url", async () => {
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu SHOULD appear because user object exists (even with empty avatar_url)
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should NOT be able to access logout when no user is provided", async () => {
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Logout option should not be accessible because context menu doesn't appear
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(onLogoutMock).not.toHaveBeenCalled();
});
it("should handle user prop changing from undefined to defined", () => {
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
// Initially no user - context menu shouldn't work
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Add user prop
rerender(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
// Component should still render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should handle user prop changing from defined to undefined", async () => {
const { rerender } = render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
// Click to open menu
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
// Remove user prop - menu should disappear
rerender(<UserActions onLogout={onLogoutMock} />);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
isLoading={true}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should still appear even when loading
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
});
@@ -0,0 +1,140 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
// Mock the useConfig hook
vi.mock("#/hooks/query/use-config", () => ({
useConfig: vi.fn(),
}));
// Mock the useConversationId hook
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
describe("useFeedbackExists", () => {
let queryClient: QueryClient;
const mockCheckFeedbackExists = vi.spyOn(OpenHands, "checkFeedbackExists");
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockCheckFeedbackExists.mockClear();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it("should not call API when APP_MODE is not saas", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "oss" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
it("should call API when APP_MODE is saas", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
mockCheckFeedbackExists.mockResolvedValue({
exists: true,
rating: 5,
reason: "Great job!",
});
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for the query to complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was called
expect(mockCheckFeedbackExists).toHaveBeenCalledWith(
"test-conversation-id",
123,
);
// Verify that the data is returned
expect(result.current.data).toEqual({
exists: true,
rating: 5,
reason: "Great job!",
});
});
it("should not call API when eventId is not provided", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(undefined), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
it("should not call API when config is not loaded yet", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
});
@@ -90,7 +90,7 @@ describe("HomeScreen", () => {
const mainContainer = screen
.getByTestId("home-screen")
.querySelector("main");
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
});
it("should filter the suggested tasks based on the selected repository", async () => {
+1111 -876
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.47.0",
"version": "0.48.0",
"private": true,
"type": "module",
"engines": {
@@ -25,15 +25,15 @@
"axios": "^1.10.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.19.2",
"i18next": "^25.2.1",
"framer-motion": "^12.23.0",
"i18next": "^25.3.0",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.28",
"jose": "^6.0.11",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.255.1",
"posthog-js": "^1.256.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -80,11 +80,11 @@
]
},
"devDependencies": {
"@babel/parser": "^7.27.7",
"@babel/traverse": "^7.27.7",
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.53.1",
"@playwright/test": "^1.53.2",
"@react-router/dev": "^7.6.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
@@ -92,7 +92,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.5",
"@types/node": "^24.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -118,7 +118,7 @@
"lint-staged": "^16.1.2",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.2.1",
"stripe": "^18.3.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",
+7
View File
@@ -1,5 +1,6 @@
import { ConversationStatus } from "#/types/conversation-status";
import { RuntimeStatus } from "#/types/runtime-status";
import { Provider } from "#/types/settings";
export interface ErrorResponse {
error: string;
@@ -70,6 +71,12 @@ export interface AuthenticateResponse {
error?: string;
}
export interface RepositorySelection {
selected_repository: string | null;
selected_branch: string | null;
git_provider: Provider | null;
}
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export interface Conversation {
@@ -0,0 +1,164 @@
import { render, screen } from "@testing-library/react";
import { useParams } from "react-router";
import { vi, describe, test, expect, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ChatInterface } from "./chat-interface";
import { useWsClient } from "#/context/ws-client-provider";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
import { useConfig } from "#/hooks/query/use-config";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { OpenHandsAction } from "#/types/core/actions";
// Mock the hooks
vi.mock("#/context/ws-client-provider");
vi.mock("#/hooks/use-optimistic-user-message");
vi.mock("#/hooks/use-ws-error-message");
vi.mock("react-router");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-upload-files");
vi.mock("react-redux", () => ({
useSelector: vi.fn(() => ({
curAgentState: "AWAITING_USER_INPUT",
selectedRepository: null,
replayJson: null,
})),
}));
describe("ChatInterface", () => {
// Create a new QueryClient for each test
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Default mock implementations
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
(
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => null),
});
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
getErrorMessage: vi.fn(() => null),
setErrorMessage: vi.fn(),
removeErrorMessage: vi.fn(),
});
(useParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
conversationId: "test-id",
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { APP_MODE: "local" },
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
isLoading: false,
});
});
// Helper function to render with QueryClientProvider
const renderWithQueryClient = (ui: React.ReactElement) =>
render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
test("should show chat suggestions when there are no events", () => {
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is rendered
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
});
test("should show chat suggestions when there are only environment events", () => {
const environmentEvent: OpenHandsAction = {
id: 1,
source: "environment",
action: "system",
args: {
content: "source .openhands/setup.sh",
tools: null,
openhands_version: null,
agent_class: null,
},
message: "Running setup script",
timestamp: "2025-07-01T00:00:00Z",
};
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [environmentEvent],
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is still rendered with environment events
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
});
test("should hide chat suggestions when there is a user message", () => {
const userEvent: OpenHandsAction = {
id: 1,
source: "user",
action: "message",
args: {
content: "Hello",
image_urls: [],
file_urls: [],
},
message: "Hello",
timestamp: "2025-07-01T00:00:00Z",
};
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [userEvent],
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is not rendered with user events
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
});
test("should hide chat suggestions when there is an optimistic user message", () => {
(
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is not rendered with optimistic user message
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
});
});
@@ -10,6 +10,7 @@ import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { isOpenHandsAction } from "#/types/core/guards";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
@@ -31,6 +32,7 @@ import { ErrorMessageBanner } from "./error-message-banner";
import { shouldRenderEvent } from "./event-content-helpers/should-render-event";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
function getEntryPoint(
hasRepository: boolean | null,
@@ -77,11 +79,26 @@ export function ChatInterface() {
const events = parsedEvents.filter(shouldRenderEvent);
// Check if there are any substantive agent actions (not just system messages)
const hasSubstantiveAgentActions = React.useMemo(
() =>
parsedEvents.some(
(event) =>
isOpenHandsAction(event) &&
event.source === "agent" &&
event.action !== "system",
),
[parsedEvents],
);
const handleSendMessage = async (
content: string,
images: File[],
files: File[],
originalImages: File[],
originalFiles: File[],
) => {
// Create mutable copies of the arrays
const images = [...originalImages];
const files = [...originalFiles];
if (events.length === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(
@@ -97,6 +114,16 @@ export function ChatInterface() {
current_message_length: content.length,
});
}
// Validate file sizes before any processing
const allFiles = [...images, ...files];
const validation = validateFiles(allFiles);
if (!validation.isValid) {
displayErrorToast(`Error: ${validation.errorMessage}`);
return; // Stop processing if validation fails
}
const promises = images.map((image) => convertImageToBase64(image));
const imageUrls = await Promise.all(promises);
@@ -167,9 +194,12 @@ export function ChatInterface() {
return (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between">
{events.length === 0 && !optimisticUserMessage && (
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
)}
{!hasSubstantiveAgentActions &&
!optimisticUserMessage &&
!events.some(
(event) => isOpenHandsAction(event) && event.source === "user",
) && <ChatSuggestions onSuggestionsClick={setMessageToSend} />}
{/* Note: We only hide chat suggestions when there's a user message */}
<div
ref={scrollRef}
@@ -192,7 +222,7 @@ export function ChatInterface() {
)}
{isWaitingForUserInput &&
events.length > 0 &&
hasSubstantiveAgentActions &&
!optimisticUserMessage && (
<ActionSuggestions
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}
@@ -12,7 +12,10 @@ export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
<div
data-testid="chat-suggestions"
className="flex flex-col gap-6 h-full px-4 items-center justify-center"
>
<div className="flex flex-col items-center p-4 bg-tertiary rounded-xl w-full">
<BuildIt width={45} height={54} />
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
@@ -5,6 +5,8 @@ import { ImageCarousel } from "../images/image-carousel";
import { UploadImageInput } from "../images/upload-image-input";
import { FileList } from "../files/file-list";
import { isFileImage } from "#/utils/is-file-image";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { validateFiles } from "#/utils/file-validation";
interface InteractiveChatBoxProps {
isDisabled?: boolean;
@@ -27,14 +29,20 @@ export function InteractiveChatBox({
const [files, setFiles] = React.useState<File[]>([]);
const handleUpload = (selectedFiles: File[]) => {
setFiles((prevFiles) => [
...prevFiles,
...selectedFiles.filter((f) => !isFileImage(f)),
]);
setImages((prevImages) => [
...prevImages,
...selectedFiles.filter((f) => isFileImage(f)),
]);
// Validate files before adding them
const validation = validateFiles(selectedFiles, [...images, ...files]);
if (!validation.isValid) {
displayErrorToast(`Error: ${validation.errorMessage}`);
return; // Don't add any files if validation fails
}
// Filter valid files by type
const validFiles = selectedFiles.filter((f) => !isFileImage(f));
const validImages = selectedFiles.filter((f) => isFileImage(f));
setFiles((prevFiles) => [...prevFiles, ...validFiles]);
setImages((prevImages) => [...prevImages, ...validImages]);
};
const removeElementByIndex = (array: Array<File>, index: number) => {
@@ -4,6 +4,7 @@ import { AgentStatusBar } from "./agent-status-bar";
import { SecurityLock } from "./security-lock";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { ConversationCard } from "../conversation-panel/conversation-card";
import { Provider } from "#/types/settings";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
@@ -29,7 +30,11 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
showOptions
title={conversation?.title ?? ""}
lastUpdatedAt={conversation?.created_at ?? ""}
selectedRepository={conversation?.selected_repository ?? null}
selectedRepository={{
selected_repository: conversation?.selected_repository ?? null,
selected_branch: conversation?.selected_branch ?? null,
git_provider: (conversation?.git_provider as Provider) ?? null,
}}
conversationStatus={conversation?.status}
conversationId={conversation?.conversation_id}
/>
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import {
BaseModalDescription,
BaseModalTitle,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
interface ConfirmStopModalProps {
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmStopModal({
onConfirm,
onCancel,
}: ConfirmStopModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<ModalBody className="items-start border border-tertiary">
<div className="flex flex-col gap-2">
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_STOP)} />
<BaseModalDescription
description={t(I18nKey.CONVERSATION$STOP_WARNING)}
/>
</div>
<div
className="flex flex-col gap-2 w-full"
onClick={(event) => event.stopPropagation()}
>
<BrandButton
type="button"
variant="primary"
onClick={onConfirm}
className="w-full"
data-testid="confirm-button"
>
{t(I18nKey.ACTION$CONFIRM)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
onClick={onCancel}
className="w-full"
data-testid="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>
);
}
@@ -8,6 +8,7 @@ import { I18nKey } from "#/i18n/declaration";
interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
@@ -19,6 +20,7 @@ interface ConversationCardContextMenuProps {
export function ConversationCardContextMenu({
onClose,
onDelete,
onStop,
onEdit,
onDisplayCost,
onShowAgentTools,
@@ -41,12 +43,17 @@ export function ConversationCardContextMenu({
>
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
Delete
{t(I18nKey.BUTTON$DELETE)}
</ContextMenuListItem>
)}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
{t(I18nKey.BUTTON$STOP)}
</ContextMenuListItem>
)}
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
Edit Title
{t(I18nKey.BUTTON$EDIT_TITLE)}
</ContextMenuListItem>
)}
{onDownloadViaVSCode && (
@@ -54,7 +61,7 @@ export function ConversationCardContextMenu({
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
Download via VS Code
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
</ContextMenuListItem>
)}
{onDisplayCost && (
@@ -62,7 +69,7 @@ export function ConversationCardContextMenu({
testId="display-cost-button"
onClick={onDisplayCost}
>
Display Cost
{t(I18nKey.BUTTON$DISPLAY_COST)}
</ContextMenuListItem>
)}
{onShowAgentTools && (
@@ -70,7 +77,7 @@ export function ConversationCardContextMenu({
testId="show-agent-tools-button"
onClick={onShowAgentTools}
>
Show Agent Tools & Metadata
{t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
</ContextMenuListItem>
)}
{onShowMicroagents && (
@@ -19,15 +19,17 @@ import OpenHands from "#/api/open-hands";
import { useWsClient } from "#/context/ws-client-provider";
import { isSystemMessage } from "#/types/core/guards";
import { ConversationStatus } from "#/types/conversation-status";
import { RepositorySelection } from "#/api/open-hands.types";
interface ConversationCardProps {
onClick?: () => void;
onDelete?: () => void;
onStop?: () => void;
onChangeTitle?: (title: string) => void;
showOptions?: boolean;
isActive?: boolean;
title: string;
selectedRepository: string | null;
selectedRepository: RepositorySelection | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
conversationStatus?: ConversationStatus;
@@ -40,6 +42,7 @@ const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
export function ConversationCard({
onClick,
onDelete,
onStop,
onChangeTitle,
showOptions,
isActive,
@@ -101,6 +104,13 @@ export function ConversationCard({
setContextMenuVisible(false);
};
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onStop?.();
setContextMenuVisible(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -171,7 +181,7 @@ export function ConversationCard({
data-testid="conversation-card"
onClick={onClick}
className={cn(
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
"h-auto w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"md:w-fit h-auto rounded-xl border border-[#525252]",
)}
@@ -224,6 +234,11 @@ export function ConversationCard({
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"
? onStop && handleStop
: undefined
}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={
conversationId && showOptions
@@ -250,11 +265,14 @@ export function ConversationCard({
<div
className={cn(
variant === "compact" && "flex items-center justify-between mt-1",
variant === "compact" && "flex flex-col justify-between mt-1",
)}
>
{selectedRepository && (
<ConversationRepoLink selectedRepository={selectedRepository} />
{selectedRepository?.selected_repository && (
<ConversationRepoLink
selectedRepository={selectedRepository}
variant={variant}
/>
)}
<p className="text-xs text-neutral-400">
<span>{t(I18nKey.CONVERSATION$CREATED)} </span>
@@ -5,10 +5,13 @@ import { I18nKey } from "#/i18n/declaration";
import { ConversationCard } from "./conversation-card";
import { useUserConversations } from "#/hooks/query/use-user-conversations";
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { ConfirmStopModal } from "./confirm-stop-modal";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ExitConversationModal } from "./exit-conversation-modal";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { Provider } from "#/types/settings";
interface ConversationPanelProps {
onClose: () => void;
@@ -22,6 +25,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
const [confirmStopModalVisible, setConfirmStopModalVisible] =
React.useState(false);
const [
confirmExitConversationModalVisible,
setConfirmExitConversationModalVisible,
@@ -33,12 +38,18 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { data: conversations, isFetching, error } = useUserConversations();
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const handleDeleteProject = (conversationId: string) => {
setConfirmDeleteModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleStopConversation = (conversationId: string) => {
setConfirmStopModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleConfirmDelete = () => {
if (selectedConversationId) {
deleteConversation(
@@ -54,6 +65,21 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
}
};
const handleConfirmStop = () => {
if (selectedConversationId) {
stopConversation(
{ conversationId: selectedConversationId },
{
onSuccess: () => {
if (selectedConversationId === currentConversationId) {
navigate("/");
}
},
},
);
}
};
return (
<div
ref={ref}
@@ -87,8 +113,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
<ConversationCard
isActive={isActive}
onDelete={() => handleDeleteProject(project.conversation_id)}
onStop={() => handleStopConversation(project.conversation_id)}
title={project.title}
selectedRepository={project.selected_repository}
selectedRepository={{
selected_repository: project.selected_repository,
selected_branch: project.selected_branch,
git_provider: project.git_provider as Provider,
}}
lastUpdatedAt={project.last_updated_at}
createdAt={project.created_at}
conversationStatus={project.status}
@@ -108,6 +139,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
/>
)}
{confirmStopModalVisible && (
<ConfirmStopModal
onConfirm={() => {
handleConfirmStop();
setConfirmStopModalVisible(false);
}}
onCancel={() => setConfirmStopModalVisible(false)}
/>
)}
{confirmExitConversationModalVisible && (
<ExitConversationModal
onConfirm={() => {
@@ -1,16 +1,44 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { RepositorySelection } from "#/api/open-hands.types";
interface ConversationRepoLinkProps {
selectedRepository: string;
selectedRepository: RepositorySelection;
variant: "compact" | "default";
}
export function ConversationRepoLink({
selectedRepository,
variant = "default",
}: ConversationRepoLinkProps) {
if (variant === "compact") {
return (
<span
data-testid="conversation-card-selected-repository"
className="text-xs text-neutral-400"
>
{selectedRepository.selected_repository}
</span>
);
}
return (
<span
data-testid="conversation-card-selected-repository"
className="text-xs text-neutral-400"
>
{selectedRepository}
</span>
<div className="flex items-center gap-1">
{selectedRepository.git_provider === "github" && <FaGithub size={14} />}
{selectedRepository.git_provider === "gitlab" && <FaGitlab />}
{selectedRepository.git_provider === "bitbucket" && <FaBitbucket />}
<span
data-testid="conversation-card-selected-repository"
className="text-xs text-neutral-400"
>
{selectedRepository.selected_repository}
</span>
<code
data-testid="conversation-card-selected-branch"
className="text-xs text-neutral-400 border border-neutral-700 rounded px-1 py-0.5 w-fit bg-neutral-800"
>
{selectedRepository.selected_branch}
</code>
</div>
);
}
@@ -6,7 +6,12 @@ interface EllipsisButtonProps {
export function EllipsisButton({ onClick }: EllipsisButtonProps) {
return (
<button data-testid="ellipsis-button" type="button" onClick={onClick}>
<button
data-testid="ellipsis-button"
type="button"
onClick={onClick}
className="cursor-pointer"
>
<FaEllipsisV fill="#a3a3a3" />
</button>
);
@@ -1,11 +1,12 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { I18nKey } from "#/i18n/declaration";
import { useConversationMicroagents } from "#/hooks/query/use-conversation-microagents";
import { BrandButton } from "../settings/brand-button";
interface MicroagentsModalProps {
onClose: () => void;
@@ -20,11 +21,12 @@ export function MicroagentsModal({
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
{},
);
const {
data: microagents,
isLoading,
isError,
refetch,
isRefetching,
} = useConversationMicroagents({
conversationId,
enabled: true,
@@ -45,9 +47,29 @@ export function MicroagentsModal({
testID="microagents-modal"
>
<div className="flex flex-col gap-6 w-full">
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
<div className="flex items-center justify-between w-full">
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
<BrandButton
testId="refresh-microagents"
type="button"
variant="primary"
className="flex items-center gap-2"
onClick={refetch}
isDisabled={isLoading || isRefetching}
>
<RefreshCw
size={16}
className={`${isRefetching ? "animate-spin" : ""}`}
/>
{t(I18nKey.BUTTON$REFRESH)}
</BrandButton>
</div>
</div>
<span className="text-sm text-gray-400">
{t(I18nKey.MICROAGENTS_MODAL$WARNING)}
</span>
<div className="w-full h-[60vh] overflow-auto rounded-md">
{isLoading && (
<div className="flex justify-center items-center py-8">
@@ -118,7 +118,7 @@ export function SystemMessageModal({
)}
</div>
<div className="h-[60vh] overflow-auto rounded-md">
<div className="max-h-[51vh] overflow-auto rounded-md">
{activeTab === "system" && (
<div className="p-4 whitespace-pre-wrap font-mono text-sm leading-relaxed text-gray-300 shadow-inner">
{systemMessage.content}
@@ -1,20 +1,26 @@
import { useTranslation } from "react-i18next";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
export function RepoProviderLinks() {
const { t } = useTranslation();
const { data: config } = useConfig();
const { providers } = useUserProviders();
const githubHref = config
? `https://github.com/apps/${config.APP_SLUG}/installations/new`
: "";
const hasGithubProvider = providers.includes("github");
return (
<div className="flex flex-col text-sm underline underline-offset-2 text-content-2 gap-4 w-fit">
<a href={githubHref} target="_blank" rel="noopener noreferrer">
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
</a>
{hasGithubProvider && (
<a href={githubHref} target="_blank" rel="noopener noreferrer">
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
</a>
)}
</div>
);
}
@@ -1,9 +1,11 @@
import { useTranslation } from "react-i18next";
import { FaInfoCircle } from "react-icons/fa";
import { TaskGroup } from "./task-group";
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface TaskSuggestionsProps {
filterFor?: string | null;
@@ -23,7 +25,19 @@ export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
data-testid="task-suggestions"
className={cn("flex flex-col w-full", !hasSuggestedTasks && "gap-6")}
>
<h2 className="heading">{t(I18nKey.TASKS$SUGGESTED_TASKS)}</h2>
<div className="flex items-center gap-2">
<h2 className="heading">{t(I18nKey.TASKS$SUGGESTED_TASKS)}</h2>
<TooltipButton
testId="task-suggestions-info"
tooltip={t(I18nKey.TASKS$TASK_SUGGESTIONS_TOOLTIP)}
ariaLabel={t(I18nKey.TASKS$TASK_SUGGESTIONS_INFO)}
className="text-[#9099AC] hover:text-white"
placement="bottom"
tooltipClassName="max-w-[348px]"
>
<FaInfoCircle size={16} />
</TooltipButton>
</div>
<div className="flex flex-col gap-6">
{isLoading && <TaskSuggestionsSkeleton />}
@@ -32,7 +32,7 @@ export function BrandButton({
type={type}
onClick={onClick}
className={cn(
"w-fit p-2 text-sm rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80",
"w-fit p-2 text-sm rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80 cursor-pointer",
variant === "primary" && "bg-primary text-[#0D0F11]",
variant === "secondary" && "border border-primary text-primary",
variant === "danger" && "bg-red-600 text-white hover:bg-red-700",
@@ -34,10 +34,15 @@ export function SecretListItem({
data-testid="secret-item"
className="border-t border-[#717888] last-of-type:border-b max-w-[830px] py-[13px] flex w-full items-center"
>
<td className="w-1/4 text-sm text-content-2">{title}</td>
<td className="w-1/4 text-sm text-content-2 truncate" title={title}>
{title}
</td>
<td className="w-1/2 truncate overflow-hidden whitespace-nowrap text-sm text-content-2 opacity-80 italic">
{description || "-"}
<td
className="w-1/2 truncate overflow-hidden whitespace-nowrap text-sm text-content-2 opacity-80 italic"
title={description || ""}
>
{description || ""}
</td>
<td className="w-1/4 flex items-center justify-end gap-4">
@@ -26,14 +26,14 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
};
return (
<div data-testid="user-actions" className="w-8 h-8 relative">
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
<UserAvatar
avatarUrl={user?.avatar_url}
onClick={toggleAccountMenu}
isLoading={isLoading}
/>
{accountContextMenuIsVisible && (
{accountContextMenuIsVisible && !!user && (
<AccountSettingsContextMenu
onLogout={handleLogout}
onClose={closeAccountMenu}
@@ -21,7 +21,7 @@ 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",
"w-8 h-8 rounded-full flex items-center justify-center cursor-pointer",
isLoading && "bg-transparent",
)}
>
@@ -16,7 +16,7 @@ export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
type="button"
data-testid="suggestion"
onClick={() => onClick(suggestion.value)}
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold"
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold cursor-pointer"
>
{t(suggestion.label)}
</button>
@@ -20,13 +20,12 @@ export function ActionButton({
<button
onClick={() => handleAction(action)}
disabled={isDisabled}
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-colors duration-300 ease-in-out border border-transparent hover:border-red-400/40 rounded-full p-1"
type="button"
>
<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" />
</button>
</Tooltip>
);
@@ -29,6 +29,7 @@ export function ConversationPanelButton({
<FaListUl
size={22}
className={cn(
"cursor-pointer",
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}
@@ -15,7 +15,7 @@ export function StopButton({ isDisabled, onClick }: StopButtonProps) {
disabled={isDisabled}
onClick={onClick}
type="button"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center cursor-pointer"
>
<div className="w-[10px] h-[10px] bg-white" />
</button>
@@ -15,7 +15,7 @@ export function SubmitButton({ isDisabled, onClick }: SubmitButtonProps) {
disabled={isDisabled}
onClick={onClick}
type="submit"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center cursor-pointer"
>
<ArrowSendIcon />
</button>
@@ -1,4 +1,4 @@
import { Tooltip } from "@heroui/react";
import { Tooltip, TooltipProps } from "@heroui/react";
import React, { ReactNode } from "react";
import { NavLink } from "react-router";
import { cn } from "#/utils/utils";
@@ -12,7 +12,9 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
tooltipClassName?: React.HTMLAttributes<HTMLDivElement>["className"];
disabled?: boolean;
placement?: TooltipProps["placement"];
}
export function TooltipButton({
@@ -24,7 +26,9 @@ export function TooltipButton({
ariaLabel,
testId,
className,
tooltipClassName,
disabled = false,
placement = "right",
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick && !disabled) {
@@ -118,7 +122,12 @@ export function TooltipButton({
}
return (
<Tooltip content={tooltip} closeDelay={100} placement="right">
<Tooltip
content={tooltip}
closeDelay={100}
placement={placement}
className={tooltipClassName}
>
{content}
</Tooltip>
);
@@ -18,7 +18,7 @@ export function TrajectoryActionButton({
type="button"
data-testid={testId}
onClick={onClick}
className="button-base p-1 hover:bg-neutral-500"
className="button-base p-1 hover:bg-neutral-500 cursor-pointer"
>
{icon}
</button>
@@ -68,7 +68,7 @@ export function ModelSelector({
const { t } = useTranslation();
return (
<div className="flex flex-col md:flex-row w-[full] md:w-[680px] justify-between gap-4 md:gap-[46px]">
<div className="flex flex-col md:flex-row w-[full] max-w-[680px] justify-between gap-4 md:gap-[46px]">
<fieldset className="flex flex-col gap-2.5 w-full">
<label className="text-sm">{t(I18nKey.LLM$PROVIDER)}</label>
<Autocomplete
@@ -0,0 +1,31 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useStopConversation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: { conversationId: string }) =>
OpenHands.stopConversation(variables.conversationId),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
"user",
"conversations",
]);
return { previousConversations };
},
onError: (_, __, context) => {
if (context?.previousConversations) {
queryClient.setQueryData(
["user", "conversations"],
context.previousConversations,
);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
},
});
};
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
export interface FeedbackData {
exists: boolean;
@@ -10,6 +11,7 @@ export interface FeedbackData {
export const useFeedbackExists = (eventId?: number) => {
const { conversationId } = useConversationId();
const { data: config } = useConfig();
return useQuery<FeedbackData>({
queryKey: ["feedback", "exists", conversationId, eventId],
@@ -17,7 +19,7 @@ export const useFeedbackExists = (eventId?: number) => {
if (!eventId) return { exists: false };
return OpenHands.checkFeedbackExists(conversationId, eventId);
},
enabled: !!eventId,
enabled: !!eventId && config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
+14
View File
@@ -97,6 +97,9 @@ export enum I18nKey {
BROWSER$EMPTY_MESSAGE = "BROWSER$EMPTY_MESSAGE",
SETTINGS$TITLE = "SETTINGS$TITLE",
CONVERSATION$START_NEW = "CONVERSATION$START_NEW",
CONVERSATION$REPOSITORY = "CONVERSATION$REPOSITORY",
CONVERSATION$BRANCH = "CONVERSATION$BRANCH",
CONVERSATION$GIT_PROVIDER = "CONVERSATION$GIT_PROVIDER",
ACCOUNT_SETTINGS$TITLE = "ACCOUNT_SETTINGS$TITLE",
WORKSPACE$TERMINAL_TAB_LABEL = "WORKSPACE$TERMINAL_TAB_LABEL",
WORKSPACE$BROWSER_TAB_LABEL = "WORKSPACE$BROWSER_TAB_LABEL",
@@ -259,6 +262,7 @@ export enum I18nKey {
CHAT_INTERFACE$AGENT_RUNNING_MESSAGE = "CHAT_INTERFACE$AGENT_RUNNING_MESSAGE",
CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE = "CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE",
CHAT_INTERFACE$AGENT_RATE_LIMITED_MESSAGE = "CHAT_INTERFACE$AGENT_RATE_LIMITED_MESSAGE",
CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE = "CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE",
CHAT_INTERFACE$AGENT_PAUSED_MESSAGE = "CHAT_INTERFACE$AGENT_PAUSED_MESSAGE",
LANDING$TITLE = "LANDING$TITLE",
LANDING$SUBTITLE = "LANDING$SUBTITLE",
@@ -290,12 +294,18 @@ export enum I18nKey {
USER$ACCOUNT_SETTINGS = "USER$ACCOUNT_SETTINGS",
JUPYTER$OUTPUT_LABEL = "JUPYTER$OUTPUT_LABEL",
BUTTON$STOP = "BUTTON$STOP",
BUTTON$EDIT_TITLE = "BUTTON$EDIT_TITLE",
BUTTON$DOWNLOAD_VIA_VSCODE = "BUTTON$DOWNLOAD_VIA_VSCODE",
BUTTON$DISPLAY_COST = "BUTTON$DISPLAY_COST",
BUTTON$SHOW_AGENT_TOOLS_AND_METADATA = "BUTTON$SHOW_AGENT_TOOLS_AND_METADATA",
LANDING$ATTACH_IMAGES = "LANDING$ATTACH_IMAGES",
LANDING$OPEN_REPO = "LANDING$OPEN_REPO",
LANDING$REPLAY = "LANDING$REPLAY",
LANDING$UPLOAD_TRAJECTORY = "LANDING$UPLOAD_TRAJECTORY",
LANDING$RECENT_CONVERSATION = "LANDING$RECENT_CONVERSATION",
CONVERSATION$CONFIRM_DELETE = "CONVERSATION$CONFIRM_DELETE",
CONVERSATION$CONFIRM_STOP = "CONVERSATION$CONFIRM_STOP",
CONVERSATION$STOP_WARNING = "CONVERSATION$STOP_WARNING",
CONVERSATION$METRICS_INFO = "CONVERSATION$METRICS_INFO",
CONVERSATION$CREATED = "CONVERSATION$CREATED",
CONVERSATION$AGO = "CONVERSATION$AGO",
@@ -345,6 +355,7 @@ export enum I18nKey {
SETTINGS$DONT_KNOW_API_KEY = "SETTINGS$DONT_KNOW_API_KEY",
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
SETTINGS$SAVED = "SETTINGS$SAVED",
SETTINGS$SAVED_WARNING = "SETTINGS$SAVED_WARNING",
SETTINGS$RESET = "SETTINGS$RESET",
SETTINGS$API_KEYS = "SETTINGS$API_KEYS",
SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION",
@@ -562,6 +573,7 @@ export enum I18nKey {
CONVERSATION$NO_MICROAGENTS = "CONVERSATION$NO_MICROAGENTS",
CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS",
MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE",
MICROAGENTS_MODAL$WARNING = "MICROAGENTS_MODAL$WARNING",
MICROAGENTS_MODAL$TRIGGERS = "MICROAGENTS_MODAL$TRIGGERS",
MICROAGENTS_MODAL$INPUTS = "MICROAGENTS_MODAL$INPUTS",
MICROAGENTS_MODAL$TOOLS = "MICROAGENTS_MODAL$TOOLS",
@@ -611,6 +623,8 @@ export enum I18nKey {
REPOSITORY$SELECT_REPO = "REPOSITORY$SELECT_REPO",
TASKS$SUGGESTED_TASKS = "TASKS$SUGGESTED_TASKS",
TASKS$NO_TASKS_AVAILABLE = "TASKS$NO_TASKS_AVAILABLE",
TASKS$TASK_SUGGESTIONS_INFO = "TASKS$TASK_SUGGESTIONS_INFO",
TASKS$TASK_SUGGESTIONS_TOOLTIP = "TASKS$TASK_SUGGESTIONS_TOOLTIP",
PAYMENT$SPECIFY_AMOUNT_USD = "PAYMENT$SPECIFY_AMOUNT_USD",
GIT$BITBUCKET_TOKEN_HELP_LINK = "GIT$BITBUCKET_TOKEN_HELP_LINK",
GIT$BITBUCKET_TOKEN_SEE_MORE_LINK = "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK",
+1 -1
View File
@@ -27,7 +27,7 @@ i18n
.init({
fallbackLng: "en",
debug: import.meta.env.NODE_ENV === "development",
load: "languageOnly",
load: "currentOnly",
});
export default i18n;
+238 -14
View File
@@ -1551,6 +1551,54 @@
"de": "Neue Unterhaltung starten",
"uk": "Почати нову розмову"
},
"CONVERSATION$REPOSITORY": {
"en": "Repository",
"ja": "リポジトリ",
"zh-CN": "仓库",
"zh-TW": "倉庫",
"ko-KR": "저장소",
"no": "Repository",
"it": "Repository",
"pt": "Repositório",
"es": "Repositorio",
"ar": "المستودع",
"fr": "Dépôt",
"tr": "Depo",
"de": "Repository",
"uk": "Репозиторій"
},
"CONVERSATION$BRANCH": {
"en": "Branch",
"ja": "ブランチ",
"zh-CN": "分支",
"zh-TW": "分支",
"ko-KR": "브랜치",
"no": "Gren",
"it": "Ramo",
"pt": "Ramo",
"es": "Rama",
"ar": "الفرع",
"fr": "Branche",
"tr": "Dal",
"de": "Zweig",
"uk": "Гілка"
},
"CONVERSATION$GIT_PROVIDER": {
"en": "Git Provider",
"ja": "Git プロバイダー",
"zh-CN": "Git 提供商",
"zh-TW": "Git 提供商",
"ko-KR": "Git 제공업체",
"no": "Git-leverandør",
"it": "Provider Git",
"pt": "Provedor Git",
"es": "Proveedor Git",
"ar": "مزود Git",
"fr": "Fournisseur Git",
"tr": "Git Sağlayıcısı",
"de": "Git-Anbieter",
"uk": "Git-провайдер"
},
"ACCOUNT_SETTINGS$TITLE": {
"en": "Account Settings",
"ja": "アカウント設定",
@@ -4128,20 +4176,36 @@
"uk": "Агент очікує на введення даних від користувача..."
},
"CHAT_INTERFACE$AGENT_RATE_LIMITED_MESSAGE": {
"en": "Agent is Rate Limited",
"zh-CN": "智能体已达到速率限制",
"zh-TW": "智慧代理已達到速率限制",
"de": "Agent ist ratenbegrenzt",
"ko-KR": "에이전트가 속도 제한되었습니다",
"no": "Agenten er hastighetsbegrenset",
"it": "L'agente è limitato dalla frequenza",
"pt": "O agente está com limite de taxa",
"es": "El agente está limitado por tasa",
"ar": "الوكيل مقيد بحد السرعة",
"fr": "L'agent est limité en fréquence",
"tr": "Ajan hız sınırına ulaştı",
"ja": "エージェントがレート制限中",
"uk": "Агента обмежено кількістю запитів"
"en": "Agent is Rate Limited. Retrying...",
"zh-CN": "智能体已达到速率限制。正在重试...",
"zh-TW": "智慧代理已達到速率限制。正在重試...",
"de": "Agent ist ratenbegrenzt. Wiederholungsversuch...",
"ko-KR": "에이전트가 속도 제한되었습니다. 재시도 중...",
"no": "Agenten er hastighetsbegrenset. Prøver på nytt...",
"it": "L'agente è limitato dalla frequenza. Riprovando...",
"pt": "O agente está com limite de taxa. Tentando novamente...",
"es": "El agente está limitado por tasa. Reintentando...",
"ar": "الوكيل مقيد بحد السرعة. إعادة المحاولة...",
"fr": "L'agent est limité en fréquence. Nouvelle tentative...",
"tr": "Ajan hız sınırına ulaştı. Yeniden deniyor...",
"ja": "エージェントがレート制限中。再試行しています...",
"uk": "Агента обмежено кількістю запитів. Повторюємо спробу..."
},
"CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE": {
"en": "Agent is rate-limited. Stopped.",
"zh-CN": "智能体已达到速率限制。已停止。",
"zh-TW": "智慧代理已達到速率限制。已停止。",
"de": "Agent ist ratenbegrenzt. Angehalten.",
"ko-KR": "에이전트가 속도 제한되었습니다. 중지됨.",
"no": "Agenten er hastighetsbegrenset. Stoppet.",
"it": "L'agente è limitato dalla frequenza. Fermato.",
"pt": "O agente está com limite de taxa. Parado.",
"es": "El agente está limitado por tasa. Detenido.",
"ar": "الوكيل مقيد بحد السرعة. توقف.",
"fr": "L'agent est limité en fréquence. Arrêté.",
"tr": "Ajan hız sınırına ulaştı. Durduruldu.",
"ja": "エージェントがレート制限中。停止しました。",
"uk": "Агента обмежено кількістю запитів. Зупинено."
},
"CHAT_INTERFACE$AGENT_PAUSED_MESSAGE": {
"en": "Agent has paused.",
@@ -4639,6 +4703,70 @@
"tr": "Durdur",
"uk": "Стоп"
},
"BUTTON$EDIT_TITLE": {
"en": "Edit Title",
"ja": "タイトルを編集",
"zh-CN": "编辑标题",
"zh-TW": "編輯標題",
"ko-KR": "제목 편집",
"fr": "Modifier le titre",
"es": "Editar título",
"de": "Titel bearbeiten",
"it": "Modifica titolo",
"pt": "Editar título",
"ar": "تحرير العنوان",
"no": "Rediger tittel",
"tr": "Başlığı Düzenle",
"uk": "Редагувати заголовок"
},
"BUTTON$DOWNLOAD_VIA_VSCODE": {
"en": "Download via VS Code",
"ja": "VS Code経由でダウンロード",
"zh-CN": "通过VS Code下载",
"zh-TW": "透過VS Code下載",
"ko-KR": "VS Code를 통해 다운로드",
"fr": "Télécharger via VS Code",
"es": "Descargar a través de VS Code",
"de": "Über VS Code herunterladen",
"it": "Scarica tramite VS Code",
"pt": "Baixar via VS Code",
"ar": "تحميل عبر VS Code",
"no": "Last ned via VS Code",
"tr": "VS Code ile İndir",
"uk": "Завантажити через VS Code"
},
"BUTTON$DISPLAY_COST": {
"en": "Display Cost",
"ja": "コストを表示",
"zh-CN": "显示成本",
"zh-TW": "顯示成本",
"ko-KR": "비용 표시",
"fr": "Afficher le coût",
"es": "Mostrar costo",
"de": "Kosten anzeigen",
"it": "Mostra costo",
"pt": "Mostrar custo",
"ar": "عرض التكلفة",
"no": "Vis kostnad",
"tr": "Maliyeti Göster",
"uk": "Показати вартість"
},
"BUTTON$SHOW_AGENT_TOOLS_AND_METADATA": {
"en": "Show Agent Tools & Metadata",
"ja": "エージェントツールとメタデータを表示",
"zh-CN": "显示代理工具和元数据",
"zh-TW": "顯示代理工具和元數據",
"ko-KR": "에이전트 도구 및 메타데이터 표시",
"fr": "Afficher les outils et métadonnées de l'agent",
"es": "Mostrar herramientas y metadatos del agente",
"de": "Agent-Tools und Metadaten anzeigen",
"it": "Mostra strumenti e metadati dell'agente",
"pt": "Mostrar ferramentas e metadados do agente",
"ar": "عرض أدوات الوكيل والبيانات الوصفية",
"no": "Vis agentverktøy og metadata",
"tr": "Ajan Araçları ve Meta Verileri Göster",
"uk": "Показати інструменти агента та метадані"
},
"LANDING$ATTACH_IMAGES": {
"en": "Attach images",
"ja": "画像を添付",
@@ -4735,6 +4863,38 @@
"de": "Löschen bestätigen",
"uk": "Підтвердити видалення"
},
"CONVERSATION$CONFIRM_STOP": {
"en": "Confirm Stop",
"ja": "停止の確認",
"zh-CN": "确认停止",
"zh-TW": "確認停止",
"ko-KR": "중지 확인",
"no": "Bekreft stopp",
"it": "Conferma arresto",
"pt": "Confirmar parada",
"es": "Confirmar detención",
"ar": "تأكيد الإيقاف",
"fr": "Confirmer l'arrêt",
"tr": "Durdurmayı Onayla",
"de": "Stopp bestätigen",
"uk": "Підтвердити зупинку"
},
"CONVERSATION$STOP_WARNING": {
"en": "Are you sure you want to stop this conversation?",
"ja": "この会話を停止してもよろしいですか?",
"zh-CN": "您确定要停止此对话吗?",
"zh-TW": "您確定要停止此對話嗎?",
"ko-KR": "이 대화를 중지하시겠습니까?",
"no": "Er du sikker på at du vil stoppe denne samtalen?",
"it": "Sei sicuro di voler fermare questa conversazione?",
"pt": "Tem certeza de que deseja parar esta conversa?",
"es": "¿Está seguro de que desea detener esta conversación?",
"ar": "هل أنت متأكد أنك تريد إيقاف هذه المحادثة؟",
"fr": "Êtes-vous sûr de vouloir arrêter cette conversation ?",
"tr": "Bu konuşmayı durdurmak istediğinizden emin misiniz?",
"de": "Sind Sie sicher, dass Sie dieses Gespräch stoppen möchten?",
"uk": "Ви впевнені, що хочете зупинити цю розмову?"
},
"CONVERSATION$METRICS_INFO": {
"en": "Conversation Metrics",
"ja": "会話メトリクス",
@@ -5519,6 +5679,22 @@
"de": "Einstellungen gespeichert",
"uk": "Налаштування збережено"
},
"SETTINGS$SAVED_WARNING": {
"en": "Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes.",
"ja": "設定が保存されました。古い会話では、変更を確認するために会話を停止して再開する必要があります。",
"zh-CN": "设置已保存。对于旧对话,您需要停止并重新开始对话才能看到更改。",
"zh-TW": "設置已保存。對於舊對話,您需要停止並重新開始對話才能看到更改。",
"ko-KR": "설정이 저장되었습니다. 기존 대화의 경우 변경사항을 보려면 대화를 중지하고 다시 시작해야 합니다.",
"no": "Innstillinger lagret. For gamle samtaler må du stoppe og starte samtalen på nytt for å se endringene.",
"it": "Impostazioni salvate. Per le conversazioni precedenti, dovrai fermare e riavviare la conversazione per vedere le modifiche.",
"pt": "Configurações salvas. Para conversas antigas, você precisará parar e reiniciar a conversa para ver as alterações.",
"es": "Configuración guardada. Para conversaciones antiguas, necesitarás detener y reiniciar la conversación para ver los cambios.",
"ar": "تم حفظ الإعدادات. بالنسبة للمحادثات القديمة، ستحتاج إلى إيقاف وإعادة تشغيل المحادثة لرؤية التغييرات.",
"fr": "Paramètres enregistrés. Pour les anciennes conversations, vous devrez arrêter et redémarrer la conversation pour voir les changements.",
"tr": "Ayarlar kaydedildi. Eski konuşmalar için değişiklikleri görmek üzere konuşmayı durdurup yeniden başlatmanız gerekecek.",
"de": "Einstellungen gespeichert. Für alte Gespräche müssen Sie das Gespräch stoppen und neu starten, um die Änderungen zu sehen.",
"uk": "Налаштування збережено. Для старих розмов вам потрібно буде зупинити та перезапустити розмову, щоб побачити зміни."
},
"SETTINGS$RESET": {
"en": "Settings reset",
"ja": "設定がリセットされました",
@@ -8991,6 +9167,22 @@
"tr": "Kullanılabilir mikro ajanlar",
"uk": "Доступні мікроагенти"
},
"MICROAGENTS_MODAL$WARNING": {
"en": "If you update the microagents, you will need to stop the conversation and then click on the refresh button to see the changes.",
"ja": "マイクロエージェントを更新する場合、会話を停止してから更新ボタンをクリックして変更を確認する必要があります。",
"zh-CN": "如果您更新微代理,您需要停止对话,然后点击刷新按钮以查看更改。",
"zh-TW": "如果您更新微代理,您需要停止對話,然後點擊重新整理按鈕以查看更改。",
"ko-KR": "마이크로에이전트를 업데이트하는 경우 대화를 중지한 후 새로고침 버튼을 클릭하여 변경사항을 확인해야 합니다.",
"no": "Hvis du oppdaterer mikroagentene, må du stoppe samtalen og deretter klikke på oppdater-knappen for å se endringene.",
"ar": "إذا قمت بتحديث الوكلاء المصغرين، فستحتاج إلى إيقاف المحادثة ثم النقر على زر التحديث لرؤية التغييرات.",
"de": "Wenn Sie die Mikroagenten aktualisieren, müssen Sie das Gespräch beenden und dann auf die Aktualisieren-Schaltfläche klicken, um die Änderungen zu sehen.",
"fr": "Si vous mettez à jour les micro-agents, vous devrez arrêter la conversation puis cliquer sur le bouton actualiser pour voir les changements.",
"it": "Se aggiorni i microagenti, dovrai fermare la conversazione e poi cliccare sul pulsante aggiorna per vedere le modifiche.",
"pt": "Se você atualizar os microagentes, precisará parar a conversa e depois clicar no botão atualizar para ver as alterações.",
"es": "Si actualiza los microagentes, necesitará detener la conversación y luego hacer clic en el botón actualizar para ver los cambios.",
"tr": "Mikro ajanları güncellerseniz, konuşmayı durdurmanız ve ardından değişiklikleri görmek için yenile düğmesine tıklamanız gerekecektir.",
"uk": "Якщо ви оновите мікроагенти, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни."
},
"MICROAGENTS_MODAL$TRIGGERS": {
"en": "Triggers",
"ja": "トリガー",
@@ -9775,6 +9967,38 @@
"de": "Keine Aufgaben verfügbar",
"uk": "Немає доступних завдань"
},
"TASKS$TASK_SUGGESTIONS_INFO": {
"en": "Task suggestions information",
"ja": "タスク提案情報",
"zh-CN": "任务建议信息",
"zh-TW": "任務建議資訊",
"ko-KR": "작업 제안 정보",
"no": "Oppgaveforslag informasjon",
"it": "Informazioni sui suggerimenti di attività",
"pt": "Informações de sugestões de tarefas",
"es": "Información de sugerencias de tareas",
"ar": "معلومات اقتراحات المهام",
"fr": "Informations sur les suggestions de tâches",
"tr": "Görev önerisi bilgileri",
"de": "Aufgabenvorschlag-Informationen",
"uk": "Інформація про пропозиції завдань"
},
"TASKS$TASK_SUGGESTIONS_TOOLTIP": {
"en": "These are AI-curated task suggestions to help you get started with common development activities and best practices for your repository.",
"ja": "これらは、リポジトリの一般的な開発活動とベストプラクティスを始めるのに役立つAIによってキュレーションされたタスク提案です。",
"zh-CN": "这些是AI策划的任务建议,帮助您开始进行常见的开发活动和存储库的最佳实践。",
"zh-TW": "這些是AI策劃的任務建議,幫助您開始進行常見的開發活動和存儲庫的最佳實踐。",
"ko-KR": "이것은 저장소의 일반적인 개발 활동과 모범 사례를 시작할 수 있도록 도와주는 AI가 선별한 작업 제안입니다.",
"no": "Dette er AI-kuraterte oppgaveforslag som hjelper deg å komme i gang med vanlige utviklingsaktiviteter og beste praksis for ditt depot.",
"it": "Questi sono suggerimenti di attività curati dall'IA per aiutarti a iniziare con le attività di sviluppo comuni e le migliori pratiche per il tuo repository.",
"pt": "Estas são sugestões de tarefas curadas por IA para ajudá-lo a começar com atividades de desenvolvimento comuns e melhores práticas para seu repositório.",
"es": "Estas son sugerencias de tareas curadas por IA para ayudarte a comenzar con actividades de desarrollo comunes y mejores prácticas para tu repositorio.",
"ar": "هذه اقتراحات مهام منسقة بواسطة الذكاء الاصطناعي لمساعدتك على البدء بأنشطة التطوير الشائعة وأفضل الممارسات لمستودعك.",
"fr": "Ce sont des suggestions de tâches curées par l'IA pour vous aider à commencer avec les activités de développement courantes et les meilleures pratiques pour votre dépôt.",
"tr": "Bunlar, deponuz için yaygın geliştirme faaliyetleri ve en iyi uygulamalarla başlamanıza yardımcı olmak için AI tarafından düzenlenmiş görev önerileridir.",
"de": "Dies sind KI-kuratierte Aufgabenvorschläge, die Ihnen helfen, mit gängigen Entwicklungsaktivitäten und bewährten Praktiken für Ihr Repository zu beginnen.",
"uk": "Це AI-курировані пропозиції завдань, які допоможуть вам розпочати з поширеними діяльностями розробки та найкращими практиками для вашого репозиторію."
},
"PAYMENT$SPECIFY_AMOUNT_USD": {
"en": "Specify an amount in USD to add - min $10",
"ja": "追加するUSD金額を指定してください - 最小$10",
+1 -1
View File
@@ -192,7 +192,7 @@ function AppSettingsScreen() {
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
step={1}
className="w-[680px]" // Match the width of the language field
className="w-full max-w-[680px]" // Match the width of the language field
/>
</div>
)}
+1 -1
View File
@@ -24,7 +24,7 @@ function HomeScreen() {
<hr className="border-[#717888]" />
<main className="flex flex-col md:flex-row justify-between gap-8">
<main className="flex flex-col lg:flex-row justify-between gap-8">
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>
+1 -1
View File
@@ -75,7 +75,7 @@ function LlmSettingsScreen() {
}, [settings, resources]);
const handleSuccessfulMutation = () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING));
setDirtyInputs({
model: false,
apiKey: false,
+1 -1
View File
@@ -197,7 +197,7 @@ export default function MainApp() {
return (
<div
data-testid="root-layout"
className="bg-base p-3 h-screen md:min-w-[1024px] flex flex-col md:flex-row gap-3"
className="bg-base p-3 h-screen lg:min-w-[1024px] flex flex-col md:flex-row gap-3"
>
<Sidebar />
+1 -1
View File
@@ -11,7 +11,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
}
export interface SystemMessageAction extends OpenHandsActionEvent<"system"> {
source: "agent";
source: "agent" | "environment";
args: {
content: string;
tools: Array<Record<string, unknown>> | null;
+70
View File
@@ -0,0 +1,70 @@
export const MAX_FILE_SIZE = 3 * 1024 * 1024; // 3MB maximum file size
export const MAX_TOTAL_SIZE = 3 * 1024 * 1024; // 3MB maximum total size for all files combined
export interface FileValidationResult {
isValid: boolean;
errorMessage?: string;
oversizedFiles?: string[];
}
/**
* Validates individual file sizes
*/
export function validateIndividualFileSizes(
files: File[],
): FileValidationResult {
const oversizedFiles = files.filter((file) => file.size > MAX_FILE_SIZE);
if (oversizedFiles.length > 0) {
const fileNames = oversizedFiles.map((f) => f.name);
return {
isValid: false,
errorMessage: `Files exceeding 3MB are not allowed: ${fileNames.join(", ")}`,
oversizedFiles: fileNames,
};
}
return { isValid: true };
}
/**
* Validates total file size including existing files
*/
export function validateTotalFileSize(
newFiles: File[],
existingFiles: File[] = [],
): FileValidationResult {
const currentTotalSize = existingFiles.reduce(
(sum, file) => sum + file.size,
0,
);
const newFilesSize = newFiles.reduce((sum, file) => sum + file.size, 0);
const totalSize = currentTotalSize + newFilesSize;
if (totalSize > MAX_TOTAL_SIZE) {
const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(1);
return {
isValid: false,
errorMessage: `Total file size would be ${totalSizeMB}MB, exceeding the 3MB limit. Please select fewer or smaller files.`,
};
}
return { isValid: true };
}
/**
* Validates both individual and total file sizes
*/
export function validateFiles(
newFiles: File[],
existingFiles: File[] = [],
): FileValidationResult {
// First check individual file sizes
const individualValidation = validateIndividualFileSizes(newFiles);
if (!individualValidation.isValid) {
return individualValidation;
}
// Then check total size
return validateTotalFileSize(newFiles, existingFiles);
}
+1
View File
@@ -23,6 +23,7 @@ export const MAP_PROVIDER = {
replicate: "Replicate",
voyage: "Voyage AI",
openrouter: "OpenRouter",
openhands: "OpenHands",
};
export const mapProvider = (provider: string) =>
+3 -1
View File
@@ -1,5 +1,5 @@
// Here are the list of verified models and providers that we know work well with OpenHands.
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic", "deepseek"];
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic", "deepseek", "openhands"];
export const VERIFIED_MODELS = [
"o3-mini-2025-01-31",
"o3-2025-04-16",
@@ -8,6 +8,8 @@ export const VERIFIED_MODELS = [
"claude-3-7-sonnet-20250219",
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"gemini-2.5-pro",
"o4-mini",
"deepseek-chat",
];
+10 -4
View File
@@ -28,6 +28,7 @@ from openhands.core.config import (
OpenHandsConfig,
)
from openhands.core.schema import AgentState
from openhands.core.schema.exit_reason import ExitReason
from openhands.events import EventSource
from openhands.events.action import (
ChangeAgentStateAction,
@@ -45,10 +46,11 @@ async def handle_commands(
config: OpenHandsConfig,
current_dir: str,
settings_store: FileSettingsStore,
) -> tuple[bool, bool, bool]:
) -> tuple[bool, bool, bool, ExitReason]:
close_repl = False
reload_microagents = False
new_session_requested = False
exit_reason = ExitReason.ERROR
if command == '/exit':
close_repl = handle_exit_command(
@@ -57,6 +59,8 @@ async def handle_commands(
usage_metrics,
sid,
)
if close_repl:
exit_reason = ExitReason.INTENTIONAL
elif command == '/help':
handle_help_command()
elif command == '/init':
@@ -69,6 +73,8 @@ async def handle_commands(
close_repl, new_session_requested = handle_new_command(
config, event_stream, usage_metrics, sid
)
if close_repl:
exit_reason = ExitReason.INTENTIONAL
elif command == '/settings':
await handle_settings_command(config, settings_store)
elif command == '/resume':
@@ -78,7 +84,7 @@ async def handle_commands(
action = MessageAction(content=command)
event_stream.add_event(action, EventSource.USER)
return close_repl, reload_microagents, new_session_requested
return close_repl, reload_microagents, new_session_requested, exit_reason
def handle_exit_command(
@@ -123,7 +129,7 @@ async def handle_init_command(
close_repl = False
reload_microagents = False
if config.runtime == 'local':
if config.runtime in ('local', 'cli'):
init_repo = await init_repository(config, current_dir)
if init_repo:
event_stream.add_event(
@@ -134,7 +140,7 @@ async def handle_init_command(
close_repl = True
else:
print_formatted_text(
'\nRepository initialization through the CLI is only supported for local runtime.\n'
'\nRepository initialization through the CLI is only supported for CLI and local runtimes.\n'
)
return close_repl, reload_microagents
+22 -9
View File
@@ -23,9 +23,10 @@ from openhands.cli.tui import (
display_initialization_animation,
display_runtime_initialization_message,
display_welcome_message,
process_agent_pause,
read_confirmation_input,
read_prompt_input,
start_pause_listener,
stop_pause_listener,
update_streaming_output,
)
from openhands.cli.utils import (
@@ -40,9 +41,11 @@ from openhands.core.config import (
)
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
from openhands.core.config.utils import finalize_config
from openhands.core.logger import openhands_logger as logger
from openhands.core.loop import run_agent_until_done
from openhands.core.schema import AgentState
from openhands.core.schema.exit_reason import ExitReason
from openhands.core.setup import (
create_agent,
create_controller,
@@ -116,11 +119,11 @@ async def run_session(
) -> bool:
reload_microagents = False
new_session_requested = False
exit_reason = ExitReason.INTENTIONAL
sid = generate_sid(config, session_name)
is_loaded = asyncio.Event()
is_paused = asyncio.Event() # Event to track agent pause requests
pause_task: asyncio.Task | None = None # No more than one pause task
always_confirm_mode = False # Flag to enable always confirm mode
# Show runtime initialization message
@@ -152,7 +155,7 @@ async def run_session(
usage_metrics = UsageMetrics()
async def prompt_for_next_task(agent_state: str) -> None:
nonlocal reload_microagents, new_session_requested
nonlocal reload_microagents, new_session_requested, exit_reason
while True:
next_message = await read_prompt_input(
config, agent_state, multiline=config.cli_multiline_input
@@ -165,6 +168,7 @@ async def run_session(
close_repl,
reload_microagents,
new_session_requested,
exit_reason,
) = await handle_commands(
next_message,
event_stream,
@@ -183,6 +187,10 @@ async def run_session(
display_event(event, config)
update_usage_metrics(event, usage_metrics)
if isinstance(event, AgentStateChangedObservation):
if event.agent_state not in [AgentState.RUNNING, AgentState.PAUSED]:
await stop_pause_listener()
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [
AgentState.AWAITING_USER_INPUT,
@@ -236,11 +244,7 @@ async def run_session(
if event.agent_state == AgentState.RUNNING:
display_agent_running_message()
nonlocal pause_task
if pause_task is None or pause_task.done():
pause_task = loop.create_task(
process_agent_pause(is_paused, event_stream)
) # Create a task to track agent pause requests from the user
start_pause_listener(loop, is_paused, event_stream)
def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))
@@ -330,6 +334,11 @@ async def run_session(
await cleanup_session(loop, agent, runtime, controller)
if exit_reason == ExitReason.INTENTIONAL:
print_formatted_text('✅ Session terminated successfully.\n')
else:
print_formatted_text(f'⚠️ Session was interrupted: {exit_reason.value}\n')
return new_session_requested
@@ -425,6 +434,10 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
config.workspace_base = os.getcwd()
config.security.confirmation_mode = True
# Need to finalize config again after setting runtime to 'cli'
# This ensures Jupyter plugin is disabled for CLI runtime
finalize_config(config)
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base
@@ -478,7 +491,7 @@ def main():
try:
loop.run_until_complete(main_with_loop(loop))
except KeyboardInterrupt:
print('Received keyboard interrupt, shutting down...')
print_formatted_text('⚠️ Session was interrupted: interrupted\n')
except ConnectionRefusedError as e:
print(f'Connection refused: {e}')
sys.exit(1)
+9
View File
@@ -15,6 +15,7 @@ from openhands.cli.utils import (
VERIFIED_ANTHROPIC_MODELS,
VERIFIED_MISTRAL_MODELS,
VERIFIED_OPENAI_MODELS,
VERIFIED_OPENHANDS_MODELS,
VERIFIED_PROVIDERS,
organize_models_and_providers,
)
@@ -234,6 +235,11 @@ async def modify_llm_settings_basic(
m for m in provider_models if m not in VERIFIED_MISTRAL_MODELS
]
provider_models = VERIFIED_MISTRAL_MODELS + provider_models
if provider == 'openhands':
provider_models = [
m for m in provider_models if m not in VERIFIED_OPENHANDS_MODELS
]
provider_models = VERIFIED_OPENHANDS_MODELS + provider_models
# Set default model to the best verified model for the provider
if provider == 'anthropic' and VERIFIED_ANTHROPIC_MODELS:
@@ -245,6 +251,9 @@ async def modify_llm_settings_basic(
elif provider == 'mistral' and VERIFIED_MISTRAL_MODELS:
# Use the first model in the VERIFIED_MISTRAL_MODELS list as it's the best/newest
default_model = VERIFIED_MISTRAL_MODELS[0]
elif provider == 'openhands' and VERIFIED_OPENHANDS_MODELS:
# Use the first model in the VERIFIED_OPENHANDS_MODELS list as it's the best/newest
default_model = VERIFIED_OPENHANDS_MODELS[0]
else:
# For other providers, use the first model in the list
default_model = (
+55 -17
View File
@@ -3,6 +3,7 @@
# CLI Settings are handled separately in cli_settings.py
import asyncio
import contextlib
import sys
import threading
import time
@@ -75,6 +76,8 @@ COMMANDS = {
print_lock = threading.Lock()
pause_task: asyncio.Task | None = None # No more than one pause task
class UsageMetrics:
def __init__(self) -> None:
@@ -565,26 +568,58 @@ async def read_confirmation_input(config: OpenHandsConfig) -> str:
try:
prompt_session = create_prompt_session(config)
with patch_stdout():
print_formatted_text('')
confirmation: str = await prompt_session.prompt_async(
HTML('<gold>Proceed with action? (y)es/(n)o/(a)lways > </gold>'),
)
while True:
with patch_stdout():
print_formatted_text('')
confirmation: str = await prompt_session.prompt_async(
HTML('<gold>Proceed with action? (y)es/(n)o/(a)lways > </gold>'),
)
confirmation = '' if confirmation is None else confirmation.strip().lower()
confirmation = (
'' if confirmation is None else confirmation.strip().lower()
)
if confirmation in ['y', 'yes']:
return 'yes'
elif confirmation in ['n', 'no']:
return 'no'
elif confirmation in ['a', 'always']:
return 'always'
else:
return 'no'
if confirmation in ['y', 'yes']:
return 'yes'
elif confirmation in ['n', 'no']:
return 'no'
elif confirmation in ['a', 'always']:
return 'always'
else:
# Display error message for invalid input
print_formatted_text('')
print_formatted_text(
HTML(
'<ansired>Invalid input. Please enter (y)es, (n)o, or (a)lways.</ansired>'
)
)
# Continue the loop to re-prompt
except (KeyboardInterrupt, EOFError):
return 'no'
def start_pause_listener(
loop: asyncio.AbstractEventLoop,
done_event: asyncio.Event,
event_stream,
) -> None:
global pause_task
if pause_task is None or pause_task.done():
pause_task = loop.create_task(
process_agent_pause(done_event, event_stream)
) # Create a task to track agent pause requests from the user
async def stop_pause_listener() -> None:
global pause_task
if pause_task and not pause_task.done():
pause_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pause_task
await asyncio.sleep(0)
pause_task = None
async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) -> None:
input = create_input()
@@ -603,9 +638,12 @@ async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) ->
)
done.set()
with input.raw_mode():
with input.attach(keys_ready):
await done.wait()
try:
with input.raw_mode():
with input.attach(keys_ready):
await done.wait()
finally:
input.close()
def cli_confirm(
+10 -1
View File
@@ -104,6 +104,8 @@ def extract_model_and_provider(model: str) -> ModelInfo:
return ModelInfo(provider='anthropic', model=split[0], separator='/')
if split[0] in VERIFIED_MISTRAL_MODELS:
return ModelInfo(provider='mistral', model=split[0], separator='/')
if split[0] in VERIFIED_OPENHANDS_MODELS:
return ModelInfo(provider='openhands', model=split[0], separator='/')
# return as model only
return ModelInfo(provider='', model=model, separator='')
@@ -145,7 +147,7 @@ def organize_models_and_providers(
return result_dict
VERIFIED_PROVIDERS = ['anthropic', 'openai', 'mistral']
VERIFIED_PROVIDERS = ['anthropic', 'openai', 'mistral', 'openhands']
VERIFIED_OPENAI_MODELS = [
'o4-mini',
@@ -178,6 +180,13 @@ VERIFIED_MISTRAL_MODELS = [
'devstral-small-2505',
]
VERIFIED_OPENHANDS_MODELS = [
'claude-sonnet-4-20250514',
'claude-opus-4-20250514',
'gemini-2.5-pro',
'o4-mini',
]
class ProviderInfo(BaseModel):
"""Information about a provider and its models."""
+14 -1
View File
@@ -275,7 +275,20 @@ class AgentController:
err_id = 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION'
self.state.last_error = err_id
elif isinstance(e, RateLimitError):
await self.set_agent_state_to(AgentState.RATE_LIMITED)
# Check if this is the final retry attempt
if (
hasattr(e, 'retry_attempt')
and hasattr(e, 'max_retries')
and e.retry_attempt >= e.max_retries
):
# All retries exhausted, set to ERROR state with a special message
self.state.last_error = (
'CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE'
)
await self.set_agent_state_to(AgentState.ERROR)
else:
# Still retrying, set to RATE_LIMITED state
await self.set_agent_state_to(AgentState.RATE_LIMITED)
return
self.status_callback('error', err_id, self.state.last_error)
+7
View File
@@ -0,0 +1,7 @@
from enum import Enum
class ExitReason(Enum):
INTENTIONAL = 'intentional'
INTERRUPTED = 'interrupted'
ERROR = 'error'
+27 -21
View File
@@ -170,6 +170,15 @@ class LLM(RetryMixin, DebugMixin):
# litellm will handle it a bit differently than the openai-compatible params
kwargs['top_k'] = self.config.top_k
# Handle OpenHands provider - rewrite to litellm_proxy
if self.config.model.startswith('openhands/'):
model_name = self.config.model.removeprefix('openhands/')
self.config.model = f'litellm_proxy/{model_name}'
self.config.base_url = 'https://llm-proxy.app.all-hands.dev/'
logger.debug(
f'Rewrote openhands/{model_name} to {self.config.model} with base URL {self.config.base_url}'
)
if (
self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS
or self.config.model.split('/')[-1] in REASONING_EFFORT_SUPPORTED_MODELS
@@ -482,24 +491,26 @@ class LLM(RetryMixin, DebugMixin):
)
self.config.top_p = 0.9 if self.config.top_p == 1 else self.config.top_p
# Set the max tokens in an LM-specific way if not set
if self.config.max_input_tokens is None:
if (
self.model_info is not None
and 'max_input_tokens' in self.model_info
and isinstance(self.model_info['max_input_tokens'], int)
):
self.config.max_input_tokens = self.model_info['max_input_tokens']
else:
# Safe fallback for any potentially viable model
self.config.max_input_tokens = 4096
# Set max_input_tokens from model info if not explicitly set
if (
self.config.max_input_tokens is None
and self.model_info is not None
and 'max_input_tokens' in self.model_info
and isinstance(self.model_info['max_input_tokens'], int)
):
self.config.max_input_tokens = self.model_info['max_input_tokens']
# Set max_output_tokens from model info if not explicitly set
if self.config.max_output_tokens is None:
# Safe default for any potentially viable model
self.config.max_output_tokens = 4096
if self.model_info is not None:
# max_output_tokens has precedence over max_tokens, if either exists.
# litellm has models with both, one or none of these 2 parameters!
# Special case for Claude 3.7 Sonnet models
if any(
model in self.config.model
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
):
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
# Try to get from model info
elif self.model_info is not None:
# max_output_tokens has precedence over max_tokens
if 'max_output_tokens' in self.model_info and isinstance(
self.model_info['max_output_tokens'], int
):
@@ -508,11 +519,6 @@ class LLM(RetryMixin, DebugMixin):
self.model_info['max_tokens'], int
):
self.config.max_output_tokens = self.model_info['max_tokens']
if any(
model in self.config.model
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
):
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
# Initialize function calling capability
# Check if model name is in our supported list
+24
View File
@@ -72,6 +72,30 @@ class RetryMixin:
def log_retry_attempt(self, retry_state: Any) -> None:
"""Log retry attempts."""
exception = retry_state.outcome.exception()
# Add retry attempt and max retries to the exception for later use
if hasattr(retry_state, 'retry_object') and hasattr(
retry_state.retry_object, 'stop'
):
# Get the max retries from the stop_after_attempt
stop_condition = retry_state.retry_object.stop
# Handle both single stop conditions and stop_any (combined conditions)
stop_funcs = []
if hasattr(stop_condition, 'stops'):
# This is a stop_any object with multiple stop conditions
stop_funcs = stop_condition.stops
else:
# This is a single stop condition
stop_funcs = [stop_condition]
for stop_func in stop_funcs:
if hasattr(stop_func, 'max_attempts'):
# Add retry information to the exception
exception.retry_attempt = retry_state.attempt_number
exception.max_retries = stop_func.max_attempts
break
logger.error(
f'{exception}. Attempt #{retry_state.attempt_number} | You can customize retry values in the configuration.',
)
-1
View File
@@ -296,7 +296,6 @@ class Memory:
self.knowledge_microagents[name] = agent_knowledge
for name, agent_repo in repo_agents.items():
self.repo_microagents[name] = agent_repo
except Exception as e:
logger.warning(
f'Failed to load user microagents from {USER_MICROAGENTS_DIR}: {str(e)}'
+4
View File
@@ -78,6 +78,10 @@ class BaseMicroagent(BaseModel):
# Handle case where there's no frontmatter or empty frontmatter
metadata_dict = loaded.metadata or {}
# Ensure version is always a string (YAML may parse numeric versions as integers)
if 'version' in metadata_dict and not isinstance(metadata_dict['version'], str):
metadata_dict['version'] = str(metadata_dict['version'])
try:
metadata = MicroagentMetadata(**metadata_dict)
+60 -44
View File
@@ -5,6 +5,7 @@ import dataclasses
import json
import os
import pathlib
import shutil
import subprocess
from argparse import Namespace
from typing import Any
@@ -393,7 +394,7 @@ class IssueResolver:
async def process_issue(
self,
issue: Issue,
branch_to_checkout: str | None,
base_commit: str,
issue_handler: ServiceContextIssue | ServiceContextPR,
reset_logger: bool = False,
) -> ResolverOutput:
@@ -404,45 +405,14 @@ class IssueResolver:
else:
logger.info(f'Starting fixing issue {issue.number}.')
# create runtime and clone repo using standard pattern
# write the repo to the workspace
if os.path.exists(self.workspace_base):
shutil.rmtree(self.workspace_base)
shutil.copytree(os.path.join(self.output_dir, 'repo'), self.workspace_base)
runtime = create_runtime(self.app_config)
await runtime.connect()
# clone repo directly into runtime workspace
from openhands.core.setup import initialize_repository_for_runtime
initialize_repository_for_runtime(runtime, self.issue_handler.get_clone_url())
# checkout to PR branch if needed
if branch_to_checkout:
logger.info(f'Checking out to PR branch {branch_to_checkout}')
# Fetch the branch first to ensure it exists locally
fetch_cmd = ['git', 'fetch', 'origin', branch_to_checkout]
subprocess.check_output(fetch_cmd, cwd=runtime.workspace_root) # noqa: ASYNC101
# Checkout the branch
checkout_cmd = ['git', 'checkout', branch_to_checkout]
subprocess.check_output(checkout_cmd, cwd=runtime.workspace_root) # noqa: ASYNC101
# get the commit id of current repo for reproducibility
base_commit = (
subprocess.check_output(
['git', 'rev-parse', 'HEAD'], cwd=runtime.workspace_root
) # noqa: ASYNC101
.decode('utf-8')
.strip()
)
logger.info(f'Base commit: {base_commit}')
# Check for .openhands_instructions file in the workspace directory
if self.repo_instruction is None:
openhands_instructions_path = os.path.join(
runtime.workspace_root, '.openhands_instructions'
)
if os.path.exists(openhands_instructions_path):
with open(openhands_instructions_path, 'r') as f: # noqa: ASYNC101
self.repo_instruction = f.read()
def on_event(evt: Event) -> None:
logger.info(evt)
@@ -595,10 +565,36 @@ class IssueResolver:
)
logger.info(f'Using output directory: {self.output_dir}')
# repo will be cloned later in process_issue using standard pattern
# base_commit will be captured after cloning
# checkout the repo
repo_dir = os.path.join(self.output_dir, 'repo')
if not os.path.exists(repo_dir):
checkout_output = subprocess.check_output( # noqa: ASYNC101
[
'git',
'clone',
self.issue_handler.get_clone_url(),
f'{self.output_dir}/repo',
]
).decode('utf-8')
if 'fatal' in checkout_output:
raise RuntimeError(f'Failed to clone repository: {checkout_output}')
# .openhands_instructions will be read after repo is cloned in process_issue
# get the commit id of current repo for reproducibility
base_commit = (
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa: ASYNC101
.decode('utf-8')
.strip()
)
logger.info(f'Base commit: {base_commit}')
if self.repo_instruction is None:
# Check for .openhands_instructions file in the workspace directory
openhands_instructions_path = os.path.join(
repo_dir, '.openhands_instructions'
)
if os.path.exists(openhands_instructions_path):
with open(openhands_instructions_path, 'r') as f: # noqa: ASYNC101
self.repo_instruction = f.read()
# OUTPUT FILE
output_file = os.path.join(self.output_dir, 'output.jsonl')
@@ -622,19 +618,39 @@ class IssueResolver:
)
try:
# determine branch to use for PR
branch_to_use = None
# checkout to pr branch if needed
if self.issue_type == 'pr':
branch_to_use = issue.head_branch
logger.info(
f'Will checkout to PR branch {branch_to_use} for issue {issue.number}'
f'Checking out to PR branch {branch_to_use} for issue {issue.number}'
)
if not branch_to_use:
raise ValueError('Branch name cannot be None')
# Fetch the branch first to ensure it exists locally
fetch_cmd = ['git', 'fetch', 'origin', branch_to_use]
subprocess.check_output( # noqa: ASYNC101
fetch_cmd,
cwd=repo_dir,
)
# Checkout the branch
checkout_cmd = ['git', 'checkout', branch_to_use]
subprocess.check_output( # noqa: ASYNC101
checkout_cmd,
cwd=repo_dir,
)
base_commit = (
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa: ASYNC101
.decode('utf-8')
.strip()
)
output = await self.process_issue(
issue,
branch_to_use, # pass branch instead of base_commit
base_commit,
self.issue_handler,
reset_logger,
)
+12 -4
View File
@@ -443,12 +443,18 @@ class Runtime(FileEditRuntimeMixin):
# setup scripts time out after 10 minutes
action = CmdRunAction(
f'chmod +x {setup_script} && source {setup_script}', blocking=True
f'chmod +x {setup_script} && source {setup_script}',
blocking=True,
hidden=True,
)
action.set_hard_timeout(600)
obs = self.run_action(action)
if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0:
self.log('error', f'Setup script failed: {obs.content}')
# Add the action to the event stream as an ENVIRONMENT event
source = EventSource.ENVIRONMENT
self.event_stream.add_event(action, source)
# Execute the action
self.run_action(action)
@property
def workspace_root(self) -> Path:
@@ -585,6 +591,8 @@ fi
loaded_microagents.extend(repo_agents.values())
loaded_microagents.extend(knowledge_agents.values())
except Exception as e:
self.log('error', f'Failed to load agents from {source_description}: {e}')
finally:
shutil.rmtree(microagent_folder)
+1 -1
View File
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik"
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik"
```
#### Additional Kubernetes Options
+2 -2
View File
@@ -230,7 +230,7 @@ class BashSession:
)
self.pane = self.window.active_pane
logger.debug(f'pane: {self.pane}; history_limit: {self.session.history_limit}')
_initial_window.kill_window()
_initial_window.kill()
# Configure bash to use simple PS1 and disable PS2
self.pane.send_keys(
@@ -268,7 +268,7 @@ class BashSession:
"""Clean up the session."""
if self._closed:
return
self.session.kill_session()
self.session.kill()
self._closed = True
@property
+5 -1
View File
@@ -43,7 +43,11 @@ if redis_host:
sio = socketio.AsyncServer(
async_mode='asgi', cors_allowed_origins='*', client_manager=client_manager
async_mode='asgi',
cors_allowed_origins='*',
client_manager=client_manager,
# Increase buffer size to 4MB (to handle 3MB files with base64 overhead)
max_http_buffer_size=4 * 1024 * 1024,
)
MonitoringListenerImpl = get_impl(
Generated
+4 -4
View File
@@ -6497,14 +6497,14 @@ pydantic = ">=1.8"
[[package]]
name = "openhands-aci"
version = "0.3.0"
version = "0.3.1"
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
optional = false
python-versions = "<4.0,>=3.12"
groups = ["main"]
files = [
{file = "openhands_aci-0.3.0-py3-none-any.whl", hash = "sha256:4f060aa1c2ae11a14f08eb72e9bf09f81db916766aab10ccea6df3bc52faafd1"},
{file = "openhands_aci-0.3.0.tar.gz", hash = "sha256:9a154853e9734f38cf103e07a35d59de2660ea409d93fb8462a5df54954cd7d8"},
{file = "openhands_aci-0.3.1-py3-none-any.whl", hash = "sha256:d1d9d5379388bc0119c6722b8dacf63f7c747788ac5b6c26263601b2001d11c3"},
{file = "openhands_aci-0.3.1.tar.gz", hash = "sha256:125c4773b3fd2729ec0c74d005095dad21aa0f7a1e8733e5f33f3f71466f6df9"},
]
[package.dependencies]
@@ -11799,4 +11799,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "de8f45fdd525059f1c021767c7f24ef4eaf9eb00b57772c7017a86ae534c040d"
content-hash = "88202de1a1251cd1e7f7d7edbd3efcd877f828fa081b7048370832127ed5aa8d"
+2 -2
View File
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.47.0"
version = "0.48.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -57,7 +57,7 @@ opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
openhands-aci = "0.3.0"
openhands-aci = "0.3.1"
python-socketio = "^5.11.4"
sse-starlette = "^2.1.3"
psutil = "*"
+805 -13
View File
@@ -1,6 +1,7 @@
"""Browsing-related tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox."""
import os
import re
import pytest
from conftest import _close_test_runtime, _load_runtime
@@ -23,10 +24,104 @@ from openhands.events.observation import (
# ============================================================================================================================
@pytest.mark.skipif(
# Skip all tests in this module for CLI runtime
pytestmark = pytest.mark.skipif(
os.environ.get('TEST_RUNTIME') == 'cli',
reason='CLIRuntime does not support browsing actions',
)
def parse_axtree_content(content: str) -> dict[str, str]:
"""Parse the accessibility tree content to extract bid -> element description mapping."""
elements = {}
current_bid = None
description_lines = []
# Find the accessibility tree section
lines = content.split('\n')
in_axtree = False
for line in lines:
line = line.strip()
# Check if we're entering the accessibility tree section
if 'BEGIN accessibility tree' in line:
in_axtree = True
continue
elif 'END accessibility tree' in line:
break
if not in_axtree or not line:
continue
# Check for bid line format: [bid] element description
bid_match = re.match(r'\[([a-zA-Z0-9]+)\]\s*(.*)', line)
if bid_match:
# Save previous element if it exists
if current_bid and description_lines:
elements[current_bid] = ' '.join(description_lines)
# Start new element
current_bid = bid_match.group(1)
description_lines = [bid_match.group(2).strip()]
else:
# Add to current description if we have a bid
if current_bid:
description_lines.append(line)
# Save last element
if current_bid and description_lines:
elements[current_bid] = ' '.join(description_lines)
return elements
def find_element_by_text(axtree_elements: dict[str, str], text: str) -> str | None:
"""Find an element bid by searching for text in the element description."""
text = text.lower().strip()
for bid, description in axtree_elements.items():
if text in description.lower():
return bid
return None
def find_element_by_id(axtree_elements: dict[str, str], element_id: str) -> str | None:
"""Find an element bid by searching for HTML id attribute."""
for bid, description in axtree_elements.items():
# Look for id="element_id" or id='element_id' patterns
if f'id="{element_id}"' in description or f"id='{element_id}'" in description:
return bid
return None
def find_element_by_tag_and_attributes(
axtree_elements: dict[str, str], tag: str, **attributes
) -> str | None:
"""Find an element bid by tag name and attributes."""
tag = tag.lower()
for bid, description in axtree_elements.items():
description_lower = description.lower()
# Check if this is the right tag
if not description_lower.startswith(tag):
continue
# Check all required attributes
match = True
for attr_name, attr_value in attributes.items():
attr_pattern = f'{attr_name}="{attr_value}"'
if attr_pattern not in description:
attr_pattern = f"{attr_name}='{attr_value}'"
if attr_pattern not in description:
match = False
break
if match:
return bid
return None
def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
@@ -71,10 +166,715 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
_close_test_runtime(runtime)
@pytest.mark.skipif(
os.environ.get('TEST_RUNTIME') == 'cli',
reason='CLIRuntime does not support browsing actions',
)
def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser navigation actions: goto, go_back, go_forward, noop."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create test HTML pages
page1_content = """
<!DOCTYPE html>
<html>
<head><title>Page 1</title></head>
<body>
<h1>Page 1</h1>
<a href="page2.html" id="link-to-page2">Go to Page 2</a>
</body>
</html>
"""
page2_content = """
<!DOCTYPE html>
<html>
<head><title>Page 2</title></head>
<body>
<h1>Page 2</h1>
<a href="page1.html" id="link-to-page1">Go to Page 1</a>
</body>
</html>
"""
# Create HTML files in temp directory
page1_path = os.path.join(temp_dir, 'page1.html')
page2_path = os.path.join(temp_dir, 'page2.html')
with open(page1_path, 'w') as f:
f.write(page1_content)
with open(page2_path, 'w') as f:
f.write(page2_content)
# Copy files to sandbox
sandbox_dir = config.workspace_mount_path_in_sandbox
runtime.copy_to(page1_path, sandbox_dir)
runtime.copy_to(page2_path, sandbox_dir)
# Start HTTP server
action_cmd = CmdRunAction(
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
# Wait for server to start
action_cmd = CmdRunAction(command='sleep 3')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Test goto action
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/page1.html")',
return_axtree=False,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert 'http://localhost:8000/page1.html' in obs.url
# Test noop action (should not change page)
action_browse = BrowseInteractiveAction(
browser_actions='noop(500)', return_axtree=False
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert 'http://localhost:8000/page1.html' in obs.url
# Navigate to page 2
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/page2.html")',
return_axtree=False,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 2' in obs.content
assert 'http://localhost:8000/page2.html' in obs.url
# Test go_back action
action_browse = BrowseInteractiveAction(
browser_actions='go_back()', return_axtree=False
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert 'http://localhost:8000/page1.html' in obs.url
# Test go_forward action
action_browse = BrowseInteractiveAction(
browser_actions='go_forward()', return_axtree=False
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 2' in obs.content
assert 'http://localhost:8000/page2.html' in obs.url
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
finally:
_close_test_runtime(runtime)
def test_browser_form_interactions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser form interaction actions: fill, click, select_option, clear."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test form page
form_content = """
<!DOCTYPE html>
<html>
<head><title>Test Form</title></head>
<body>
<h1>Test Form</h1>
<form id="test-form">
<input type="text" id="text-input" name="text" placeholder="Enter text">
<textarea id="textarea-input" name="message" placeholder="Enter message"></textarea>
<select id="select-input" name="option">
<option value="">Select an option</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
<button type="button" id="test-button">Test Button</button>
<input type="submit" id="submit-button" value="Submit">
</form>
<div id="result"></div>
<script>
document.getElementById('test-button').onclick = function() {
document.getElementById('result').innerHTML = 'Button clicked!';
};
</script>
</body>
</html>
"""
# Create HTML file
form_path = os.path.join(temp_dir, 'form.html')
with open(form_path, 'w') as f:
f.write(form_content)
# Copy to sandbox
sandbox_dir = config.workspace_mount_path_in_sandbox
runtime.copy_to(form_path, sandbox_dir)
# Start HTTP server
action_cmd = CmdRunAction(
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'ACTION'})
assert obs.exit_code == 0
# Wait for server to start
action_cmd = CmdRunAction(command='sleep 3')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Navigate to form page
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/form.html")',
return_axtree=True, # Need axtree to get element bids
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Test Form' in obs.content
# Parse the axtree to get actual bid values
axtree_elements = parse_axtree_content(obs.content)
# Find elements by their characteristics visible in the axtree
text_input_bid = find_element_by_text(axtree_elements, 'Enter text')
textarea_bid = find_element_by_text(axtree_elements, 'Enter message')
select_bid = find_element_by_text(axtree_elements, 'combobox')
button_bid = find_element_by_text(axtree_elements, 'Test Button')
# Verify we found the correct elements
assert text_input_bid is not None, (
f'Could not find text input element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert textarea_bid is not None, (
f'Could not find textarea element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert button_bid is not None, (
f'Could not find button element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert select_bid is not None, (
f'Could not find select element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert text_input_bid != button_bid, (
'Text input bid should be different from button bid'
)
# Test fill action with real bid values
action_browse = BrowseInteractiveAction(
browser_actions=f"""
fill("{text_input_bid}", "Hello World")
fill("{textarea_bid}", "This is a test message")
""".strip(),
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
# Verify the action executed successfully
assert not obs.error, (
f'Browser action failed with error: {obs.last_browser_action_error}'
)
# Parse the updated axtree to verify the text was actually filled
updated_axtree_elements = parse_axtree_content(obs.content)
# Check that the text input now contains our text
assert text_input_bid in updated_axtree_elements, (
f'Text input element {text_input_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
text_input_desc = updated_axtree_elements[text_input_bid]
# The filled value should appear in the element description (axtree shows values differently)
assert 'Hello World' in text_input_desc or "'Hello World'" in text_input_desc, (
f"Text input should contain 'Hello World' but description is: {text_input_desc}"
)
assert textarea_bid in updated_axtree_elements, (
f'Textarea element {textarea_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
textarea_desc = updated_axtree_elements[textarea_bid]
assert (
'This is a test message' in textarea_desc
or "'This is a test message'" in textarea_desc
), f'Textarea should contain test message but description is: {textarea_desc}'
# Test select_option action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'select_option("{select_bid}", "option2")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, (
f'Select option action failed: {obs.last_browser_action_error}'
)
# Verify that option2 is now selected
updated_axtree_elements = parse_axtree_content(obs.content)
assert select_bid in updated_axtree_elements, (
f'Select element {select_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
select_desc = updated_axtree_elements[select_bid]
# The selected option should be reflected in the select element description
assert 'option2' in select_desc or 'Option 2' in select_desc, (
f"Select element should show 'option2' as selected but description is: {select_desc}"
)
# Test click action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'click("{button_bid}")', return_axtree=True
)
obs = runtime.run_action(action_browse)
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Click action failed: {obs.last_browser_action_error}'
# Verify that the button click triggered the JavaScript and updated the result div
updated_axtree_elements = parse_axtree_content(obs.content)
# Look for the "Button clicked!" text that should appear in the result div
result_found = any(
'Button clicked!' in desc for desc in updated_axtree_elements.values()
)
assert result_found, (
f"Button click should have triggered JavaScript to show 'Button clicked!' but not found in: {dict(list(updated_axtree_elements.items())[:10])}"
)
# Test clear action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'clear("{text_input_bid}")', return_axtree=True
)
obs = runtime.run_action(action_browse)
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Clear action failed: {obs.last_browser_action_error}'
# Verify that the text input is now empty/cleared
updated_axtree_elements = parse_axtree_content(obs.content)
assert text_input_bid in updated_axtree_elements
text_input_desc = updated_axtree_elements[text_input_bid]
# After clearing, the input should not contain the previous text
assert 'Hello World' not in text_input_desc, (
f'Text input should be cleared but still contains text: {text_input_desc}'
)
# Check that it's back to showing placeholder text or is empty
assert (
'Enter text' in text_input_desc # placeholder text
or 'textbox' in text_input_desc.lower() # generic textbox description
or text_input_desc.strip() == '' # empty description
), (
f'Cleared text input should show placeholder or be empty but description is: {text_input_desc}'
)
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
finally:
_close_test_runtime(runtime)
def test_browser_interactive_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser interactive actions: scroll, hover, fill, press, focus."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test page with scrollable content
scroll_content = """
<!DOCTYPE html>
<html>
<head>
<title>Scroll Test</title>
<style>
body { margin: 0; padding: 20px; }
.content { height: 2000px; background: linear-gradient(to bottom, #ff0000, #0000ff); }
.hover-target {
width: 200px; height: 100px; background: #ccc; margin: 20px;
border: 2px solid #000; cursor: pointer;
}
.hover-target:hover { background: #ffff00; }
#focus-input { margin: 20px; padding: 10px; font-size: 16px; }
</style>
</head>
<body>
<h1>Interactive Test Page</h1>
<div class="hover-target" id="hover-div">Hover over me</div>
<input type="text" id="focus-input" placeholder="Focus me and type">
<div class="content">
<p>This is a long scrollable page...</p>
<p style="margin-top: 500px;">Middle content</p>
<p style="margin-top: 500px;" id="bottom-content">Bottom content</p>
</div>
</body>
</html>
"""
# Create HTML file
scroll_path = os.path.join(temp_dir, 'scroll.html')
with open(scroll_path, 'w') as f:
f.write(scroll_content)
# Copy to sandbox
sandbox_dir = config.workspace_mount_path_in_sandbox
runtime.copy_to(scroll_path, sandbox_dir)
# Start HTTP server
action_cmd = CmdRunAction(
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
# Wait for server to start
action_cmd = CmdRunAction(command='sleep 3')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Navigate to scroll page
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/scroll.html")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Interactive Test Page' in obs.content
# Test scroll action
action_browse = BrowseInteractiveAction(
browser_actions='scroll(0, 300)', # Scroll down 300 pixels
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Scroll action failed: {obs.last_browser_action_error}'
# Verify the scroll action was recorded correctly
assert 'scroll(0, 300)' in obs.last_browser_action, (
f'Expected scroll action in browser history but got: {obs.last_browser_action}'
)
# Parse the axtree to get actual bid values for interactive elements
axtree_elements = parse_axtree_content(obs.content)
# Find elements by their characteristics visible in the axtree
hover_div_bid = find_element_by_text(axtree_elements, 'Hover over me')
focus_input_bid = find_element_by_text(axtree_elements, 'Focus me and type')
# Verify we found the required elements
assert hover_div_bid is not None, (
f'Could not find hover div element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert focus_input_bid is not None, (
f'Could not find focus input element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
# Test hover action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'hover("{hover_div_bid}")', return_axtree=True
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Hover action failed: {obs.last_browser_action_error}'
# Test focus action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'focus("{focus_input_bid}")', return_axtree=True
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Focus action failed: {obs.last_browser_action_error}'
# Verify that the input element is now focused
assert obs.focused_element_bid == focus_input_bid, (
f'Expected focused element to be {focus_input_bid}, but got {obs.focused_element_bid}'
)
# Test fill action (type in focused input) with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'fill("{focus_input_bid}", "TestValue123")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Fill action failed: {obs.last_browser_action_error}'
# Verify that the text was actually entered
updated_axtree_elements = parse_axtree_content(obs.content)
assert focus_input_bid in updated_axtree_elements, (
f'Focus input element {focus_input_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
input_desc = updated_axtree_elements[focus_input_bid]
assert 'TestValue123' in input_desc or "'TestValue123'" in input_desc, (
f"Input should contain 'TestValue123' but description is: {input_desc}"
)
# Test press action (for pressing individual keys) with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'press("{focus_input_bid}", "Backspace")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Press action failed: {obs.last_browser_action_error}'
# Verify the backspace removed the last character (3 from TestValue123)
updated_axtree_elements = parse_axtree_content(obs.content)
assert focus_input_bid in updated_axtree_elements, (
f'Focus input element {focus_input_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
input_desc = updated_axtree_elements[focus_input_bid]
assert 'TestValue12' in input_desc or "'TestValue12'" in input_desc, (
f"Input should contain 'TestValue12' after backspace but description is: {input_desc}"
)
# Test multiple actions in sequence
action_browse = BrowseInteractiveAction(
browser_actions="""
scroll(0, -200)
noop(1000)
scroll(0, 400)
""".strip(),
return_axtree=False,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, (
f'Multiple actions sequence failed: {obs.last_browser_action_error}'
)
# Verify the last action in the sequence was recorded
assert (
'scroll(0, 400)' in obs.last_browser_action
or 'noop(1000)' in obs.last_browser_action
), f'Expected final action from sequence but got: {obs.last_browser_action}'
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
finally:
_close_test_runtime(runtime)
def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
"""Test browser file upload action."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test file to upload
test_file_content = 'This is a test file for upload testing.'
test_file_path = os.path.join(temp_dir, 'upload_test.txt')
with open(test_file_path, 'w') as f:
f.write(test_file_content)
# Create an upload form page
upload_content = """
<!DOCTYPE html>
<html>
<head><title>File Upload Test</title></head>
<body>
<h1>File Upload Test</h1>
<form enctype="multipart/form-data">
<input type="file" id="file-input" name="file" accept=".txt,.pdf,.png">
<button type="button" onclick="handleUpload()">Upload File</button>
</form>
<div id="upload-result"></div>
<script>
function handleUpload() {
const fileInput = document.getElementById('file-input');
if (fileInput.files.length > 0) {
document.getElementById('upload-result').innerHTML =
'File selected: ' + fileInput.files[0].name;
} else {
document.getElementById('upload-result').innerHTML = 'No file selected';
}
}
</script>
</body>
</html>
"""
# Create HTML file
upload_path = os.path.join(temp_dir, 'upload.html')
with open(upload_path, 'w') as f:
f.write(upload_content)
# Copy files to sandbox
sandbox_dir = config.workspace_mount_path_in_sandbox
runtime.copy_to(upload_path, sandbox_dir)
runtime.copy_to(test_file_path, sandbox_dir)
# Start HTTP server
action_cmd = CmdRunAction(
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
# Wait for server to start
action_cmd = CmdRunAction(command='sleep 3')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Navigate to upload page
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/upload.html")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'File Upload Test' in obs.content
# Parse the axtree to get the file input bid
axtree_elements = parse_axtree_content(obs.content)
# File inputs often show up as buttons in axtree, try multiple strategies
file_input_bid = (
find_element_by_text(axtree_elements, 'Choose File')
or find_element_by_text(axtree_elements, 'No file chosen')
or find_element_by_text(axtree_elements, 'Browse')
or find_element_by_text(axtree_elements, 'file')
or find_element_by_id(axtree_elements, 'file-input')
)
# Also look for button near the file input (Upload File button)
upload_button_bid = find_element_by_text(axtree_elements, 'Upload File')
# Test upload_file action with real bid
assert file_input_bid is not None, (
f'Could not find file input element in axtree. Available elements: {dict(list(axtree_elements.items())[:10])}'
)
action_browse = BrowseInteractiveAction(
browser_actions=f'upload_file("{file_input_bid}", "/workspace/upload_test.txt")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, (
f'File upload action failed: {obs.last_browser_action_error}'
)
# Verify the file input now shows the selected file
updated_axtree_elements = parse_axtree_content(obs.content)
assert file_input_bid in updated_axtree_elements, (
f'File input element {file_input_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
file_input_desc = updated_axtree_elements[file_input_bid]
# File inputs typically show the filename when a file is selected
assert (
'upload_test.txt' in file_input_desc
or 'upload_test' in file_input_desc
or 'txt' in file_input_desc
), f'File input should show selected file but description is: {file_input_desc}'
# Test clicking the upload button to trigger the JavaScript function
if upload_button_bid:
action_browse = BrowseInteractiveAction(
browser_actions=f'click("{upload_button_bid}")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, (
f'Upload button click failed: {obs.last_browser_action_error}'
)
# Check if the JavaScript function executed and updated the result div
final_axtree_elements = parse_axtree_content(obs.content)
# Look for the result text that should be set by JavaScript
result_found = any(
'File selected:' in desc or 'upload_test.txt' in desc
for desc in final_axtree_elements.values()
)
assert result_found, (
f'JavaScript upload handler should have updated the page but no result found in: {dict(list(final_axtree_elements.items())[:10])}'
)
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
finally:
_close_test_runtime(runtime)
def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
@@ -147,10 +947,6 @@ def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
_close_test_runtime(runtime)
@pytest.mark.skipif(
os.environ.get('TEST_RUNTIME') == 'cli',
reason='CLIRuntime does not support browsing actions',
)
def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
@@ -218,10 +1014,6 @@ def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
_close_test_runtime(runtime)
@pytest.mark.skipif(
os.environ.get('TEST_RUNTIME') == 'cli',
reason='CLIRuntime does not support browsing actions',
)
def test_download_file(temp_dir, runtime_cls, run_as_openhands):
"""Test downloading a file using the browser."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
+26
View File
@@ -125,6 +125,12 @@ def mock_config():
)
config.search_api_key = search_api_key_mock
# Mock sandbox with volumes attribute to prevent finalize_config issues
config.sandbox = MagicMock()
config.sandbox.volumes = (
None # This prevents finalize_config from overriding workspace_base
)
return config
@@ -327,7 +333,9 @@ async def test_run_session_with_initial_action(
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_main_without_task(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
@@ -411,7 +419,9 @@ async def test_main_without_task(
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_main_with_task(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
@@ -506,7 +516,9 @@ async def test_main_with_task(
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_main_with_session_name_passes_name_to_run_session(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
@@ -600,7 +612,9 @@ async def test_main_with_session_name_passes_name_to_run_session(
@patch('openhands.cli.main.display_initialization_animation') # Cosmetic
@patch('openhands.cli.main.initialize_repository_for_runtime') # Cosmetic / setup
@patch('openhands.cli.main.display_initial_user_prompt') # Cosmetic
@patch('openhands.cli.main.finalize_config')
async def test_run_session_with_name_attempts_state_restore(
mock_finalize_config,
mock_display_initial_user_prompt,
mock_initialize_repo,
mock_display_init_anim,
@@ -684,11 +698,17 @@ async def test_run_session_with_name_attempts_state_restore(
@patch('openhands.cli.main.setup_config_from_args')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.check_folder_security_agreement')
@patch('openhands.cli.main.read_task')
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_main_security_check_fails(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
mock_read_task,
mock_check_security,
mock_get_settings_store,
mock_setup_config,
@@ -743,7 +763,9 @@ async def test_main_security_check_fails(
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_config_loading_order(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
@@ -841,15 +863,19 @@ async def test_config_loading_order(
@patch('openhands.cli.main.setup_config_from_args')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.check_folder_security_agreement')
@patch('openhands.cli.main.read_task')
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
@patch('builtins.open', new_callable=MagicMock)
async def test_main_with_file_option(
mock_open,
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
mock_read_task,
mock_check_security,
mock_get_settings_store,
mock_setup_config,
+7 -7
View File
@@ -45,7 +45,7 @@ class TestHandleCommands:
async def test_handle_exit_command(self, mock_handle_exit, mock_dependencies):
mock_handle_exit.return_value = True
close_repl, reload_microagents, new_session = await handle_commands(
close_repl, reload_microagents, new_session, _ = await handle_commands(
'/exit', **mock_dependencies
)
@@ -64,7 +64,7 @@ class TestHandleCommands:
async def test_handle_help_command(self, mock_handle_help, mock_dependencies):
mock_handle_help.return_value = (False, False, False)
close_repl, reload_microagents, new_session = await handle_commands(
close_repl, reload_microagents, new_session, _ = await handle_commands(
'/help', **mock_dependencies
)
@@ -78,7 +78,7 @@ class TestHandleCommands:
async def test_handle_init_command(self, mock_handle_init, mock_dependencies):
mock_handle_init.return_value = (True, True)
close_repl, reload_microagents, new_session = await handle_commands(
close_repl, reload_microagents, new_session, _ = await handle_commands(
'/init', **mock_dependencies
)
@@ -96,7 +96,7 @@ class TestHandleCommands:
async def test_handle_status_command(self, mock_handle_status, mock_dependencies):
mock_handle_status.return_value = (False, False, False)
close_repl, reload_microagents, new_session = await handle_commands(
close_repl, reload_microagents, new_session, _ = await handle_commands(
'/status', **mock_dependencies
)
@@ -112,7 +112,7 @@ class TestHandleCommands:
async def test_handle_new_command(self, mock_handle_new, mock_dependencies):
mock_handle_new.return_value = (True, True)
close_repl, reload_microagents, new_session = await handle_commands(
close_repl, reload_microagents, new_session, _ = await handle_commands(
'/new', **mock_dependencies
)
@@ -131,7 +131,7 @@ class TestHandleCommands:
async def test_handle_settings_command(
self, mock_handle_settings, mock_dependencies
):
close_repl, reload_microagents, new_session = await handle_commands(
close_repl, reload_microagents, new_session, _ = await handle_commands(
'/settings', **mock_dependencies
)
@@ -147,7 +147,7 @@ class TestHandleCommands:
async def test_handle_unknown_command(self, mock_dependencies):
user_message = 'Hello, this is not a command'
close_repl, reload_microagents, new_session = await handle_commands(
close_repl, reload_microagents, new_session, _ = await handle_commands(
user_message, **mock_dependencies
)
+7 -2
View File
@@ -253,7 +253,12 @@ class TestCliCommandsPauseResume:
mock_handle_resume.return_value = (False, False)
# Call handle_commands
close_repl, reload_microagents, new_session_requested = await handle_commands(
(
close_repl,
reload_microagents,
new_session_requested,
_,
) = await handle_commands(
message,
event_stream,
usage_metrics,
@@ -275,7 +280,7 @@ class TestCliCommandsPauseResume:
class TestAgentStatePauseResume:
@pytest.mark.asyncio
@patch('openhands.cli.main.display_agent_running_message')
@patch('openhands.cli.main.process_agent_pause')
@patch('openhands.cli.tui.process_agent_pause')
async def test_agent_running_enables_pause(
self, mock_process_agent_pause, mock_display_message
):
+66 -8
View File
@@ -335,34 +335,92 @@ class TestReadConfirmationInput:
assert result == 'always'
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_invalid(self, mock_create_session):
async def test_read_confirmation_input_invalid_then_valid(
self, mock_create_session, mock_print
):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = 'invalid'
# First return invalid input, then valid input
mock_session.prompt_async.side_effect = ['invalid', 'y']
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
assert result == 'yes'
# Verify error message was displayed
error_calls = [
call
for call in mock_print.call_args_list
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
]
assert len(error_calls) > 0
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_empty(self, mock_create_session):
async def test_read_confirmation_input_empty_then_valid(
self, mock_create_session, mock_print
):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = ''
# First return empty input, then valid input
mock_session.prompt_async.side_effect = ['', 'n']
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
# Verify error message was displayed
error_calls = [
call
for call in mock_print.call_args_list
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
]
assert len(error_calls) > 0
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_none(self, mock_create_session):
async def test_read_confirmation_input_none_then_valid(
self, mock_create_session, mock_print
):
mock_session = AsyncMock()
mock_session.prompt_async.return_value = None
# First return None, then valid input
mock_session.prompt_async.side_effect = [None, 'always']
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
assert result == 'always'
# Verify error message was displayed
error_calls = [
call
for call in mock_print.call_args_list
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
]
assert len(error_calls) > 0
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.create_prompt_session')
async def test_read_confirmation_input_multiple_invalid_then_valid(
self, mock_create_session, mock_print
):
mock_session = AsyncMock()
# Multiple invalid inputs, then valid input
mock_session.prompt_async.side_effect = ['invalid1', 'invalid2', 'maybe', 'y']
mock_create_session.return_value = mock_session
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
# Verify error message was displayed multiple times
error_calls = [
call
for call in mock_print.call_args_list
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
]
assert len(error_calls) >= 3 # Should have at least 3 error messages
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_prompt_session')
+56
View File
@@ -0,0 +1,56 @@
import time
from unittest.mock import MagicMock
import pytest
from openhands.cli.commands import handle_commands
from openhands.core.schema.exit_reason import ExitReason
def test_exit_reason_enum_values():
assert ExitReason.INTENTIONAL.value == 'intentional'
assert ExitReason.INTERRUPTED.value == 'interrupted'
assert ExitReason.ERROR.value == 'error'
def test_exit_reason_enum_names():
assert ExitReason['INTENTIONAL'] == ExitReason.INTENTIONAL
assert ExitReason['INTERRUPTED'] == ExitReason.INTERRUPTED
assert ExitReason['ERROR'] == ExitReason.ERROR
def test_exit_reason_str_representation():
assert str(ExitReason.INTENTIONAL) == 'ExitReason.INTENTIONAL'
assert repr(ExitReason.ERROR) == "<ExitReason.ERROR: 'error'>"
@pytest.mark.asyncio
async def test_handle_exit_command_returns_intentional(monkeypatch):
monkeypatch.setattr('openhands.cli.commands.cli_confirm', lambda *a, **k: 0)
mock_usage_metrics = MagicMock()
mock_usage_metrics.session_init_time = time.time() - 3600
mock_usage_metrics.metrics.accumulated_cost = 0.123456
# Mock all token counts used in display formatting
mock_usage_metrics.metrics.accumulated_token_usage.prompt_tokens = 1234
mock_usage_metrics.metrics.accumulated_token_usage.cache_read_tokens = 5678
mock_usage_metrics.metrics.accumulated_token_usage.cache_write_tokens = 9012
mock_usage_metrics.metrics.accumulated_token_usage.completion_tokens = 3456
(
close_repl,
reload_microagents,
new_session_requested,
exit_reason,
) = await handle_commands(
'/exit',
MagicMock(),
mock_usage_metrics,
'test-session',
MagicMock(),
'/tmp/test',
MagicMock(),
)
assert exit_reason == ExitReason.INTENTIONAL
+93 -2
View File
@@ -140,8 +140,8 @@ def test_llm_init_without_model_info(mock_get_model_info, default_config):
mock_get_model_info.side_effect = Exception('Model info not available')
llm = LLM(default_config)
llm.init_model_info()
assert llm.config.max_input_tokens == 4096
assert llm.config.max_output_tokens == 4096
assert llm.config.max_input_tokens is None
assert llm.config.max_output_tokens is None
def test_llm_init_with_custom_config():
@@ -981,3 +981,94 @@ def test_llm_base_url_auto_protocol_patch(mock_get):
called_url = mock_get.call_args[0][0]
assert called_url.startswith('http://') or called_url.startswith('https://')
# Tests for max_output_tokens configuration and usage
def test_unknown_model_token_limits():
"""Test that models without known token limits get None for both max_output_tokens and max_input_tokens."""
# Create LLM instance with a non-existent model to avoid litellm having model info for it
config = LLMConfig(model='non-existent-model', api_key='test_key')
llm = LLM(config)
# Verify max_output_tokens and max_input_tokens are initialized to None (default value)
assert llm.config.max_output_tokens is None
assert llm.config.max_input_tokens is None
def test_max_tokens_from_model_info():
"""Test that max_output_tokens and max_input_tokens are correctly initialized from model info."""
# Create LLM instance with GPT-4 model which has known token limits
config = LLMConfig(model='gpt-4', api_key='test_key')
llm = LLM(config)
# GPT-4 has specific token limits
# These are the expected values from litellm
assert llm.config.max_output_tokens == 4096
assert llm.config.max_input_tokens == 8192
def test_claude_3_7_sonnet_max_output_tokens():
"""Test that Claude 3.7 Sonnet models get the special 64000 max_output_tokens value and default max_input_tokens."""
# Create LLM instance with Claude 3.7 Sonnet model
config = LLMConfig(model='claude-3-7-sonnet', api_key='test_key')
llm = LLM(config)
# Verify max_output_tokens is set to 64000 for Claude 3.7 Sonnet
assert llm.config.max_output_tokens == 64000
# Verify max_input_tokens is set to None (default value)
assert llm.config.max_input_tokens is None
def test_claude_sonnet_4_max_output_tokens():
"""Test that Claude Sonnet 4 models get the correct max_output_tokens and max_input_tokens values."""
# Create LLM instance with a Claude Sonnet 4 model
config = LLMConfig(model='claude-sonnet-4-20250514', api_key='test_key')
llm = LLM(config)
# Verify max_output_tokens is set to the expected value
assert llm.config.max_output_tokens == 64000
# Verify max_input_tokens is set to the expected value
# For Claude models, we expect a specific value from litellm
assert llm.config.max_input_tokens == 200000
def test_sambanova_deepseek_model_max_output_tokens():
"""Test that SambaNova DeepSeek-V3-0324 model gets the correct max_output_tokens value."""
# Create LLM instance with SambaNova DeepSeek model
config = LLMConfig(model='sambanova/DeepSeek-V3-0324', api_key='test_key')
llm = LLM(config)
# SambaNova DeepSeek model has specific token limits
# This is the expected value from litellm
assert llm.config.max_output_tokens == 32768
def test_max_output_tokens_override_in_config():
"""Test that max_output_tokens can be overridden in the config."""
# Create LLM instance with minimal config and overridden max_output_tokens
config = LLMConfig(
model='claude-sonnet-4-20250514', api_key='test_key', max_output_tokens=2048
)
llm = LLM(config)
# Verify the config has the overridden max_output_tokens value
assert llm.config.max_output_tokens == 2048
def test_azure_model_default_max_tokens():
"""Test that Azure models have the default max_output_tokens value."""
# Create minimal config for Azure model (without specifying max_output_tokens)
azure_config = LLMConfig(
model='azure/non-existent-model', # Use a non-existent model to avoid litellm having model info for it
api_key='test_key',
base_url='https://test.openai.azure.com/',
api_version='2024-12-01-preview',
)
# Create LLM instance with Azure model
llm = LLM(azure_config)
# Verify the config has the default max_output_tokens value
assert llm.config.max_output_tokens is None # Default value
+91
View File
@@ -223,6 +223,97 @@ Add proper error handling."""
assert agent.source == str(cursorrules_path)
def test_microagent_version_as_integer():
"""Test loading a microagent with version as integer (reproduces the bug)."""
# Create a microagent with version as an unquoted integer
# This should be parsed as an integer by YAML but converted to string by our code
microagent_content = """---
name: test_agent
type: knowledge
version: 2512312
agent: CodeActAgent
triggers:
- test
---
# Test Agent
This is a test agent with integer version.
"""
test_path = Path('test_agent.md')
# This should not raise an error even though version is an integer in YAML
agent = BaseMicroagent.load(test_path, file_content=microagent_content)
# Verify the agent was loaded correctly
assert isinstance(agent, KnowledgeMicroagent)
assert agent.name == 'test_agent'
assert agent.metadata.version == '2512312' # Should be converted to string
assert isinstance(agent.metadata.version, str) # Ensure it's actually a string
assert agent.type == MicroagentType.KNOWLEDGE
def test_microagent_version_as_float():
"""Test loading a microagent with version as float."""
# Create a microagent with version as an unquoted float
microagent_content = """---
name: test_agent_float
type: knowledge
version: 1.5
agent: CodeActAgent
triggers:
- test
---
# Test Agent Float
This is a test agent with float version.
"""
test_path = Path('test_agent_float.md')
# This should not raise an error even though version is a float in YAML
agent = BaseMicroagent.load(test_path, file_content=microagent_content)
# Verify the agent was loaded correctly
assert isinstance(agent, KnowledgeMicroagent)
assert agent.name == 'test_agent_float'
assert agent.metadata.version == '1.5' # Should be converted to string
assert isinstance(agent.metadata.version, str) # Ensure it's actually a string
assert agent.type == MicroagentType.KNOWLEDGE
def test_microagent_version_as_string_unchanged():
"""Test loading a microagent with version as string (should remain unchanged)."""
# Create a microagent with version as a quoted string
microagent_content = """---
name: test_agent_string
type: knowledge
version: "1.0.0"
agent: CodeActAgent
triggers:
- test
---
# Test Agent String
This is a test agent with string version.
"""
test_path = Path('test_agent_string.md')
# This should work normally
agent = BaseMicroagent.load(test_path, file_content=microagent_content)
# Verify the agent was loaded correctly
assert isinstance(agent, KnowledgeMicroagent)
assert agent.name == 'test_agent_string'
assert agent.metadata.version == '1.0.0' # Should remain as string
assert isinstance(agent.metadata.version, str) # Ensure it's actually a string
assert agent.type == MicroagentType.KNOWLEDGE
@pytest.fixture
def temp_microagents_dir_with_cursorrules():
"""Create a temporary directory with test microagents and .cursorrules file."""
+73
View File
@@ -0,0 +1,73 @@
"""Unit tests for the setup script functionality."""
from unittest.mock import MagicMock, patch
from openhands.events.action import CmdRunAction, FileReadAction
from openhands.events.event import EventSource
from openhands.events.observation import ErrorObservation, FileReadObservation
from openhands.runtime.base import Runtime
def test_maybe_run_setup_script_executes_action():
"""Test that maybe_run_setup_script executes the action after adding it to the event stream."""
# Create mock runtime
runtime = MagicMock(spec=Runtime)
runtime.read.return_value = FileReadObservation(
content="#!/bin/bash\necho 'test'", path='.openhands/setup.sh'
)
# Mock the event stream
runtime.event_stream = MagicMock()
# Add required attributes
runtime.status_callback = None
# Call the actual implementation
with patch.object(
Runtime, 'maybe_run_setup_script', Runtime.maybe_run_setup_script
):
Runtime.maybe_run_setup_script(runtime)
# Verify that read was called with the correct action
runtime.read.assert_called_once_with(FileReadAction(path='.openhands/setup.sh'))
# Verify that add_event was called with the correct action and source
runtime.event_stream.add_event.assert_called_once()
args, kwargs = runtime.event_stream.add_event.call_args
action, source = args
assert isinstance(action, CmdRunAction)
assert source == EventSource.ENVIRONMENT
# Verify that run_action was called with the correct action
runtime.run_action.assert_called_once()
args, kwargs = runtime.run_action.call_args
action = args[0]
assert isinstance(action, CmdRunAction)
assert (
action.command == 'chmod +x .openhands/setup.sh && source .openhands/setup.sh'
)
def test_maybe_run_setup_script_skips_when_file_not_found():
"""Test that maybe_run_setup_script skips execution when the setup script is not found."""
# Create mock runtime
runtime = MagicMock(spec=Runtime)
runtime.read.return_value = ErrorObservation(content='File not found', error_id='')
# Mock the event stream
runtime.event_stream = MagicMock()
# Call the actual implementation
with patch.object(
Runtime, 'maybe_run_setup_script', Runtime.maybe_run_setup_script
):
Runtime.maybe_run_setup_script(runtime)
# Verify that read was called with the correct action
runtime.read.assert_called_once_with(FileReadAction(path='.openhands/setup.sh'))
# Verify that add_event was not called
runtime.event_stream.add_event.assert_not_called()
# Verify that run_action was not called
runtime.run_action.assert_not_called()