mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a78ca768 | |||
| 6da7e051be | |||
| 002e12a049 | |||
| ed58858e03 | |||
| 11ae4f96c2 | |||
| c2acf4e07e | |||
| e9bdf761b7 | |||
| 04b93069b4 | |||
| ec03ce1ca0 | |||
| 46157a85d8 | |||
| a691e3148a | |||
| 4674e0b77a | |||
| d7d0329d25 | |||
| 17853cd5bd | |||
| c992b6d2a0 | |||
| 34bf645d64 | |||
| 1ae1c16b26 | |||
| 5099413729 | |||
| b06a3bdb7c |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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` を実行してください。
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 user’s
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+234
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Generated
+1078
-835
File diff suppressed because it is too large
Load Diff
@@ -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.22.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.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@babel/traverse": "^7.27.7",
|
||||
"@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.8",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
|
||||
@@ -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";
|
||||
@@ -77,6 +78,18 @@ 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[],
|
||||
@@ -167,9 +180,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 +208,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">
|
||||
|
||||
@@ -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,
|
||||
@@ -44,6 +46,11 @@ export function ConversationCardContextMenu({
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onStop && (
|
||||
<ContextMenuListItem testId="stop-button" onClick={onStop}>
|
||||
Stop
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onEdit && (
|
||||
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
|
||||
Edit Title
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
interface ConversationCardProps {
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
onStop?: () => void;
|
||||
onChangeTitle?: (title: string) => void;
|
||||
showOptions?: boolean;
|
||||
isActive?: boolean;
|
||||
@@ -40,6 +41,7 @@ const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onStop,
|
||||
onChangeTitle,
|
||||
showOptions,
|
||||
isActive,
|
||||
@@ -101,6 +103,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();
|
||||
@@ -224,6 +233,11 @@ export function ConversationCard({
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onStop={
|
||||
conversationStatus !== "STOPPED"
|
||||
? onStop && handleStop
|
||||
: undefined
|
||||
}
|
||||
onEdit={onChangeTitle && handleEdit}
|
||||
onDownloadViaVSCode={
|
||||
conversationId && showOptions
|
||||
|
||||
@@ -5,7 +5,9 @@ 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";
|
||||
@@ -22,6 +24,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
|
||||
React.useState(false);
|
||||
const [confirmStopModalVisible, setConfirmStopModalVisible] =
|
||||
React.useState(false);
|
||||
const [
|
||||
confirmExitConversationModalVisible,
|
||||
setConfirmExitConversationModalVisible,
|
||||
@@ -33,12 +37,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 +64,21 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmStop = () => {
|
||||
if (selectedConversationId) {
|
||||
stopConversation(
|
||||
{ conversationId: selectedConversationId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (selectedConversationId === currentConversationId) {
|
||||
navigate("/");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -87,6 +112,7 @@ 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}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
@@ -108,6 +134,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmStopModalVisible && (
|
||||
<ConfirmStopModal
|
||||
onConfirm={() => {
|
||||
handleConfirmStop();
|
||||
setConfirmStopModalVisible(false);
|
||||
}}
|
||||
onCancel={() => setConfirmStopModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmExitConversationModalVisible && (
|
||||
<ExitConversationModal
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -296,6 +296,8 @@ export enum I18nKey {
|
||||
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 +347,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",
|
||||
@@ -611,6 +614,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",
|
||||
|
||||
@@ -4735,6 +4735,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 +5551,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": "設定がリセットされました",
|
||||
@@ -9775,6 +9823,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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
+22
-9
@@ -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)
|
||||
|
||||
+31
-3
@@ -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:
|
||||
@@ -585,6 +588,28 @@ async def read_confirmation_input(config: OpenHandsConfig) -> str:
|
||||
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 +628,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(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ExitReason(Enum):
|
||||
INTENTIONAL = 'intentional'
|
||||
INTERRUPTED = 'interrupted'
|
||||
ERROR = 'error'
|
||||
+18
-21
@@ -482,24 +482,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 +510,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
|
||||
|
||||
@@ -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)}'
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+805
-13
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user