Compare commits

..

3 Commits

Author SHA1 Message Date
openhands
c25795e094 Complete fix for '[Errno 21] Is a directory' errors
- Fixed CLIRuntime.read() to check directories before binary file checks
- Added directory validation to all file reader functions (parse_pdf, parse_docx, parse_latex, parse_audio, parse_pptx, _base64_img)
- Moved directory/existence checks before any file operations to prevent raw errno 21 errors
- Enhanced unit tests to cover all file reader functions
- All functions now return user-friendly error messages instead of raw errno 21

This completely eliminates '[Errno 21] Is a directory' errors from the OpenHands CLI interface.
2025-07-25 19:19:04 +00:00
openhands
7bb84c1d02 Fix 'Is a directory' error in CLI runtime and file readers
- Fixed CLIRuntime.read() to check for directories before attempting file operations
- Added directory validation to file reader functions (parse_latex, parse_audio, _base64_img)
- Moved directory/existence checks before binary file checks for better error handling
- Added comprehensive unit tests for directory error handling
- All functions now return user-friendly error messages instead of raw errno 21 errors

Fixes issue where CLI would show '[Errno 21] Is a directory' when trying to read folders
2025-07-25 19:10:58 +00:00
openhands
a88f8d3851 Fix 'Is a directory' error when CLI tries to read a folder
- Add directory validation in read_file() function in openhands/cli/utils.py
- Add directory validation in read_task_from_file() function in openhands/io/io.py
- Add comprehensive error handling in read_task() function with user-friendly messages
- Add unit tests for directory error handling in both CLI utils and IO modules
- Replace raw errno 21 errors with clear IsADirectoryError messages

Fixes issue where users would see confusing '[Errno 21] Is a directory' errors
when accidentally trying to read a directory as a file.
2025-07-25 19:02:26 +00:00
82 changed files with 2801 additions and 3790 deletions

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
node-version: 22
fail-fast: true
steps:
- name: Checkout

View File

@@ -1,158 +1,59 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
echo "This hook runs selective linting based on changed files."
echo "This hook runs 'make lint' to ensure code quality before committing."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Get the list of staged files
STAGED_FILES=$(git diff --cached --name-only)
# Run make lint to check both frontend and backend code
echo "Running linting checks with 'make lint'..."
make lint
if [ $? -ne 0 ]; then
echo "Linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Linting checks passed!"
fi
# Check if any files match specific patterns
has_frontend_changes=false
has_backend_changes=false
has_vscode_changes=false
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running additional frontend checks..."
# Check each file individually to avoid issues with grep
for file in $STAGED_FILES; do
if [[ $file == frontend/* ]]; then
has_frontend_changes=true
elif [[ $file == openhands/* || $file == evaluation/* || $file == tests/* ]]; then
has_backend_changes=true
# Check for VSCode extension changes (subset of backend changes)
if [[ $file == openhands/integrations/vscode/* ]]; then
has_vscode_changes=true
fi
fi
done
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
echo "Analyzing changes..."
echo "- Frontend changes: $has_frontend_changes"
echo "- Backend changes: $has_backend_changes"
echo "- VSCode extension changes: $has_vscode_changes"
# Run frontend linting if needed
if [ "$has_frontend_changes" = true ]; then
# Check if we're in a CI environment or if frontend dependencies are missing
if [ -n "$CI" ] || ! command -v react-router &> /dev/null || ! command -v vitest &> /dev/null; then
echo "Skipping frontend checks (CI environment or missing dependencies detected)."
echo "WARNING: Frontend files have changed but frontend checks are being skipped."
echo "Please run 'make lint-frontend' manually before submitting your PR."
else
echo "Running frontend linting..."
make lint-frontend
# Run build
echo "Running npm build..."
npm run build
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
echo "Frontend build failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Frontend linting checks passed!"
fi
# Run additional frontend checks
if [ -d "frontend" ]; then
echo "Running additional frontend checks..."
cd frontend || exit 1
# Run build
echo "Running npm build..."
npm run build
if [ $? -ne 0 ]; then
echo "Frontend build failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run tests
echo "Running npm test..."
npm test
if [ $? -ne 0 ]; then
echo "Frontend tests failed. Please fix the failing tests before committing."
EXIT_CODE=1
fi
cd ..
fi
fi
else
echo "Skipping frontend checks (no frontend changes detected)."
fi
# Run backend linting if needed
if [ "$has_backend_changes" = true ]; then
echo "Running backend linting..."
make lint-backend
if [ $? -ne 0 ]; then
echo "Backend linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Backend linting checks passed!"
fi
else
echo "Skipping backend checks (no backend changes detected)."
fi
# Run VSCode extension checks if needed
if [ "$has_vscode_changes" = true ]; then
# Check if we're in a CI environment
if [ -n "$CI" ]; then
echo "Skipping VSCode extension checks (CI environment detected)."
echo "WARNING: VSCode extension files have changed but checks are being skipped."
echo "Please run VSCode extension checks manually before submitting your PR."
else
echo "Running VSCode extension checks..."
if [ -d "openhands/integrations/vscode" ]; then
cd openhands/integrations/vscode || exit 1
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "VSCode extension linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension linting passed!"
fi
echo "Running npm typecheck..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "VSCode extension type checking failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension type checking passed!"
fi
echo "Running npm compile..."
npm run compile
if [ $? -ne 0 ]; then
echo "VSCode extension compilation failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension compilation passed!"
fi
cd ../../..
fi
fi
else
echo "Skipping VSCode extension checks (no VSCode extension changes detected)."
fi
# If no specific code changes detected, run basic checks
if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; then
echo "No specific code changes detected. Running basic checks..."
if [ -n "$STAGED_FILES" ]; then
# Run only basic pre-commit hooks for non-code files
poetry run pre-commit run --files $(echo "$STAGED_FILES" | tr '\n' ' ') --hook-stage commit --config ./dev_config/python/.pre-commit-config.yaml
# Run tests
echo "Running npm test..."
npm test
if [ $? -ne 0 ]; then
echo "Basic checks failed. Please fix the issues before committing."
echo "Frontend tests failed. Please fix the failing tests before committing."
EXIT_CODE=1
else
echo "Basic checks passed!"
fi
# Return to the original directory
cd ..
if [ $EXIT_CODE -eq 0 ]; then
echo "Frontend checks passed!"
fi
else
echo "No files changed. Skipping basic checks."
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping additional frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user

View File

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

View File

@@ -174,7 +174,7 @@ install-python-dependencies:
fi
@echo "$(GREEN)Python dependencies installed successfully.$(RESET)"
install-frontend-dependencies: check-npm check-nodejs
install-frontend-dependencies:
@echo "$(YELLOW)Setting up frontend environment...$(RESET)"
@echo "$(YELLOW)Detect Node.js version...$(RESET)"
@cd frontend && node ./scripts/detect-node-version.js
@@ -182,17 +182,17 @@ install-frontend-dependencies: check-npm check-nodejs
@cd frontend && npm install
@echo "$(GREEN)Frontend dependencies installed successfully.$(RESET)"
install-pre-commit-hooks: check-python check-poetry install-python-dependencies
install-pre-commit-hooks:
@echo "$(YELLOW)Installing pre-commit hooks...$(RESET)"
@git config --unset-all core.hooksPath || true
@poetry run pre-commit install --config $(PRE_COMMIT_CONFIG_PATH)
@echo "$(GREEN)Pre-commit hooks installed successfully.$(RESET)"
lint-backend: install-pre-commit-hooks
lint-backend:
@echo "$(YELLOW)Running linters...$(RESET)"
@poetry run pre-commit run --all-files --show-diff-on-failure --config $(PRE_COMMIT_CONFIG_PATH)
lint-frontend: install-frontend-dependencies
lint-frontend:
@echo "$(YELLOW)Running linters for frontend...$(RESET)"
@cd frontend && npm run lint

View File

@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-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.51
docker.all-hands.dev/all-hands-ai/openhands:0.50
```
> **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.

View File

@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-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.51
docker.all-hands.dev/all-hands-ai/openhands:0.50
```
> **注意**: 如果您在0.44版本之前使用过OpenHands您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。

View File

@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-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.51
docker.all-hands.dev/all-hands-ai/openhands:0.50
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.51-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.50-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.50-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:

View File

@@ -8,29 +8,6 @@ description: This guide walks you through the process of installing OpenHands Cl
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## IP Whitelisting
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow OpenHands to access your repositories:
### Core App IP
```
34.68.58.200
```
### Runtime IPs
```
34.10.175.217
34.136.162.246
34.45.0.142
34.28.69.126
35.224.240.213
34.70.174.52
34.42.4.87
35.222.133.153
34.29.175.97
34.60.55.59
```
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.

View File

@@ -24,7 +24,7 @@ description: This guide walks you through installing the OpenHands Slack app.
**This step is for Slack admins/owners**
1. Make sure you have permissions to install Apps to your workspace.
2. Click the button below to install OpenHands Slack App <a target="_blank" href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope="><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
2. Click the button below to install OpenHands Slack App <a target="_blank" href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
3. In the top right corner, select the workspace to install the OpenHands Slack app.
4. Review permissions and click allow.

View File

@@ -103,7 +103,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.51-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -112,7 +112,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.51 \
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
python -m openhands.cli.main --override-cli-mode true
```

View File

@@ -61,7 +61,7 @@ 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.51-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-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.51 \
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -39,12 +39,6 @@ limits and monitor usage.
- [mistralai/devstral-small](https://www.all-hands.dev/blog/devstral-a-new-state-of-the-art-open-model-for-coding-agents) (20 May 2025) -- also available through [OpenRouter](https://openrouter.ai/mistralai/devstral-small:free)
- [all-hands/openhands-lm-32b-v0.1](https://www.all-hands.dev/blog/introducing-openhands-lm-32b----a-strong-open-coding-agent-model) (31 March 2025) -- also available through [OpenRouter](https://openrouter.ai/all-hands/openhands-lm-32b-v0.1)
### Known Issues
<Warning>
As of July 2025, there are known issues with Gemini 2.5 Pro conversations taking longer than normal with OpenHands. We are continuing to investigate.
</Warning>
<Note>
Most current local and open source models are not as powerful. When using such models, you may see long
wait times between messages, poor responses, or errors about malformed JSON. OpenHands can only be as powerful as the

View File

@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-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.51
docker.all-hands.dev/all-hands-ai/openhands:0.50
```
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.51
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.50
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None

View File

@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-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.51
docker.all-hands.dev/all-hands-ai/openhands:0.50
```
> **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.

View File

@@ -10,7 +10,6 @@ Model Context Protocol (MCP) is a mechanism that allows OpenHands to communicate
servers can provide additional functionality to the agent, such as specialized data processing, external API access,
or custom tools. MCP is based on the open standard defined at [modelcontextprotocol.io](https://modelcontextprotocol.io).
<Note>
MCP is currently not available on OpenHands Cloud. This feature is only available when running OpenHands locally.
</Note>
@@ -36,57 +35,41 @@ MCP configuration can be defined in:
* The OpenHands UI through the Settings under the `MCP` tab.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Examples
#### Recommended: Using Proxy Servers (SSE/HTTP)
For stdio-based MCP servers, we recommend using MCP proxy tools like [`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to HTTP/SSE endpoints:
Start the proxy servers separately:
```bash
# Terminal 1: Filesystem server proxy
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
# Terminal 2: Fetch server proxy
supergateway --stdio "uvx mcp-server-fetch" --port 8081
```
Then configure OpenHands to use the HTTP endpoint:
### Configuration Example via config.toml
```toml
[mcp]
# SSE Servers - Recommended approach using proxy tools
# SSE Servers - External servers that communicate via Server-Sent Events
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SuperGateway proxy for fetch server
"http://localhost:8081/sse",
# External MCP service with authentication
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
# SSE server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
```
# SHTTP Servers - External servers that communicate via Streamable HTTP
shttp_servers = [
# Basic SHTTP server with just a URL
"http://example.com:8080/mcp",
# SHTTP server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
#### Alternative: Direct Stdio Servers (Not Recommended for Production)
```toml
[mcp]
# Direct stdio servers - use only for development/testing
# Stdio Servers - Local processes that communicate via standard input/output
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="filesystem",
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/"],
name="data-processor",
command="python",
args=["-m", "my_mcp_server"],
env={
"DEBUG": "true"
"DEBUG": "true",
"PORT": "8080"
}
}
]
@@ -120,8 +103,6 @@ SHTTP (Streamable HTTP) servers are configured using either a string URL or an o
### Stdio Servers
**Note**: While stdio servers are supported, we recommend using MCP proxies (see above) for better reliability and performance.
Stdio servers are configured using an object with the following properties:
- `name` (required)
@@ -142,39 +123,6 @@ Stdio servers are configured using an object with the following properties:
- Default: `{}`
- Description: Environment variables to set for the server process
#### When to Use Direct Stdio
Direct stdio connections may still be appropriate in these scenarios:
- **Development and testing**: Quick prototyping of MCP servers
- **Simple, single-use tools**: Tools that don't require high reliability or concurrent access
- **Local-only environments**: When you don't want to manage additional proxy processes
For production use, we recommend using proxy tools like SuperGateway.
### Other Proxy Tools
Other options include:
- **Custom FastAPI/Express servers**: Build your own HTTP wrapper around stdio MCP servers
- **Docker-based proxies**: Containerized solutions for better isolation
- **Cloud-hosted MCP services**: Third-party services that provide MCP endpoints
### Troubleshooting MCP Connections
#### Common Issues with Stdio Servers
- **Process crashes**: Stdio processes may crash without proper error handling
- **Deadlocks**: Stdio communication can deadlock under high load
- **Resource leaks**: Zombie processes if not properly managed
- **Debugging difficulty**: Hard to inspect stdio communication
#### Benefits of Using Proxies
- **HTTP status codes**: Clear error reporting via standard HTTP responses
- **Request logging**: Easy to log and monitor HTTP requests
- **Load balancing**: Can distribute requests across multiple server instances
- **Health checks**: HTTP endpoints can provide health status
- **CORS support**: Better integration with web-based tools
## Transport Protocols
OpenHands supports three different MCP transport protocols:

View File

@@ -8,4 +8,4 @@ npx lint-staged
# Run backend pre-commit
echo "Running backend pre-commit..."
cd ..
poetry run pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml

View File

@@ -72,7 +72,6 @@ describe("HomeHeader", () => {
undefined,
undefined,
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId

View File

@@ -209,7 +209,6 @@ describe("RepoConnector", () => {
undefined,
"main",
undefined,
undefined,
);
});

View File

@@ -97,7 +97,6 @@ describe("TaskCard", () => {
},
undefined,
undefined,
undefined,
);
});
});

View File

@@ -1,52 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { time?: string }) => {
const translations: Record<string, string> = {
"MAINTENANCE$SCHEDULED_MESSAGE": `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
};
return translations[key] || key;
},
}),
};
});
describe("MaintenanceBanner", () => {
it("renders maintenance banner with formatted time", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const { container } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument();
// Check if the warning icon (SVG) is present
const svgIcon = container.querySelector('svg');
expect(svgIcon).toBeInTheDocument();
});
it("handles invalid date gracefully", () => {
const invalidTime = "invalid-date";
render(<MaintenanceBanner startTime={invalidTime} />);
// Should still render the banner with the original string
expect(screen.getByText(/Scheduled maintenance will begin at invalid-date/)).toBeInTheDocument();
});
it("formats ISO date string correctly", () => {
const isoTime = "2024-01-15T15:30:00.000Z";
render(<MaintenanceBanner startTime={isoTime} />);
// Should render the banner (exact time format will depend on user's timezone)
expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument();
});
});

View File

@@ -73,73 +73,4 @@ describe("TrajectoryActions", () => {
expect(onExportTrajectory).toHaveBeenCalled();
});
describe("SaaS mode", () => {
it("should only render export button when isSaasMode is true", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const actions = screen.getByTestId("feedback-actions");
// Should not render feedback buttons in SaaS mode
expect(within(actions).queryByTestId("positive-feedback")).toBeNull();
expect(within(actions).queryByTestId("negative-feedback")).toBeNull();
// Should still render export button
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is false", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={false}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is undefined (default behavior)", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onExportTrajectory when export button is clicked in SaaS mode", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});
});

View File

@@ -222,12 +222,10 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the header button is clicked
await userEvent.click(headerLaunchButton);
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
@@ -242,12 +240,10 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the repo button is clicked
await userEvent.click(repoLaunchButton);
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
@@ -262,12 +258,10 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the task button is clicked
await userEvent.click(tasksLaunchButtons[0]);
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
});

View File

@@ -366,17 +366,17 @@ describe("Form submission", () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
await screen.findByTestId("llm-settings-form-advanced");
screen.getByTestId("llm-settings-form-advanced");
const submitButton = await screen.findByTestId("submit-button");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = await screen.findByTestId("llm-custom-model-input");
const baseUrl = await screen.findByTestId("base-url-input");
const apiKey = await screen.findByTestId("llm-api-key-input");
const agent = await screen.findByTestId("agent-input");
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.type(model, "-mini");
@@ -449,7 +449,7 @@ describe("Form submission", () => {
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,21 @@
{
"name": "openhands-frontend",
"version": "0.51.0",
"version": "0.50.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.2",
"@heroui/react": "^2.8.1",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.8.1",
"@stripe/stripe-js": "^7.7.0",
"@stripe/react-stripe-js": "^3.8.0",
"@stripe/stripe-js": "^7.6.1",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
@@ -25,17 +25,17 @@
"axios": "^1.11.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.11",
"framer-motion": "^12.23.9",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"isbot": "^5.1.28",
"jose": "^6.0.12",
"lucide-react": "^0.533.0",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.258.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.6.1",
@@ -88,13 +88,13 @@
"@react-router/dev": "^7.7.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -102,7 +102,7 @@
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.2.3",
"autoprefixer": "^10.4.21",
"cross-env": "^10.0.0",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",

View File

@@ -56,9 +56,6 @@ export interface GetConfigResponse {
HIDE_LLM_SETTINGS: boolean;
HIDE_MICROAGENT_MANAGEMENT?: boolean;
};
MAINTENANCE?: {
startTime: string;
};
}
export interface GetVSCodeUrlResponse {

View File

@@ -232,16 +232,17 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
isSaasMode={config?.APP_MODE === "saas"}
/>
{config?.APP_MODE !== "saas" && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
/>
)}
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{curAgentState === AgentState.RUNNING && <TypingIndicator />}

View File

@@ -1,69 +0,0 @@
import { useTranslation } from "react-i18next";
import { FaTriangleExclamation } from "react-icons/fa6";
interface MaintenanceBannerProps {
startTime: string;
}
export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) {
const { t } = useTranslation();
// Convert EST timestamp to user's local timezone
const formatMaintenanceTime = (estTimeString: string): string => {
try {
// Parse the EST timestamp
// If the string doesn't include timezone info, assume it's EST
let dateToFormat: Date;
if (
estTimeString.includes("T") &&
(estTimeString.includes("-05:00") ||
estTimeString.includes("-04:00") ||
estTimeString.includes("EST") ||
estTimeString.includes("EDT"))
) {
// Already has timezone info
dateToFormat = new Date(estTimeString);
} else {
// Assume EST and convert to UTC for proper parsing
// EST is UTC-5, EDT is UTC-4, but we'll assume EST for simplicity
const estDate = new Date(estTimeString);
if (Number.isNaN(estDate.getTime())) {
throw new Error("Invalid date");
}
dateToFormat = estDate;
}
// Format to user's local timezone
return dateToFormat.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
} catch (error) {
// Fallback to original string if parsing fails
// eslint-disable-next-line no-console
console.warn("Failed to parse maintenance time:", error);
return estTimeString;
}
};
const localTime = formatMaintenanceTime(startTime);
return (
<div className="bg-primary text-[#0D0F11] p-4 rounded">
<div className="flex items-center">
<div className="flex-shrink-0">
<FaTriangleExclamation className="text-white align-middle" />
</div>
<div className="ml-3">
<p className="text-sm font-medium">
{t("MAINTENANCE$SCHEDULED_MESSAGE", { time: localTime })}
</p>
</div>
</div>
</div>
);
}

View File

@@ -8,7 +8,7 @@ export function InstallSlackAppAnchor() {
return (
<a
data-testid="install-slack-app-button"
href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope="
href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"
target="_blank"
rel="noreferrer noopener"
className="py-9"

View File

@@ -9,35 +9,29 @@ interface TrajectoryActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
onExportTrajectory: () => void;
isSaasMode?: boolean;
}
export function TrajectoryActions({
onPositiveFeedback,
onNegativeFeedback,
onExportTrajectory,
isSaasMode = false,
}: TrajectoryActionsProps) {
const { t } = useTranslation();
return (
<div data-testid="feedback-actions" className="flex gap-1">
{!isSaasMode && (
<>
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
/>
</>
)}
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
/>
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}

View File

@@ -1,6 +1,5 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
MAINTENANCE$SCHEDULED_MESSAGE = "MAINTENANCE$SCHEDULED_MESSAGE",
MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND",
MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT",
MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD",

View File

@@ -1,20 +1,4 @@
{
"MAINTENANCE$SCHEDULED_MESSAGE": {
"en": "Scheduled maintenance will begin at {{time}}",
"ja": "予定されたメンテナンスは{{time}}に開始されます",
"zh-CN": "计划维护将于{{time}}开始",
"zh-TW": "計劃維護將於{{time}}開始",
"ko-KR": "예정된 유지보수가 {{time}}에 시작됩니다",
"no": "Planlagt vedlikehold starter {{time}}",
"it": "La manutenzione programmata inizierà alle {{time}}",
"pt": "A manutenção programada começará às {{time}}",
"es": "El mantenimiento programado comenzará a las {{time}}",
"ar": "ستبدأ الصيانة المجدولة في {{time}}",
"fr": "La maintenance programmée commencera à {{time}}",
"tr": "Planlı bakım {{time}} tarihinde başlayacak",
"de": "Die geplante Wartung beginnt um {{time}}",
"uk": "Планове технічне обслуговування розпочнеться о {{time}}"
},
"MICROAGENT$NO_REPOSITORY_FOUND": {
"en": "No repository found to launch microagent",
"ja": "マイクロエージェントを起動するためのリポジトリが見つかりません",

View File

@@ -187,10 +187,6 @@ export const handlers = [
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
},
// Uncomment the following to test the maintenance banner
// MAINTENANCE: {
// startTime: "2024-01-15T10:00:00-05:00", // EST timestamp
// },
};
return HttpResponse.json(config);

View File

@@ -26,7 +26,6 @@ import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
export function ErrorBoundary() {
const error = useRouteError();
@@ -206,9 +205,6 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
{config.data?.MAINTENANCE && (
<MaintenanceBanner startTime={config.data.MAINTENANCE.startTime} />
)}
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>

View File

@@ -17,6 +17,6 @@ export enum AgentState {
export const RUNTIME_INACTIVE_STATES = [
AgentState.INIT,
AgentState.LOADING,
// Removed AgentState.STOPPED to allow tabs to remain visible when agent is stopped
AgentState.STOPPED,
AgentState.ERROR,
];

View File

@@ -43,7 +43,7 @@ describe("RepositorySelectionForm", () => {
});
(useCreateConversation as any).mockReturnValue({
mutate: vi.fn(() => (useIsCreatingConversation as any).mockReturnValue(true)),
mutate: vi.fn(),
isPending: false,
isSuccess: false,
});

View File

@@ -36,7 +36,6 @@
"react": ">=19.1.0",
"react-dom": ">=19.1.0",
"tailwind-merge": "^3.3.1",
"react": ">=19.1.0",
"tailwindcss": "^4.1.10",
},
},
@@ -168,7 +167,7 @@
"@floating-ui/dom": ["@floating-ui/dom@1.7.2", "", { "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" } }, "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA=="],
"@floating-ui/react": ["@floating-ui/react@0.27.14", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.4", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-aSf9JXfyXpRQWMbtuW+CJQrnhzHu4Hg1Th9AkvR1o+wSW/vCUVMrtgXaRY5ToV5Fh5w3I7lXJdvlKVvYrQrppw=="],
"@floating-ui/react": ["@floating-ui/react@0.27.13", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.4", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-Qmj6t9TjgWAvbygNEu1hj4dbHI9CY0ziCMIJrmYoDIn9TUAH5lRmiIeZmRd4c6QEZkzdoH7jNnoNyoY1AIESiA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.4", "", { "dependencies": { "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw=="],
@@ -210,45 +209,45 @@
"@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.46.1", "", { "os": "android", "cpu": "arm" }, "sha512-oENme6QxtLCqjChRUUo3S6X8hjCXnWmJWnedD7VbGML5GUtaOtAyx+fEEXnBXVf0CBZApMQU0Idwi0FmyxzQhw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.45.1", "", { "os": "android", "cpu": "arm" }, "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.46.1", "", { "os": "android", "cpu": "arm64" }, "sha512-OikvNT3qYTl9+4qQ9Bpn6+XHM+ogtFadRLuT2EXiFQMiNkXFLQfNVppi5o28wvYdHL2s3fM0D/MZJ8UkNFZWsw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.45.1", "", { "os": "android", "cpu": "arm64" }, "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.46.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EFYNNGij2WllnzljQDQnlFTXzSJw87cpAs4TVBAWLdkvic5Uh5tISrIL6NRcxoh/b2EFBG/TK8hgRrGx94zD4A=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.45.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.46.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZaNH06O1KeTug9WI2+GRBE5Ujt9kZw4a1+OIwnBHal92I8PxSsl5KpsrPvthRynkhMck4XPdvY0z26Cym/b7oA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.45.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.46.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n4SLVebZP8uUlJ2r04+g2U/xFeiQlw09Me5UFqny8HGbARl503LNH5CqFTb5U5jNxTouhRjai6qPT0CR5c/Iig=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.45.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.46.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8vu9c02F16heTqpvo3yeiu7Vi1REDEC/yES/dIfq3tSXe6mLndiwvYr3AAvd1tMNUqE9yeGYa5w7PRbI5QUV+w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.45.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.46.1", "", { "os": "linux", "cpu": "arm" }, "sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.45.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.46.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.45.1", "", { "os": "linux", "cpu": "arm" }, "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.46.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.45.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.46.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.45.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.46.1", "", { "os": "linux", "cpu": "none" }, "sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.45.1", "", { "os": "linux", "cpu": "none" }, "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.46.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA=="],
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.45.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.46.1", "", { "os": "linux", "cpu": "none" }, "sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.45.1", "", { "os": "linux", "cpu": "none" }, "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.46.1", "", { "os": "linux", "cpu": "none" }, "sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.45.1", "", { "os": "linux", "cpu": "none" }, "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.46.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.45.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.46.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.45.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.46.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.45.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.46.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.45.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.46.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-7Thzy9TMXDw9AU4f4vsLNBxh7/VOKuXi73VH3d/kHGr0tZ3x/ewgL9uC7ojUKmH1/zvmZe2tLapYcZllk3SO8Q=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.45.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.46.1", "", { "os": "win32", "cpu": "x64" }, "sha512-7GVB4luhFmGUNXXJhH2jJwZCFB3pIOixv2E3s17GQHBFUOQaISlt7aGcQgqvCaDSxTZJUzlK/QJ1FN8S94MrzQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.45.1", "", { "os": "win32", "cpu": "x64" }, "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA=="],
"@rushstack/node-core-library": ["@rushstack/node-core-library@5.14.0", "", { "dependencies": { "ajv": "~8.13.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.5.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-eRong84/rwQUlATGFW3TMTYVyqL1vfW9Lf10PH+mVGfIb9HzU3h5AASNIw+axnBLjnD0n3rT5uQBwu9fvzATrg=="],
@@ -310,9 +309,9 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.6.4", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.21", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.6.3", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.21", "redent": "^3.0.0" } }, "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA=="],
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
@@ -376,11 +375,11 @@
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@volar/language-core": ["@volar/language-core@2.4.22", "", { "dependencies": { "@volar/source-map": "2.4.22" } }, "sha512-gp4M7Di5KgNyIyO903wTClYBavRt6UyFNpc5LWfyZr1lBsTUY+QrVZfmbNF2aCyfklBOVk9YC4p+zkwoyT7ECg=="],
"@volar/language-core": ["@volar/language-core@2.4.20", "", { "dependencies": { "@volar/source-map": "2.4.20" } }, "sha512-dRDF1G33xaAIDqR6+mXUIjXYdu9vzSxlMGfMEwBxQsfY/JMUEXSpLTR057oTKlUQ2nIvCmP9k94A8h8z2VrNSA=="],
"@volar/source-map": ["@volar/source-map@2.4.22", "", {}, "sha512-L2nVr/1vei0xKRgO2tYVXtJYd09HTRjaZi418e85Q+QdbbqA8h7bBjfNyPPSsjnrOO4l4kaAo78c8SQUAdHvgA=="],
"@volar/source-map": ["@volar/source-map@2.4.20", "", {}, "sha512-mVjmFQH8mC+nUaVwmbxoYUy8cww+abaO8dWzqPUjilsavjxH0jCJ3Mp8HFuHsdewZs2c+SP+EO7hCd8Z92whJg=="],
"@volar/typescript": ["@volar/typescript@2.4.22", "", { "dependencies": { "@volar/language-core": "2.4.22", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-6ZczlJW1/GWTrNnkmZxJp4qyBt/SGVlcTuCWpI5zLrdPdCZsj66Aff9ZsfFaT3TyjG8zVYgBMYPuCm/eRkpcpQ=="],
"@volar/typescript": ["@volar/typescript@2.4.20", "", { "dependencies": { "@volar/language-core": "2.4.20", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Oc4DczPwQyXcVbd+5RsNEqX6ia0+w3p+klwdZQ6ZKhFjWoBP9PCPQYlKYRi/tDemWphW93P/Vv13vcE9I9D2GQ=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.18", "", { "dependencies": { "@babel/parser": "^7.28.0", "@vue/shared": "3.5.18", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw=="],
@@ -404,7 +403,7 @@
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@@ -448,6 +447,8 @@
"chai": ["chai@5.2.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -846,7 +847,7 @@
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.46.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.1", "@rollup/rollup-android-arm64": "4.46.1", "@rollup/rollup-darwin-arm64": "4.46.1", "@rollup/rollup-darwin-x64": "4.46.1", "@rollup/rollup-freebsd-arm64": "4.46.1", "@rollup/rollup-freebsd-x64": "4.46.1", "@rollup/rollup-linux-arm-gnueabihf": "4.46.1", "@rollup/rollup-linux-arm-musleabihf": "4.46.1", "@rollup/rollup-linux-arm64-gnu": "4.46.1", "@rollup/rollup-linux-arm64-musl": "4.46.1", "@rollup/rollup-linux-loongarch64-gnu": "4.46.1", "@rollup/rollup-linux-ppc64-gnu": "4.46.1", "@rollup/rollup-linux-riscv64-gnu": "4.46.1", "@rollup/rollup-linux-riscv64-musl": "4.46.1", "@rollup/rollup-linux-s390x-gnu": "4.46.1", "@rollup/rollup-linux-x64-gnu": "4.46.1", "@rollup/rollup-linux-x64-musl": "4.46.1", "@rollup/rollup-win32-arm64-msvc": "4.46.1", "@rollup/rollup-win32-ia32-msvc": "4.46.1", "@rollup/rollup-win32-x64-msvc": "4.46.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ=="],
"rollup": ["rollup@4.45.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.45.1", "@rollup/rollup-android-arm64": "4.45.1", "@rollup/rollup-darwin-arm64": "4.45.1", "@rollup/rollup-darwin-x64": "4.45.1", "@rollup/rollup-freebsd-arm64": "4.45.1", "@rollup/rollup-freebsd-x64": "4.45.1", "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", "@rollup/rollup-linux-arm-musleabihf": "4.45.1", "@rollup/rollup-linux-arm64-gnu": "4.45.1", "@rollup/rollup-linux-arm64-musl": "4.45.1", "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-musl": "4.45.1", "@rollup/rollup-linux-s390x-gnu": "4.45.1", "@rollup/rollup-linux-x64-gnu": "4.45.1", "@rollup/rollup-linux-x64-musl": "4.45.1", "@rollup/rollup-win32-arm64-msvc": "4.45.1", "@rollup/rollup-win32-ia32-msvc": "4.45.1", "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@@ -1040,6 +1041,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
@@ -1058,6 +1061,8 @@
"pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -1074,8 +1079,6 @@
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],

View File

@@ -1,4 +1,170 @@
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap");
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Outfit:wght@100..900&display=swap");
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Outfit:wght@100..900&display=swap');
@import "tailwindcss";
@import "./tokens.css";
@plugin 'tailwind-scrollbar' {
nocompatible: true;
preferredStrategy: 'pseudoelements';
}
@theme {
/* COLOR VARIABLES */
--color-primary-15: #FFFCF0;
--color-primary-30: #FFF9E1;
--color-primary-50: #FFF7D7;
--color-primary-100: #FFF3C0;
--color-primary-200: #FFEEAA;
--color-primary-300: #FFEA92;
--color-primary-400: #FFE57B;
--color-primary-500: #FFE165;
--color-primary-600: #DCC257;
--color-primary-700: #BBA54A;
--color-primary-800: #99873D;
--color-primary-900: #76682F;
--color-primary-950: #534921;
--color-primary-970: #433B1B;
--color-primary-985: #2D2812;
/* Light Neutral */
--color-light-neutral-15: #F7F8FB;
--color-light-neutral-30: #F0F2F7;
--color-light-neutral-50: #EBEDF3;
--color-light-neutral-100: #DFE2ED;
--color-light-neutral-200: #D4D8E7;
--color-light-neutral-300: #C8CDE0;
--color-light-neutral-400: #BCC3D9;
--color-light-neutral-500: #B1B9D3;
--color-light-neutral-600: #99A0B6;
--color-light-neutral-700: #82889B;
--color-light-neutral-800: #6A6F7F;
--color-light-neutral-900: #525662;
--color-light-neutral-950: #3A3C45;
--color-light-neutral-970: #2F3137;
--color-light-neutral-985: #1F2125;
/* Grey */
--color-grey-15: #EDEDEF;
--color-grey-30: #DCDDDF;
--color-grey-50: #CFD0D3;
--color-grey-100: #B5B6BA;
--color-grey-200: #9A9CA2;
--color-grey-300: #7E8088;
--color-grey-400: #63666F;
--color-grey-500: #494C57;
--color-grey-600: #3F424B;
--color-grey-700: #363840;
--color-grey-800: #2C2E34;
--color-grey-900: #222328;
--color-grey-950: #18191C;
--color-grey-970: #131417;
--color-grey-985: #0D0D0F;
/* Green */
--color-green-15: #F8FFF4;
--color-green-30: #F2FFE9;
--color-green-50: #EDFFE1;
--color-green-100: #E4FFD0;
--color-green-200: #DAFFBF;
--color-green-300: #CFFFAD;
--color-green-400: #C6FF9D;
--color-green-500: #BCFF8C;
--color-green-600: #A2DC79;
--color-green-700: #8ABB67;
--color-green-800: #719954;
--color-green-900: #577641;
--color-green-950: #3D532E;
--color-green-970: #314325;
--color-green-985: #212D19;
/* Aqua */
--color-aqua-15: #F4FFFE;
--color-aqua-30: #E9FFFE;
--color-aqua-50: #E1FFFD;
--color-aqua-100: #D1FFFD;
--color-aqua-200: #C0FFFC;
--color-aqua-300: #AEFFFB;
--color-aqua-400: #9EFFFA;
--color-aqua-500: #8DFFF9;
--color-aqua-600: #7ADCD7;
--color-aqua-700: #67BBB7
--color-aqua-800: #559995;
--color-aqua-900: #417673;
--color-aqua-950: #2E5351;
--color-aqua-970: #254341;
--color-aqua-985: #192D2C;
/* Red */
--color-red-15: #FFF0EE;
--color-red-30: #FFE2DD;
--color-red-50: #FFD7D0;
--color-red-100: #FFC1B7;
--color-red-200: #FFAC9D;
--color-red-300: #FF9481;
--color-red-400: #FF7E68;
--color-red-500: #FF684E;
--color-red-600: #DC5A43;
--color-red-700: #BB4C39;
--color-red-800: #993E2F;
--color-red-900: #763024;
--color-red-950: #532219;
--color-red-970: #431B14;
--color-red-985: #2D120E;
/* OpacityBlue */
--color-blue: #DCE5FF;
/* TYPOGRAPHY VARIABLES */
--font-size-xxs: 0.75rem; /* 12px */
--font-size-xs: 0.875rem; /* 14px */
--font-size-s: 1rem; /* 16px */
--font-size-m: 1.125rem; /* 18px */
--font-size-l: 1.5rem; /* 24px */
--font-size-xl: 2rem; /* 32px */
--font-size-xxl: 2.25rem; /* 36px */
--font-size-xxxl: 3rem; /* 48px */
}
@layer utilities {
.tg-family-outfit {
font-family: Outfit;
}
.tg-family-ibm-plex {
font-family: IBM Plex Mono
}
.tg-xxs {
font-size: var(--font-size-xxs);
}
.tg-xs {
font-size: var(--font-size-xs);
}
.tg-s {
font-size: var(--font-size-s);
}
.tg-m {
font-size: var(--font-size-m);
}
.tg-lg {
font-size: var(--font-size-l);
}
.tg-xl {
font-size: var(--font-size-xl);
}
.tg-xxl {
font-size: var(--font-size-xxl);
}
.tg-xxxl {
font-size: var(--font-size-xxxl);
}
}

View File

@@ -16,3 +16,6 @@ export { Toggle } from "./components/toggle/Toggle";
export { Tabs } from "./components/tabs/Tabs";
export { Tooltip } from "./components/tooltip/Tooltip";
export { Typography } from "./components/typography/Typography";
// Styles
import "./index.css";

View File

@@ -14,7 +14,7 @@
"email": "stephan@all-hands.dev"
}
],
"version": "1.0.0-beta.9",
"version": "1.0.0-beta.8",
"description": "OpenHands UI Components",
"keywords": [
"openhands",
@@ -74,9 +74,7 @@
"react": ">=19.1.0",
"react-dom": ">=19.1.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"tailwind-scrollbar": "^4.0.2"
"tailwindcss": "^4.1.10"
},
"dependencies": {
"@floating-ui/react": "^0.27.12",

View File

@@ -1,175 +0,0 @@
@theme {
/* COLOR VARIABLES */
--color-primary-15: #FFFCF0;
--color-primary-30: #FFF9E1;
--color-primary-50: #FFF7D7;
--color-primary-100: #FFF3C0;
--color-primary-200: #FFEEAA;
--color-primary-300: #FFEA92;
--color-primary-400: #FFE57B;
--color-primary-500: #FFE165;
--color-primary-600: #DCC257;
--color-primary-700: #BBA54A;
--color-primary-800: #99873D;
--color-primary-900: #76682F;
--color-primary-950: #534921;
--color-primary-970: #433B1B;
--color-primary-985: #2D2812;
/* Light Neutral */
--color-light-neutral-15: #F7F8FB;
--color-light-neutral-30: #F0F2F7;
--color-light-neutral-50: #EBEDF3;
--color-light-neutral-100: #DFE2ED;
--color-light-neutral-200: #D4D8E7;
--color-light-neutral-300: #C8CDE0;
--color-light-neutral-400: #BCC3D9;
--color-light-neutral-500: #B1B9D3;
--color-light-neutral-600: #99A0B6;
--color-light-neutral-700: #82889B;
--color-light-neutral-800: #6A6F7F;
--color-light-neutral-900: #525662;
--color-light-neutral-950: #3A3C45;
--color-light-neutral-970: #2F3137;
--color-light-neutral-985: #1F2125;
/* Grey */
--color-grey-15: #EDEDEF;
--color-grey-30: #DCDDDF;
--color-grey-50: #CFD0D3;
--color-grey-100: #B5B6BA;
--color-grey-200: #9A9CA2;
--color-grey-300: #7E8088;
--color-grey-400: #63666F;
--color-grey-500: #494C57;
--color-grey-600: #3F424B;
--color-grey-700: #363840;
--color-grey-800: #2C2E34;
--color-grey-900: #222328;
--color-grey-950: #18191C;
--color-grey-970: #131417;
--color-grey-985: #0D0D0F;
/* Green */
--color-green-15: #F8FFF4;
--color-green-30: #F2FFE9;
--color-green-50: #EDFFE1;
--color-green-100: #E4FFD0;
--color-green-200: #DAFFBF;
--color-green-300: #CFFFAD;
--color-green-400: #C6FF9D;
--color-green-500: #BCFF8C;
--color-green-600: #A2DC79;
--color-green-700: #8ABB67;
--color-green-800: #719954;
--color-green-900: #577641;
--color-green-950: #3D532E;
--color-green-970: #314325;
--color-green-985: #212D19;
/* Aqua */
--color-aqua-15: #F4FFFE;
--color-aqua-30: #E9FFFE;
--color-aqua-50: #E1FFFD;
--color-aqua-100: #D1FFFD;
--color-aqua-200: #C0FFFC;
--color-aqua-300: #AEFFFB;
--color-aqua-400: #9EFFFA;
--color-aqua-500: #8DFFF9;
--color-aqua-600: #7ADCD7;
--color-aqua-700: #67BBB7;
--color-aqua-800: #559995;
--color-aqua-900: #417673;
--color-aqua-950: #2E5351;
--color-aqua-970: #254341;
--color-aqua-985: #192D2C;
/* Red */
--color-red-15: #FFF0EE;
--color-red-30: #FFE2DD;
--color-red-50: #FFD7D0;
--color-red-100: #FFC1B7;
--color-red-200: #FFAC9D;
--color-red-300: #FF9481;
--color-red-400: #FF7E68;
--color-red-500: #FF684E;
--color-red-600: #DC5A43;
--color-red-700: #BB4C39;
--color-red-800: #993E2F;
--color-red-900: #763024;
--color-red-950: #532219;
--color-red-970: #431B14;
--color-red-985: #2D120E;
/* OpacityBlue */
--color-blue: #DCE5FF;
/* TYPOGRAPHY VARIABLES */
--font-size-xxs: 0.75rem; /* 12px */
--font-size-xs: 0.875rem; /* 14px */
--font-size-s: 1rem; /* 16px */
--font-size-m: 1.125rem; /* 18px */
--font-size-l: 1.5rem; /* 24px */
--font-size-xl: 2rem; /* 32px */
--font-size-xxl: 2.25rem; /* 36px */
--font-size-xxxl: 3rem; /* 48px */
/* OLD TAILWIND STYLES */
--color-primary: #C9B974;
--color-logo: #CFB755;
--color-base: #0D0F11;
--color-base-secondary: #24272E;
--color-danger: #E76A5E;
--color-success: #A5E75E;
--color-basic: #9099AC;
--color-tertiary: #454545;
--color-tertiary-light: #B7BDC2;
--color-content: #ECEDEE;
--color-content-2: #F9FBFE;
}
@layer utilities {
.tg-family-outfit {
font-family: Outfit;
}
.tg-family-ibm-plex {
font-family: IBM Plex Mono
}
.tg-xxs {
font-size: var(--font-size-xxs);
}
.tg-xs {
font-size: var(--font-size-xs);
}
.tg-s {
font-size: var(--font-size-s);
}
.tg-m {
font-size: var(--font-size-m);
}
.tg-lg {
font-size: var(--font-size-l);
}
.tg-xl {
font-size: var(--font-size-xl);
}
.tg-xxl {
font-size: var(--font-size-xxl);
}
.tg-xxxl {
font-size: var(--font-size-xxxl);
}
}

View File

@@ -15,10 +15,7 @@ export default defineConfig({
],
build: {
lib: {
entry: {
index: resolve(__dirname, "index.ts"),
tokens: resolve(__dirname, "tokens.css"),
},
entry: resolve(__dirname, "index.ts"),
name: "OpenHandsUI",
formats: ["es"],
fileName: "index",
@@ -32,6 +29,6 @@ export default defineConfig({
},
},
},
cssCodeSplit: true,
cssCodeSplit: false, // Bundle all CSS into a single index.css file
},
});

View File

@@ -1,5 +1,4 @@
import os
from pathlib import Path
__package_name__ = 'openhands_ai'
@@ -8,16 +7,10 @@ def get_version():
# Try getting the version from pyproject.toml
try:
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
candidate_paths = [
Path(root_dir) / 'pyproject.toml',
Path(root_dir) / 'openhands' / 'pyproject.toml',
]
for file_path in candidate_paths:
if file_path.is_file():
with open(file_path, 'r') as f:
for line in f:
if line.strip().startswith('version ='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
with open(os.path.join(root_dir, 'pyproject.toml'), 'r') as f:
for line in f:
if line.startswith('version ='):
return line.split('=')[1].strip().strip('"')
except FileNotFoundError:
pass

View File

@@ -31,7 +31,7 @@ Your primary role is to assist users by executing commands, modifying code, and
</CODE_QUALITY>
<VERSION_CONTROL>
* When committing changes, you MUST use the `--author` flag to set the author to `"openhands <openhands@all-hands.dev>"`. For example: `git commit --author="openhands <openhands@all-hands.dev>" -m "Fix bug"`. This ensures all commits are attributed to the OpenHands agent, regardless of the local git config.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.

View File

@@ -25,7 +25,7 @@ Your primary role is to assist users by executing commands, modifying code, and
</CODE_QUALITY>
<VERSION_CONTROL>
* When committing changes, you MUST use the `--author` flag to set the author to `"openhands <openhands@all-hands.dev>"`. For example: `git commit --author="openhands <openhands@all-hands.dev>" -m "Fix bug"`. This ensures all commits are attributed to the OpenHands agent, regardless of the local git config.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.

View File

@@ -25,7 +25,7 @@ Your primary role is to assist users by executing commands, modifying code, and
</CODE_QUALITY>
<VERSION_CONTROL>
* When committing changes, you MUST use the `--author` flag to set the author to `"openhands <openhands@all-hands.dev>"`. For example: `git commit --author="openhands <openhands@all-hands.dev>" -m "Fix bug"`. This ensures all commits are attributed to the OpenHands agent, regardless of the local git config.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.

View File

@@ -352,23 +352,10 @@ async def run_session(
if initial_state.last_error:
# If the last session ended in an error, provide a message.
error_message = initial_state.last_error
# Check if it's an authentication error
if 'ERROR_LLM_AUTHENTICATION' in error_message:
# Start with base authentication error message
initial_message = 'Authentication error with the LLM provider. Please check your API key.'
# Add OpenHands-specific guidance if using an OpenHands model
llm_config = config.get_llm_config()
if llm_config.model.startswith('openhands/'):
initial_message += " If you're using OpenHands models, get a new API key from https://app.all-hands.dev/settings/api-keys"
else:
# For other errors, use the standard message
initial_message = (
'NOTE: the last session ended with an error.'
"Let's get back on track. Do NOT resume your task. Ask me about it."
)
initial_message = (
'NOTE: the last session ended with an error.'
"Let's get back on track. Do NOT resume your task. Ask me about it."
)
else:
# If we are resuming, we already have a task
initial_message = ''
@@ -558,30 +545,14 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
# Use settings from settings store if available and override with command line arguments
if settings:
# Handle agent configuration
if args.agent_cls:
config.default_agent = str(args.agent_cls)
else:
# settings.agent is not None because we check for it in setup_config_from_args
assert settings.agent is not None
config.default_agent = settings.agent
# Handle LLM configuration with proper precedence:
# 1. CLI parameters (-l) have highest precedence (already handled in setup_config_from_args)
# 2. config.toml in current directory has next highest precedence (already loaded)
# 3. ~/.openhands/settings.json has lowest precedence (handled here)
# Only apply settings from settings.json if:
# - No LLM config was specified via CLI arguments (-l)
# - The current LLM config doesn't have model or API key set (indicating it wasn't loaded from config.toml)
llm_config = config.get_llm_config()
if (
not args.llm_config
and (not llm_config.model or not llm_config.api_key)
and settings.llm_model
and settings.llm_api_key
):
logger.debug('Using LLM configuration from settings.json')
if not args.llm_config and settings.llm_model and settings.llm_api_key:
llm_config = config.get_llm_config()
llm_config.model = settings.llm_model
llm_config.api_key = settings.llm_api_key
llm_config.base_url = settings.llm_base_url

View File

@@ -231,6 +231,26 @@ def split_is_actually_version(split: list[str]) -> bool:
def read_file(file_path: str | Path) -> str:
"""Read content from a file.
Args:
file_path: Path to the file to read
Returns:
str: Content of the file
Raises:
IsADirectoryError: If the path is a directory
FileNotFoundError: If the file doesn't exist
PermissionError: If there are permission issues
"""
from pathlib import Path
path = Path(file_path)
if path.is_dir():
raise IsADirectoryError(f"'{file_path}' is a directory, not a file")
with open(file_path, 'r') as f:
return f.read()

View File

@@ -43,7 +43,7 @@ class LLMConfig(BaseModel):
log_completions_folder: The folder to log LLM completions to. Required if log_completions is True.
custom_tokenizer: A custom tokenizer to use for token counting.
native_tool_calling: Whether to use native tool calling if supported by the model. Can be True, False, or not set.
reasoning_effort: The effort to put into reasoning. This is a string that can be one of 'low', 'medium', 'high', or 'none'. Can apply to all reasoning models.
reasoning_effort: The effort to put into reasoning. This is a string that can be one of 'low', 'medium', 'high', or 'none'. Exclusive for o1 models.
seed: The seed to use for the LLM.
safety_settings: Safety settings for models that support them (like Mistral AI and Gemini).
"""
@@ -85,7 +85,7 @@ class LLMConfig(BaseModel):
log_completions_folder: str = Field(default=os.path.join(LOG_DIR, 'completions'))
custom_tokenizer: str | None = Field(default=None)
native_tool_calling: bool | None = Field(default=None)
reasoning_effort: str | None = Field(default=None)
reasoning_effort: str | None = Field(default='high')
seed: int | None = Field(default=None)
safety_settings: list[dict[str, str]] | None = Field(
default=None,
@@ -171,14 +171,6 @@ class LLMConfig(BaseModel):
if self.openrouter_app_name:
os.environ['OR_APP_NAME'] = self.openrouter_app_name
# Set reasoning_effort to 'high' by default for non-Gemini models
# Gemini models use optimized thinking budget when reasoning_effort is None
logger.debug(
f'Setting reasoning_effort for model {self.model} with reasoning_effort {self.reasoning_effort}'
)
if self.reasoning_effort is None and 'gemini-2.5-pro' not in self.model:
self.reasoning_effort = 'high'
# Set an API version by default for Azure models
# Required for newer models.
# Azure issue: https://github.com/All-Hands-AI/OpenHands/issues/7755

View File

@@ -337,7 +337,7 @@ def finalize_config(cfg: OpenHandsConfig) -> None:
if cfg.workspace_base is not None or cfg.workspace_mount_path is not None:
logger.openhands_logger.warning(
'DEPRECATED: The WORKSPACE_BASE and WORKSPACE_MOUNT_PATH environment variables are deprecated. '
"Please use SANDBOX_VOLUMES instead, e.g. 'SANDBOX_VOLUMES=/my/host/dir:/workspace:rw'"
"Please use RUNTIME_MOUNT instead, e.g. 'RUNTIME_MOUNT=/my/host/dir:/workspace:rw'"
)
if cfg.sandbox.volumes is not None:
# Split by commas to handle multiple mounts
@@ -515,14 +515,7 @@ def get_llm_config_arg(
if llm_config_arg.startswith('llm.'):
llm_config_arg = llm_config_arg[4:]
logger.openhands_logger.debug(
f'Loading llm config "{llm_config_arg}" from {toml_file}'
)
# Check if the file exists
if not os.path.exists(toml_file):
logger.openhands_logger.debug(f'Config file not found: {toml_file}')
return None
logger.openhands_logger.debug(f'Loading llm config from {llm_config_arg}')
# load the toml file
try:
@@ -540,10 +533,7 @@ def get_llm_config_arg(
# update the llm config with the specified section
if 'llm' in toml_config and llm_config_arg in toml_config['llm']:
return LLMConfig(**toml_config['llm'][llm_config_arg])
logger.openhands_logger.debug(
f'LLM config "{llm_config_arg}" not found in {toml_file}'
)
logger.openhands_logger.debug(f'Loading from toml failed for {llm_config_arg}')
return None
@@ -851,52 +841,20 @@ def setup_config_from_args(args: argparse.Namespace) -> OpenHandsConfig:
"""Load config from toml and override with command line arguments.
Common setup used by both CLI and main.py entry points.
Configuration precedence (from highest to lowest):
1. CLI parameters (e.g., -l for LLM config)
2. config.toml in current directory (or --config-file location if specified)
3. ~/.openhands/settings.json and ~/.openhands/config.toml
"""
# Load base config from toml and env vars
config = load_openhands_config(config_file=args.config_file)
# Override with command line arguments if provided
if args.llm_config:
logger.openhands_logger.debug(f'CLI specified LLM config: {args.llm_config}')
# Check if the LLM config is NOT in the loaded configs
# if we didn't already load it, get it from the toml file
if args.llm_config not in config.llms:
# Try to load from the specified config file
llm_config = get_llm_config_arg(args.llm_config, args.config_file)
# If not found in the specified config file, try the user's config.toml
if llm_config is None and args.config_file != os.path.join(
os.path.expanduser('~'), '.openhands', 'config.toml'
):
user_config = os.path.join(
os.path.expanduser('~'), '.openhands', 'config.toml'
)
if os.path.exists(user_config):
logger.openhands_logger.debug(
f"Trying to load LLM config '{args.llm_config}' from user config: {user_config}"
)
llm_config = get_llm_config_arg(args.llm_config, user_config)
llm_config = get_llm_config_arg(args.llm_config)
else:
# If it's already in the loaded configs, use that
llm_config = config.llms[args.llm_config]
logger.openhands_logger.debug(
f"Using LLM config '{args.llm_config}' from loaded configuration"
)
if llm_config is None:
raise ValueError(
f"Cannot find LLM configuration '{args.llm_config}' in any config file"
)
# Set this as the default LLM config (highest precedence)
raise ValueError(f'Invalid toml file, cannot read {args.llm_config}')
config.set_llm_config(llm_config)
logger.openhands_logger.debug(
f'Set LLM config from CLI parameter: {args.llm_config}'
)
# Override default agent if provided
if args.agent_cls:

View File

@@ -1,7 +1,5 @@
import os
from openhands.core.config.agent_config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.utils.import_utils import get_impl
@@ -13,15 +11,6 @@ class ExperimentManager:
) -> ConversationInitData:
return conversation_settings
@staticmethod
def run_agent_config_variant_test(
user_id: str, conversation_id: str, agent_config: AgentConfig
) -> AgentConfig:
logger.debug(
f'Running agent config variant test for user_id={user_id}, conversation_id={conversation_id}'
)
return agent_config
experiment_manager_cls = os.environ.get(
'OPENHANDS_EXPERIMENT_MANAGER_CLS',

View File

@@ -18,7 +18,24 @@ def read_input(cli_multiline_input: bool = False) -> str:
def read_task_from_file(file_path: str) -> str:
"""Read task from the specified file."""
"""Read task from the specified file.
Args:
file_path: Path to the file to read
Returns:
str: Content of the file
Raises:
IsADirectoryError: If the path is a directory
FileNotFoundError: If the file doesn't exist
PermissionError: If there are permission issues
"""
import os
if os.path.isdir(file_path):
raise IsADirectoryError(f"'{file_path}' is a directory, not a file")
with open(file_path, 'r', encoding='utf-8') as file:
return file.read()
@@ -31,7 +48,20 @@ def read_task(args: argparse.Namespace, cli_multiline_input: bool) -> str:
# Determine the task
task_str = ''
if args.file:
task_str = read_task_from_file(args.file)
try:
task_str = read_task_from_file(args.file)
except IsADirectoryError as e:
print(f'Error: {e}')
sys.exit(1)
except FileNotFoundError:
print(f"Error: File '{args.file}' not found.")
sys.exit(1)
except PermissionError:
print(f"Error: Permission denied when reading '{args.file}'.")
sys.exit(1)
except Exception as e:
print(f"Error reading file '{args.file}': {e}")
sys.exit(1)
elif args.task:
task_str = args.task
elif not sys.stdin.isatty():

View File

@@ -88,7 +88,6 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
'gpt-4.1',
'kimi-k2-0711-preview',
'kimi-k2-instruct',
'Qwen3-Coder-480B-A35B-Instruct',
]
REASONING_EFFORT_SUPPORTED_MODELS = [
@@ -194,24 +193,7 @@ class LLM(RetryMixin, DebugMixin):
self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS
or self.config.model.split('/')[-1] in REASONING_EFFORT_SUPPORTED_MODELS
):
# For Gemini models, only map 'low' to optimized thinking budget
# Let other reasoning_effort values pass through to API as-is
if 'gemini-2.5-pro' in self.config.model:
logger.debug(
f'Gemini model {self.config.model} with reasoning_effort {self.config.reasoning_effort}'
)
if self.config.reasoning_effort in {None, 'low', 'none'}:
kwargs['thinking'] = {'budget_tokens': 128}
kwargs['allowed_openai_params'] = ['thinking']
kwargs.pop('reasoning_effort', None)
else:
kwargs['reasoning_effort'] = self.config.reasoning_effort
logger.debug(
f'Gemini model {self.config.model} with reasoning_effort {self.config.reasoning_effort} mapped to thinking {kwargs.get("thinking")}'
)
else:
kwargs['reasoning_effort'] = self.config.reasoning_effort
kwargs['reasoning_effort'] = self.config.reasoning_effort
kwargs.pop(
'temperature'
) # temperature is not supported for reasoning models

View File

@@ -242,6 +242,9 @@ class ConversationMemory:
# Add the LLM message (assistant) that initiated the tool calls
# (overwrites any previous message with the same response_id)
logger.debug(
f'Tool calls type: {type(assistant_msg.tool_calls)}, value: {assistant_msg.tool_calls}'
)
pending_tool_call_action_messages[llm_response.id] = Message(
role=getattr(assistant_msg, 'role', 'assistant'),
# tool call content SHOULD BE a string

View File

@@ -17,9 +17,7 @@ import httpx
from openhands.core.config import OpenHandsConfig, SandboxConfig
from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
)
from openhands.core.exceptions import AgentRuntimeDisconnectedError
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventSource, EventStream, EventStreamSubscriber
from openhands.events.action import (
@@ -133,8 +131,7 @@ class Runtime(FileEditRuntimeMixin):
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
self.git_handler = GitHandler(
execute_shell_fn=self._execute_shell_fn_git_handler,
create_file_fn=self._create_file_fn_git_handler,
execute_shell_fn=self._execute_shell_fn_git_handler
)
self.sid = sid
self.event_stream = event_stream
@@ -343,23 +340,14 @@ class Runtime(FileEditRuntimeMixin):
observation: Observation = await self.call_tool_mcp(event)
else:
observation = await call_sync_from_async(self.run_action, event)
except PermissionError as e:
# Handle PermissionError specially - convert to ErrorObservation
# so the agent can receive feedback and continue execution
observation = ErrorObservation(content=str(e))
except (httpx.NetworkError, AgentRuntimeDisconnectedError) as e:
runtime_status = RuntimeStatus.ERROR_RUNTIME_DISCONNECTED
error_message = f'{type(e).__name__}: {str(e)}'
self.log('error', f'Unexpected error while running action: {error_message}')
self.log('error', f'Problematic action: {str(event)}')
self.set_runtime_status(runtime_status, error_message, level='error')
return
except Exception as e:
runtime_status = RuntimeStatus.ERROR
if isinstance(e, (httpx.NetworkError, AgentRuntimeDisconnectedError)):
runtime_status = RuntimeStatus.ERROR_RUNTIME_DISCONNECTED
error_message = f'{type(e).__name__}: {str(e)}'
self.log('error', f'Unexpected error while running action: {error_message}')
self.log('error', f'Problematic action: {str(event)}')
self.set_runtime_status(runtime_status, error_message, level='error')
self.set_runtime_status(runtime_status, error_message)
return
observation._cause = event.id # type: ignore[attr-defined]
@@ -1018,15 +1006,6 @@ fi
return CommandResult(content=content, exit_code=exit_code)
def _create_file_fn_git_handler(self, path: str, content: str) -> int:
"""
This function is used by the GitHandler to execute shell commands.
"""
obs = self.write(FileWriteAction(path=path, content=content))
if isinstance(obs, ErrorObservation):
return -1
return 0
def get_git_changes(self, cwd: str) -> list[dict[str, str]] | None:
self.git_handler.set_cwd(cwd)
changes = self.git_handler.get_git_changes()

View File

@@ -25,6 +25,7 @@ from pydantic import SecretStr
from openhands.core.config import OpenHandsConfig
from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
from openhands.core.exceptions import LLMMalformedActionError
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.events.action import (
@@ -376,6 +377,7 @@ class CLIRuntime(Runtime):
if ready_to_read:
line = process.stdout.readline()
if line:
logger.debug(f'LINE: {line}')
output_lines.append(line)
if self._shell_stream_callback:
self._shell_stream_callback(line)
@@ -386,6 +388,7 @@ class CLIRuntime(Runtime):
while line:
line = process.stdout.readline()
if line:
logger.debug(f'LINE: {line}')
output_lines.append(line)
if self._shell_stream_callback:
self._shell_stream_callback(line)
@@ -505,7 +508,7 @@ class CLIRuntime(Runtime):
)
elif filename.startswith('/'):
if not filename.startswith(self._workspace_path):
raise PermissionError(
raise LLMMalformedActionError(
f'Invalid path: {filename}. You can only work with files in {self._workspace_path}.'
)
actual_filename = filename
@@ -517,7 +520,7 @@ class CLIRuntime(Runtime):
# Check if the resolved path is still within the workspace
if not resolved_path.startswith(self._workspace_path):
raise PermissionError(
raise LLMMalformedActionError(
f'Invalid path traversal: {filename}. Path resolves outside the workspace. Resolved: {resolved_path}, Workspace: {self._workspace_path}'
)
@@ -530,8 +533,15 @@ class CLIRuntime(Runtime):
file_path = self._sanitize_filename(action.path)
# Check if the file exists and is a directory first (before any file operations)
if not os.path.exists(file_path):
return ErrorObservation(f'File not found: {action.path}')
if os.path.isdir(file_path):
return ErrorObservation(f'Cannot read directory: {action.path}')
# Cannot read binary files
if os.path.exists(file_path) and is_binary(file_path):
if is_binary(file_path):
return ErrorObservation('ERROR_BINARY_FILE')
# Use OHEditor for OH_ACI implementation source
@@ -549,14 +559,6 @@ class CLIRuntime(Runtime):
)
try:
# Check if the file exists
if not os.path.exists(file_path):
return ErrorObservation(f'File not found: {action.path}')
# Check if it's a directory
if os.path.isdir(file_path):
return ErrorObservation(f'Cannot read directory: {action.path}')
# Read the file
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()

View File

@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik"
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik"
```
#### Additional Kubernetes Options

View File

@@ -575,30 +575,25 @@ class LocalRuntime(ActionExecutionClient):
# Fallback to localhost
return self.config.sandbox.local_runtime_url
def _create_url(self, prefix: str, port: int) -> str:
runtime_url = self.runtime_url
if 'localhost' in runtime_url:
url = f'{self.runtime_url}:{self._vscode_port}'
else:
# Similar to remote runtime...
parsed_url = urlparse(runtime_url)
url = f'{parsed_url.scheme}://{prefix}-{parsed_url.netloc}'
return url
@property
def vscode_url(self) -> str | None:
token = super().get_vscode_token()
if not token:
return None
vscode_url = self._create_url('vscode', self._vscode_port)
runtime_url = self.runtime_url
if 'localhost' in runtime_url:
vscode_url = f'{self.runtime_url}:{self._vscode_port}'
else:
# Similar to remote runtime...
parsed_url = urlparse(runtime_url)
vscode_url = f'{parsed_url.scheme}://vscode-{parsed_url.netloc}'
return f'{vscode_url}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
@property
def web_hosts(self) -> dict[str, int]:
hosts: dict[str, int] = {}
for index, port in enumerate(self._app_ports):
url = self._create_url(f'work-{index + 1}', port)
hosts[url] = port
for port in self._app_ports:
hosts[f'{self.runtime_url}:{port}'] = port
return hosts

View File

@@ -41,16 +41,30 @@ def parse_pdf(file_path: str) -> None:
Args:
file_path: str: The path to the file to open.
"""
import os
print(f'[Reading PDF file from {file_path}]')
content = PyPDF2.PdfReader(file_path)
text = ''
for page_idx in range(len(content.pages)):
text += (
f'@@ Page {page_idx + 1} @@\n'
+ content.pages[page_idx].extract_text()
+ '\n\n'
)
print(text.strip())
if not os.path.exists(file_path):
print(f'ERROR: File not found: {file_path}')
return
if os.path.isdir(file_path):
print(f'ERROR: Cannot read directory as PDF file: {file_path}')
return
try:
content = PyPDF2.PdfReader(file_path)
text = ''
for page_idx in range(len(content.pages)):
text += (
f'@@ Page {page_idx + 1} @@\n'
+ content.pages[page_idx].extract_text()
+ '\n\n'
)
print(text.strip())
except Exception as e:
print(f'Error reading PDF file: {e}')
def parse_docx(file_path: str) -> None:
@@ -59,12 +73,26 @@ def parse_docx(file_path: str) -> None:
Args:
file_path: str: The path to the file to open.
"""
import os
print(f'[Reading DOCX file from {file_path}]')
content = docx.Document(file_path)
text = ''
for i, para in enumerate(content.paragraphs):
text += f'@@ Page {i + 1} @@\n' + para.text + '\n\n'
print(text)
if not os.path.exists(file_path):
print(f'ERROR: File not found: {file_path}')
return
if os.path.isdir(file_path):
print(f'ERROR: Cannot read directory as DOCX file: {file_path}')
return
try:
content = docx.Document(file_path)
text = ''
for i, para in enumerate(content.paragraphs):
text += f'@@ Page {i + 1} @@\n' + para.text + '\n\n'
print(text)
except Exception as e:
print(f'Error reading DOCX file: {e}')
def parse_latex(file_path: str) -> None:
@@ -73,7 +101,18 @@ def parse_latex(file_path: str) -> None:
Args:
file_path: str: The path to the file to open.
"""
import os
print(f'[Reading LaTex file from {file_path}]')
if not os.path.exists(file_path):
print(f'ERROR: File not found: {file_path}')
return
if os.path.isdir(file_path):
print(f'ERROR: Cannot read directory as LaTeX file: {file_path}')
return
with open(file_path) as f:
data = f.read()
text = LatexNodes2Text().latex_to_text(data)
@@ -81,6 +120,14 @@ def parse_latex(file_path: str) -> None:
def _base64_img(file_path: str) -> str:
import os
if not os.path.exists(file_path):
raise FileNotFoundError(f'File not found: {file_path}')
if os.path.isdir(file_path):
raise IsADirectoryError(f'Cannot read directory as image file: {file_path}')
with open(file_path, 'rb') as image_file:
encoded_image = base64.b64encode(image_file.read()).decode('utf-8')
return encoded_image
@@ -126,7 +173,18 @@ def parse_audio(file_path: str, model: str = 'whisper-1') -> None:
file_path: str: The path to the audio file to transcribe.
model: str: The audio model to use for transcription. Defaults to 'whisper-1'.
"""
import os
print(f'[Transcribing audio file from {file_path}]')
if not os.path.exists(file_path):
print(f'ERROR: File not found: {file_path}')
return
if os.path.isdir(file_path):
print(f'ERROR: Cannot read directory as audio file: {file_path}')
return
try:
# TODO: record the COST of the API call
with open(file_path, 'rb') as audio_file:
@@ -217,7 +275,18 @@ def parse_pptx(file_path: str) -> None:
Args:
file_path: str: The path to the file to open.
"""
import os
print(f'[Reading PowerPoint file from {file_path}]')
if not os.path.exists(file_path):
print(f'ERROR: File not found: {file_path}')
return
if os.path.isdir(file_path):
print(f'ERROR: Cannot read directory as PowerPoint file: {file_path}')
return
try:
pres = Presentation(str(file_path))
text = []

View File

@@ -1,194 +0,0 @@
#!/usr/bin/env python3
"""
Get git changes in the current working directory relative to the remote origin if possible.
NOTE: Since this is run as a script, there should be no imports from project files!
"""
import glob
import json
import os
import subprocess
from pathlib import Path
def run(cmd: str, cwd: str) -> str:
result = subprocess.run(
args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
)
byte_content = result.stderr or result.stdout or b''
if result.returncode != 0:
raise RuntimeError(
f'error_running_cmd:{result.returncode}:{byte_content.decode()}'
)
return byte_content.decode().strip()
def get_valid_ref(repo_dir: str) -> str | None:
refs = []
try:
current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir)
refs.append(f'origin/{current_branch}')
except RuntimeError:
pass
try:
default_branch = (
run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir)
.split()[-1]
.strip()
)
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = f'origin/{default_branch}'
refs.append(ref_non_default_branch)
refs.append(ref_default_branch)
except RuntimeError:
pass
# compares with empty tree
ref_new_repo = (
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
)
refs.append(ref_new_repo)
# Find a ref that exists...
for ref in refs:
try:
result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir)
return result
except RuntimeError:
# invalid ref - try next
continue
return None
def get_changes_in_repo(repo_dir: str) -> list[dict[str, str]]:
# Gets the status relative to the origin default branch - not the same as `git status`
ref = get_valid_ref(repo_dir)
if not ref:
return []
# Get changed files
changed_files = run(
f'git --no-pager diff --name-status {ref}', repo_dir
).splitlines()
changes = []
for line in changed_files:
if not line.strip():
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
# Handle different output formats from git diff --name-status
# Depending on git config, format can be either:
# * "A file.txt"
# * "A file.txt"
# * "R100 old_file.txt new_file.txt" (rename with similarity percentage)
parts = line.split()
if len(parts) < 2:
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
status = parts[0].strip()
# Handle rename operations (status starts with 'R' followed by similarity percentage)
if status.startswith('R') and len(parts) == 3:
# Rename: convert to delete (old path) + add (new path)
old_path = parts[1].strip()
new_path = parts[2].strip()
changes.append(
{
'status': 'D',
'path': old_path,
}
)
changes.append(
{
'status': 'A',
'path': new_path,
}
)
continue
# Handle copy operations (status starts with 'C' followed by similarity percentage)
elif status.startswith('C') and len(parts) == 3:
# Copy: only add the new path (original remains)
new_path = parts[2].strip()
changes.append(
{
'status': 'A',
'path': new_path,
}
)
continue
# Handle regular operations (M, A, D, etc.)
elif len(parts) == 2:
path = parts[1].strip()
else:
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
if status == '??':
status = 'A'
elif status == '*':
status = 'M'
# Check for valid single-character status codes
if status in {'M', 'A', 'D', 'U'}:
changes.append(
{
'status': status,
'path': path,
}
)
else:
raise RuntimeError(f'unexpected_status_in_git_diff:{changed_files}')
# Get untracked files
untracked_files = run(
'git --no-pager ls-files --others --exclude-standard', repo_dir
).splitlines()
for path in untracked_files:
if path:
changes.append({'status': 'A', 'path': path})
return changes
def get_git_changes(cwd: str) -> list[dict[str, str]]:
git_dirs = {
os.path.dirname(f)[2:]
for f in glob.glob('./*/.git', root_dir=cwd, recursive=True)
}
# First try the workspace directory
changes = get_changes_in_repo(cwd)
# Filter out any changes which are in one of the git directories
changes = [
change
for change in changes
if next(
iter(git_dir for git_dir in git_dirs if change['path'].startswith(git_dir)),
None,
)
is None
]
# Add changes from git directories
for git_dir in git_dirs:
git_dir_changes = get_changes_in_repo(str(Path(cwd, git_dir)))
for change in git_dir_changes:
change['path'] = git_dir + '/' + change['path']
changes.append(change)
changes.sort(key=lambda change: change['path'])
return changes
if __name__ == '__main__':
try:
changes = get_git_changes(os.getcwd())
print(json.dumps(changes))
except Exception as e:
print(json.dumps({'error': str(e)}))

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""
Get git diff in a single git file for the closest git repo in the file system
NOTE: Since this is run as a script, there should be no imports from project files!
"""
import json
import os
import subprocess
import sys
from pathlib import Path
def get_closest_git_repo(path: Path) -> Path | None:
while True:
path = path.parent
git_path = Path(path, '.git')
if git_path.is_dir():
return path
if path.parent == path:
return None
def run(cmd: str, cwd: str) -> str:
result = subprocess.run(
args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
)
byte_content = result.stderr or result.stdout or b''
if result.returncode != 0:
raise RuntimeError(
f'error_running_cmd:{result.returncode}:{byte_content.decode()}'
)
return byte_content.decode().strip()
def get_valid_ref(repo_dir: str) -> str | None:
refs = []
try:
current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir)
refs.append(f'origin/{current_branch}')
except RuntimeError:
pass
try:
default_branch = (
run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir)
.split()[-1]
.strip()
)
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = f'origin/{default_branch}'
refs.append(ref_non_default_branch)
refs.append(ref_default_branch)
except RuntimeError:
pass
# compares with empty tree
ref_new_repo = (
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
)
refs.append(ref_new_repo)
# Find a ref that exists...
for ref in refs:
try:
result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir)
return result
except RuntimeError:
# invalid ref - try next
continue
return None
def get_git_diff(relative_file_path: str) -> dict[str, str]:
path = Path(os.getcwd(), relative_file_path).resolve()
closest_git_repo = get_closest_git_repo(path)
if not closest_git_repo:
raise ValueError('no_repo')
current_rev = get_valid_ref(str(closest_git_repo))
try:
original = run(
f'git show "{current_rev}:{path.relative_to(closest_git_repo)}"',
str(closest_git_repo),
)
except RuntimeError:
original = ''
try:
with open(path, 'r') as f:
modified = '\n'.join(f.read().splitlines())
except FileNotFoundError:
modified = ''
return {
'modified': modified,
'original': original,
}
if __name__ == '__main__':
diff = get_git_diff(sys.argv[-1])
print(json.dumps(diff))

View File

@@ -1,15 +1,6 @@
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.utils import git_changes, git_diff
GIT_CHANGES_CMD = 'python3 /openhands/code/openhands/runtime/utils/git_changes.py'
GIT_DIFF_CMD = (
'python3 /openhands/code/openhands/runtime/utils/git_diff.py "{file_path}"'
)
from uuid import uuid4
@dataclass
@@ -34,13 +25,9 @@ class GitHandler:
def __init__(
self,
execute_shell_fn: Callable[[str, str | None], CommandResult],
create_file_fn: Callable[[str, str], int],
):
self.execute = execute_shell_fn
self.create_file_fn = create_file_fn
self.cwd: str | None = None
self.git_changes_cmd = GIT_CHANGES_CMD
self.git_diff_cmd = GIT_DIFF_CMD
def set_cwd(self, cwd: str) -> None:
"""
@@ -51,13 +38,148 @@ class GitHandler:
"""
self.cwd = cwd
def _create_python_script_file(self, file: str):
result = self.execute('mktemp -d', self.cwd)
script_file = Path(result.content.strip(), Path(file).name)
with open(file, 'r') as f:
self.create_file_fn(str(script_file), f.read())
result = self.execute(f'chmod +x "{script_file}"', self.cwd)
return script_file
def _is_git_repo(self) -> bool:
"""
Checks if the current directory is a Git repository.
Returns:
bool: True if inside a Git repository, otherwise False.
"""
cmd = 'git --no-pager rev-parse --is-inside-work-tree'
output = self.execute(cmd, self.cwd)
return output.content.strip() == 'true'
def _get_current_file_content(self, file_path: str) -> str:
"""
Retrieves the current content of a given file.
Args:
file_path (str): Path to the file.
Returns:
str: The file content.
"""
output = self.execute(f'cat {file_path}', self.cwd)
return output.content
def _verify_ref_exists(self, ref: str) -> bool:
"""
Verifies whether a specific Git reference exists.
Args:
ref (str): The Git reference to check.
Returns:
bool: True if the reference exists, otherwise False.
"""
cmd = f'git --no-pager rev-parse --verify {ref}'
output = self.execute(cmd, self.cwd)
return output.exit_code == 0
def _get_ref_content(self, file_path: str) -> str:
"""
Retrieves the content of a file from a valid Git reference.
Finds the git repository closest to the file in the tree and executes the command in that context.
Args:
file_path (str): The file path in the repository.
Returns:
str: The content of the file from the reference, or an empty string if unavailable.
"""
if not self.cwd:
return ''
unique_id = uuid4().hex
# Single bash command that finds the closest git repository to the file and gets the ref content
cmd = f"""bash -c '
# Convert to absolute path
file_path="$(realpath "{file_path}")"
# Find the closest git repository by walking up the directory tree
current_dir="$(dirname "$file_path")"
git_repo_dir=""
while [[ "$current_dir" != "/" ]]; do
if [[ -d "$current_dir/.git" ]] || git -C "$current_dir" rev-parse --git-dir >/dev/null 2>&1; then
git_repo_dir="$current_dir"
break
fi
current_dir="$(dirname "$current_dir")"
done
# If no git repository found, exit
if [[ -z "$git_repo_dir" ]]; then
exit 1
fi
# Get the file path relative to the git repository root
repo_root="$(cd "$git_repo_dir" && git rev-parse --show-toplevel)"
relative_file_path="${{file_path#${{repo_root}}/}}"
# Function to get current branch
get_current_branch() {{
git -C "$git_repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null
}}
# Function to get default branch
get_default_branch() {{
git -C "$git_repo_dir" remote show origin 2>/dev/null | grep "HEAD branch" | awk "{{print \\$NF}}" || echo "main"
}}
# Function to verify if a ref exists
verify_ref_exists() {{
git -C "$git_repo_dir" rev-parse --verify "$1" >/dev/null 2>&1
}}
# Get valid reference for comparison
current_branch="$(get_current_branch)"
default_branch="$(get_default_branch)"
# Check if origin remote exists
has_origin="$(git -C "$git_repo_dir" remote | grep -q "^origin$" && echo "true" || echo "false")"
if [[ "$has_origin" == "true" ]]; then
ref_current_branch="origin/$current_branch"
ref_non_default_branch="$(git -C "$git_repo_dir" merge-base HEAD "$(git -C "$git_repo_dir" rev-parse --abbrev-ref origin/$default_branch)" 2>/dev/null || echo "")"
ref_default_branch="origin/$default_branch"
else
# For repositories without origin, try HEAD~1 (previous commit) or empty tree
ref_current_branch="HEAD~1"
ref_non_default_branch=""
ref_default_branch=""
fi
ref_new_repo="$(git -C "$git_repo_dir" rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904 2>/dev/null || echo "")" # empty tree
# Try refs in order of preference
valid_ref=""
for ref in "$ref_current_branch" "$ref_non_default_branch" "$ref_default_branch" "$ref_new_repo"; do
if [[ -n "$ref" ]] && verify_ref_exists "$ref"; then
valid_ref="$ref"
break
fi
done
# If no valid ref found, exit
if [[ -z "$valid_ref" ]]; then
exit 1
fi
# Get the file content from the reference
git -C "$git_repo_dir" show "$valid_ref:$relative_file_path" 2>/dev/null || exit 1
# {unique_id}'"""
result = self.execute(cmd, self.cwd)
if result.exit_code != 0:
return ''
# TODO: The command echoes the bash script. Why?
content = result.content.split(f'{unique_id}')[-1]
return content
def get_git_changes(self) -> list[dict[str, str]] | None:
"""
@@ -73,31 +195,57 @@ class GitHandler:
if not self.cwd:
return None
result = self.execute(self.git_changes_cmd, self.cwd)
if result.exit_code == 0:
try:
changes = json.loads(result.content)
return changes
except Exception:
logger.exception(
'GitHandler:get_git_changes:error',
extra={'content': result.content},
)
return None
# Single bash command that:
# 1. Creates a list of directories to check (current dir + direct subdirectories)
# 2. For each directory, checks if it's a git repo and gets status
# 3. Outputs in format: REPO_PATH|STATUS|FILE_PATH
cmd = """bash -c '
{
# Check current directory first
echo "."
# List direct subdirectories (excluding hidden ones)
find . -maxdepth 1 -type d ! -name ".*" ! -name "." 2>/dev/null || true
} | while IFS= read -r dir; do
if [ -d "$dir/.git" ] || git -C "$dir" rev-parse --git-dir >/dev/null 2>&1; then
# Get absolute path of the directory
# Get git status for this repository
git -C "$dir" status --porcelain -uall 2>/dev/null | while IFS= read -r line; do
if [ -n "$line" ]; then
# Extract status (first 2 chars) and file path (from char 3 onwards)
status=$(echo "$line" | cut -c1-2)
file_path=$(echo "$line" | cut -c4-)
# Convert status codes to single character
case "$status" in
"M "*|" M") echo "$dir|M|$file_path" ;;
"A "*|" A") echo "$dir|A|$file_path" ;;
"D "*|" D") echo "$dir|D|$file_path" ;;
"R "*|" R") echo "$dir|R|$file_path" ;;
"C "*|" C") echo "$dir|C|$file_path" ;;
"U "*|" U") echo "$dir|U|$file_path" ;;
"??") echo "$dir|A|$file_path" ;;
*) echo "$dir|M|$file_path" ;;
esac
fi
done
fi
done
' """
if self.git_changes_cmd != GIT_CHANGES_CMD:
# We have already tried to add a script to the workspace - it did not work
result = self.execute(cmd.strip(), self.cwd)
if result.exit_code != 0 or not result.content.strip():
return None
# We try to add a script for getting git changes to the runtime - legacy runtimes may be missing the script
logger.info(
'GitHandler:get_git_changes: adding git_changes script to runtime...'
)
script_file = self._create_python_script_file(git_changes.__file__)
self.git_changes_cmd = f'python3 {script_file}'
# Parse the output
changes = []
for line in result.content.strip().split('\n'):
if '|' in line:
parts = line.split('|', 2)
if len(parts) == 3:
repo_path, status, file_path = parts
file_path = f'{repo_path}/{file_path}'[2:]
changes.append({'status': status, 'path': file_path})
# Try again with the new changes cmd
return self.get_git_changes()
return changes if changes else None
def get_git_diff(self, file_path: str) -> dict[str, str]:
"""
@@ -109,23 +257,36 @@ class GitHandler:
Returns:
dict[str, str]: A dictionary containing the original and modified content.
"""
# If cwd is not set, return None
if not self.cwd:
raise ValueError('no_dir_in_git_diff')
modified = self._get_current_file_content(file_path)
original = self._get_ref_content(file_path)
result = self.execute(self.git_diff_cmd.format(file_path=file_path), self.cwd)
if result.exit_code == 0:
diff = json.loads(result.content)
return diff
return {
'modified': modified,
'original': original,
}
if self.git_diff_cmd != GIT_DIFF_CMD:
# We have already tried to add a script to the workspace - it did not work
raise ValueError('error_in_git_diff')
# We try to add a script for getting git changes to the runtime - legacy runtimes may be missing the script
logger.info('GitHandler:get_git_diff: adding git_diff script to runtime...')
script_file = self._create_python_script_file(git_diff.__file__)
self.git_diff_cmd = f'python3 {script_file} "{{file_path}}"'
def parse_git_changes(changes_list: list[str]) -> list[dict[str, str]]:
"""
Parses the list of changed files and extracts their statuses and paths.
# Try again with the new changes cmd
return self.get_git_diff(file_path)
Args:
changes_list (list[str]): List of changed file entries.
Returns:
list[dict[str, str]]: Parsed list of file changes with statuses.
"""
result = []
for line in changes_list:
status = line[:2].strip()
path = line[2:].strip()
# Get the first non-space character as the primary status
primary_status = status.replace(' ', '')[0]
result.append(
{
'status': primary_status,
'path': path,
}
)
return result

View File

@@ -28,7 +28,6 @@ from openhands.events.observation.agent import RecallObservation
from openhands.events.observation.error import ErrorObservation
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.events.stream import EventStreamSubscriber
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.llm.llm import LLM
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.session.agent_session import AgentSession
@@ -158,10 +157,6 @@ class Session:
llm = self._create_llm(agent_cls)
agent_config = self.config.get_agent_config(agent_cls)
agent_config = ExperimentManagerImpl.run_agent_config_variant_test(
self.user_id, self.sid, agent_config
)
if settings.enable_default_condenser:
# Default condenser chains three condensers together:
# 1. a conversation window condenser that handles explicit

View File

@@ -52,11 +52,6 @@ class DefaultUserAuth(UserAuth):
return settings
settings_store = await self.get_user_settings_store()
settings = await settings_store.load()
# Merge config.toml settings with stored settings
if settings:
settings = settings.merge_with_config_settings()
self._settings = settings
return settings

View File

@@ -137,33 +137,3 @@ class Settings(BaseModel):
max_budget_per_task=app_config.max_budget_per_task,
)
return settings
def merge_with_config_settings(self) -> 'Settings':
"""Merge config.toml settings with stored settings.
Config.toml takes priority for MCP settings, but they are merged rather than replaced.
This method can be used by both server mode and CLI mode.
"""
# Get config.toml settings
config_settings = Settings.from_config()
if not config_settings or not config_settings.mcp_config:
return self
# If stored settings don't have MCP config, use config.toml MCP config
if not self.mcp_config:
self.mcp_config = config_settings.mcp_config
return self
# Both have MCP config - merge them with config.toml taking priority
merged_mcp = MCPConfig(
sse_servers=list(config_settings.mcp_config.sse_servers)
+ list(self.mcp_config.sse_servers),
stdio_servers=list(config_settings.mcp_config.stdio_servers)
+ list(self.mcp_config.stdio_servers),
shttp_servers=list(config_settings.mcp_config.shttp_servers)
+ list(self.mcp_config.shttp_servers),
)
# Create new settings with merged MCP config
self.mcp_config = merged_mcp
return self

88
poetry.lock generated
View File

@@ -5487,6 +5487,25 @@ files = [
[package.dependencies]
psutil = "*"
[[package]]
name = "minio"
version = "7.2.16"
description = "MinIO Python SDK for Amazon S3 Compatible Cloud Storage"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "minio-7.2.16-py3-none-any.whl", hash = "sha256:9288ab988ca57c181eb59a4c96187b293131418e28c164392186c2b89026b223"},
{file = "minio-7.2.16.tar.gz", hash = "sha256:81e365c8494d591d8204a63ee7596bfdf8a7d06ad1b1507d6b9c1664a95f299a"},
]
[package.dependencies]
argon2-cffi = "*"
certifi = "*"
pycryptodome = "*"
typing-extensions = "*"
urllib3 = "*"
[[package]]
name = "mistune"
version = "3.1.3"
@@ -7509,6 +7528,57 @@ files = [
]
markers = {test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
[[package]]
name = "pycryptodome"
version = "3.23.0"
description = "Cryptographic library for Python"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
files = [
{file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"},
{file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"},
{file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"},
{file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"},
{file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"},
{file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"},
{file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"},
{file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"},
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"},
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"},
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"},
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"},
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"},
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"},
{file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"},
{file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"},
{file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"},
{file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"},
{file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"},
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"},
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"},
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"},
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"},
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"},
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"},
{file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"},
{file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"},
{file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"},
{file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"},
{file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"},
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"},
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"},
{file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"},
]
[[package]]
name = "pydantic"
version = "2.11.5"
@@ -9674,6 +9744,22 @@ docs = ["myst-parser[linkify]", "sphinx", "sphinx-rtd-theme"]
release = ["twine"]
test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"]
[[package]]
name = "stripe"
version = "12.3.0"
description = "Python bindings for the Stripe API"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "stripe-12.3.0-py2.py3-none-any.whl", hash = "sha256:f53daf37253cef0323613aa298b66d2d2081d37d0f2e4d9f8639824bf67185b9"},
{file = "stripe-12.3.0.tar.gz", hash = "sha256:ad8afdab8acdbd75fc098b0fefdfee698f68334a3f6e787633e8d290da89932b"},
]
[package.dependencies]
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
typing_extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
[[package]]
name = "swebench"
version = "4.0.4"
@@ -11754,4 +11840,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "4640c66849d6436eed73826154e2d8cf88b456a4d1b71efb9438531245845826"
content-hash = "b89a5ec4de63ea1f2adb0b6ba171778ff25313fd29cd81a7cec5c957b23a9dc9"

View File

@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.51.0"
version = "0.50.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -84,7 +84,9 @@ bashlex = "^0.18"
# TODO: These are integrations that should probably be optional
redis = ">=5.2,<7.0"
minio = "^7.2.8"
stripe = ">=11.5,<13.0"
google-cloud-aiplatform = "*"
anthropic = { extras = [ "vertex" ], version = "*" }
boto3 = "*"

View File

@@ -825,14 +825,7 @@ async def test_config_loading_order(
mock_config = MagicMock()
mock_config.workspace_base = '/test/dir'
mock_config.cli_multiline_input = False
# Create a mock LLM config that has no model or API key set
# This simulates the case where config.toml doesn't have LLM settings
mock_llm_config = MagicMock()
mock_llm_config.model = None
mock_llm_config.api_key = None
mock_config.get_llm_config = MagicMock(return_value=mock_llm_config)
mock_config.get_llm_config = MagicMock(return_value=MagicMock())
mock_config.set_llm_config = MagicMock()
mock_config.get_agent_config = MagicMock(return_value=MagicMock())
mock_config.set_agent_config = MagicMock()

View File

@@ -1,204 +0,0 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from litellm.exceptions import AuthenticationError
from openhands.cli import main as cli
from openhands.events import EventSource
from openhands.events.action import MessageAction
@pytest_asyncio.fixture
def mock_agent():
agent = AsyncMock()
agent.reset = MagicMock()
return agent
@pytest_asyncio.fixture
def mock_runtime():
runtime = AsyncMock()
runtime.close = MagicMock()
runtime.event_stream = MagicMock()
return runtime
@pytest_asyncio.fixture
def mock_controller():
controller = AsyncMock()
controller.close = AsyncMock()
# Setup for get_state() and the returned state's save_to_session()
mock_state = MagicMock()
mock_state.save_to_session = MagicMock()
controller.get_state = MagicMock(return_value=mock_state)
return controller
@pytest_asyncio.fixture
def mock_config():
config = MagicMock()
config.runtime = 'local'
config.cli_multiline_input = False
config.workspace_base = '/test/dir'
# Set up LLM config to use OpenHands provider
llm_config = MagicMock()
llm_config.model = 'openhands/o3' # Use OpenHands provider with o3 model
llm_config.api_key = MagicMock()
llm_config.api_key.get_secret_value.return_value = 'invalid-api-key'
config.llm = llm_config
# Mock search_api_key with get_secret_value method
search_api_key_mock = MagicMock()
search_api_key_mock.get_secret_value.return_value = (
'' # Empty string, not starting with 'tvly-'
)
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
@pytest_asyncio.fixture
def mock_settings_store():
settings_store = AsyncMock()
return settings_store
@pytest.mark.asyncio
@patch('openhands.cli.main.display_runtime_initialization_message')
@patch('openhands.cli.main.display_initialization_animation')
@patch('openhands.cli.main.create_agent')
@patch('openhands.cli.main.add_mcp_tools_to_agent')
@patch('openhands.cli.main.create_runtime')
@patch('openhands.cli.main.create_controller')
@patch('openhands.cli.main.create_memory')
@patch('openhands.cli.main.run_agent_until_done')
@patch('openhands.cli.main.cleanup_session')
@patch('openhands.cli.main.initialize_repository_for_runtime')
@patch('openhands.llm.llm.litellm_completion')
async def test_openhands_provider_authentication_error(
mock_litellm_completion,
mock_initialize_repo,
mock_cleanup_session,
mock_run_agent_until_done,
mock_create_memory,
mock_create_controller,
mock_create_runtime,
mock_add_mcp_tools,
mock_create_agent,
mock_display_animation,
mock_display_runtime_init,
mock_config,
mock_settings_store,
):
"""Test that authentication errors with the OpenHands provider are handled correctly.
This test reproduces the error seen in the CLI when using the OpenHands provider:
```
litellm.exceptions.AuthenticationError: litellm.AuthenticationError: AuthenticationError: Litellm_proxyException -
Authentication Error, Invalid proxy server token passed. Received API Key = sk-...7hlQ,
Key Hash (Token) =e316fa114498880be11f2e236d6f482feee5e324a4a148b98af247eded5290c4.
Unable to find token in cache or `LiteLLM_VerificationTokenTable`
18:38:53 - openhands:ERROR: loop.py:25 - STATUS$ERROR_LLM_AUTHENTICATION
```
The test mocks the litellm_completion function to raise an AuthenticationError
with the OpenHands provider and verifies that the CLI handles the error gracefully.
"""
loop = asyncio.get_running_loop()
# Mock initialize_repository_for_runtime to return a valid path
mock_initialize_repo.return_value = '/test/dir'
# Mock objects returned by the setup functions
mock_agent = AsyncMock()
mock_create_agent.return_value = mock_agent
mock_runtime = AsyncMock()
mock_runtime.event_stream = MagicMock()
mock_create_runtime.return_value = mock_runtime
mock_controller = AsyncMock()
mock_controller_task = MagicMock()
mock_create_controller.return_value = (mock_controller, mock_controller_task)
# Create a regular MagicMock for memory to avoid coroutine issues
mock_memory = MagicMock()
mock_create_memory.return_value = mock_memory
# Mock the litellm_completion function to raise an AuthenticationError
# This simulates the exact error seen in the user's issue
auth_error_message = (
'litellm.AuthenticationError: AuthenticationError: Litellm_proxyException - '
'Authentication Error, Invalid proxy server token passed. Received API Key = sk-...7hlQ, '
'Key Hash (Token) =e316fa114498880be11f2e236d6f482feee5e324a4a148b98af247eded5290c4. '
'Unable to find token in cache or `LiteLLM_VerificationTokenTable`'
)
mock_litellm_completion.side_effect = AuthenticationError(
message=auth_error_message, llm_provider='litellm_proxy', model='o3'
)
with patch(
'openhands.cli.main.read_prompt_input', new_callable=AsyncMock
) as mock_read_prompt:
# Set up read_prompt_input to return a string that will trigger the command handler
mock_read_prompt.return_value = '/exit'
# Mock handle_commands to return values that will exit the loop
with patch(
'openhands.cli.main.handle_commands', new_callable=AsyncMock
) as mock_handle_commands:
mock_handle_commands.return_value = (
True,
False,
False,
) # close_repl, reload_microagents, new_session_requested
# Mock logger.error to capture the error message
with patch('openhands.core.logger.openhands_logger.error'):
# Run the function with an initial action that will trigger the OpenHands provider
initial_action_content = 'Hello, I need help with a task'
# Run the function
result = await cli.run_session(
loop,
mock_config,
mock_settings_store,
'/test/dir',
initial_action_content,
)
# Check that an event was added to the event stream
mock_runtime.event_stream.add_event.assert_called_once()
call_args = mock_runtime.event_stream.add_event.call_args[0]
assert isinstance(call_args[0], MessageAction)
# The CLI might modify the initial message, so we don't check the exact content
assert call_args[1] == EventSource.USER
# Check that run_agent_until_done was called
mock_run_agent_until_done.assert_called_once()
# Since we're mocking the litellm_completion function to raise an AuthenticationError,
# we can verify that the error was handled by checking that the run_agent_until_done
# function was called and the session was cleaned up properly
# We can't directly check the error message in the test since the logger.error
# method isn't being called in our mocked environment. In a real environment,
# the error would be logged and the user would see the improved error message.
# Check that cleanup_session was called
mock_cleanup_session.assert_called_once()
# Check that the function returns the expected value
assert result is False

View File

@@ -0,0 +1,83 @@
"""Unit tests for CLI runtime directory error handling."""
import tempfile
from pathlib import Path
from openhands.events.action import FileReadAction
from openhands.events.event import FileReadSource
from openhands.events.observation import ErrorObservation, FileReadObservation
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
class TestCLIRuntimeDirectoryHandling:
"""Test CLI runtime directory error handling."""
def setup_method(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.test_dir = Path(self.temp_dir) / 'test_directory'
self.test_dir.mkdir()
self.test_file = Path(self.temp_dir) / 'test_file.txt'
self.test_file.write_text('This is a test file.')
# Create minimal runtime instance
self.runtime = CLIRuntime.__new__(CLIRuntime)
self.runtime._runtime_initialized = True
self.runtime._workspace_path = self.temp_dir
def teardown_method(self):
"""Clean up test fixtures."""
import shutil
shutil.rmtree(self.temp_dir)
def test_read_directory_returns_error(self):
"""Test that reading a directory returns proper error message."""
action = FileReadAction(path=str(self.test_dir))
result = self.runtime.read(action)
assert isinstance(result, ErrorObservation)
assert 'Cannot read directory' in result.content
assert str(self.test_dir) in result.content
def test_read_nonexistent_file_returns_error(self):
"""Test that reading a non-existent file returns proper error message."""
nonexistent_file = Path(self.temp_dir) / 'nonexistent.txt'
action = FileReadAction(path=str(nonexistent_file))
result = self.runtime.read(action)
assert isinstance(result, ErrorObservation)
assert 'File not found' in result.content
assert str(nonexistent_file) in result.content
def test_read_valid_file_succeeds(self):
"""Test that reading a valid file works correctly."""
action = FileReadAction(path=str(self.test_file))
result = self.runtime.read(action)
assert isinstance(result, FileReadObservation)
assert 'This is a test file' in result.content
def test_read_directory_with_oh_aci_returns_error(self):
"""Test that reading a directory with OH_ACI source returns proper error message."""
action = FileReadAction(
path=str(self.test_dir), impl_source=FileReadSource.OH_ACI
)
result = self.runtime.read(action)
assert isinstance(result, ErrorObservation)
assert 'Cannot read directory' in result.content
assert str(self.test_dir) in result.content
def test_read_binary_file_returns_error(self):
"""Test that reading a binary file returns proper error message."""
# Create a binary file
binary_file = Path(self.temp_dir) / 'test.bin'
binary_file.write_bytes(b'\x00\x01\x02\x03')
action = FileReadAction(path=str(binary_file))
result = self.runtime.read(action)
assert isinstance(result, ErrorObservation)
assert 'ERROR_BINARY_FILE' in result.content

View File

@@ -461,6 +461,26 @@ class TestFileOperations:
assert result == mock_content
def test_read_file_directory_error(self):
"""Test that read_file raises IsADirectoryError when trying to read a directory."""
with patch('pathlib.Path.is_dir', return_value=True):
try:
read_file('/some/directory')
raise AssertionError('Expected IsADirectoryError to be raised')
except IsADirectoryError as e:
assert 'is a directory, not a file' in str(e)
assert '/some/directory' in str(e)
def test_read_file_regular_file(self):
"""Test that read_file works normally for regular files."""
mock_content = 'test file content'
with (
patch('pathlib.Path.is_dir', return_value=False),
patch('builtins.open', mock_open(read_data=mock_content)),
):
result = read_file('test.txt')
assert result == mock_content
def test_write_to_file(self):
mock_content = 'test file content'
mock_file = mock_open()

View File

@@ -6,6 +6,7 @@ import tempfile
import pytest
from openhands.core.config import OpenHandsConfig
from openhands.core.exceptions import LLMMalformedActionError
from openhands.events import EventStream
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
from openhands.storage import get_file_store
@@ -48,7 +49,7 @@ def test_sanitize_filename_relative_path(cli_runtime):
def test_sanitize_filename_outside_workspace(cli_runtime):
"""Test _sanitize_filename with a path outside the workspace."""
test_path = '/tmp/test.txt' # Path outside workspace
with pytest.raises(PermissionError) as exc_info:
with pytest.raises(LLMMalformedActionError) as exc_info:
cli_runtime._sanitize_filename(test_path)
assert 'Invalid path:' in str(exc_info.value)
assert 'You can only work with files in' in str(exc_info.value)
@@ -57,7 +58,7 @@ def test_sanitize_filename_outside_workspace(cli_runtime):
def test_sanitize_filename_path_traversal(cli_runtime):
"""Test _sanitize_filename with path traversal attempt."""
test_path = os.path.join(cli_runtime._workspace_path, '..', 'test.txt')
with pytest.raises(PermissionError) as exc_info:
with pytest.raises(LLMMalformedActionError) as exc_info:
cli_runtime._sanitize_filename(test_path)
assert 'Invalid path traversal:' in str(exc_info.value)
assert 'Path resolves outside the workspace' in str(exc_info.value)

View File

@@ -1,310 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config import (
OpenHandsConfig,
get_llm_config_arg,
setup_config_from_args,
)
@pytest.fixture
def default_config():
"""Fixture to provide a default OpenHandsConfig instance."""
yield OpenHandsConfig()
@pytest.fixture
def temp_config_files(tmp_path):
"""Create temporary config files for testing precedence."""
# Create a directory structure mimicking ~/.openhands/
user_config_dir = tmp_path / 'home' / '.openhands'
user_config_dir.mkdir(parents=True, exist_ok=True)
# Create ~/.openhands/config.toml
user_config_toml = user_config_dir / 'config.toml'
user_config_toml.write_text("""
[llm]
model = "user-home-model"
api_key = "user-home-api-key"
[llm.user-llm]
model = "user-specific-model"
api_key = "user-specific-api-key"
""")
# Create ~/.openhands/settings.json
user_settings_json = user_config_dir / 'settings.json'
user_settings_json.write_text("""
{
"LLM_MODEL": "settings-json-model",
"LLM_API_KEY": "settings-json-api-key"
}
""")
# Create current directory config.toml
current_dir_toml = tmp_path / 'current' / 'config.toml'
current_dir_toml.parent.mkdir(parents=True, exist_ok=True)
current_dir_toml.write_text("""
[llm]
model = "current-dir-model"
api_key = "current-dir-api-key"
[llm.current-dir-llm]
model = "current-dir-specific-model"
api_key = "current-dir-specific-api-key"
""")
return {
'user_config_toml': str(user_config_toml),
'user_settings_json': str(user_settings_json),
'current_dir_toml': str(current_dir_toml),
'home_dir': str(user_config_dir.parent),
'current_dir': str(current_dir_toml.parent),
}
@patch('openhands.core.config.utils.os.path.expanduser')
def test_llm_config_precedence_cli_highest(mock_expanduser, temp_config_files):
"""Test that CLI parameters have the highest precedence."""
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock args with CLI parameters
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = 'current-dir-llm' # Specify LLM via CLI
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config with CLI parameters
with patch('os.path.exists', return_value=True):
config = setup_config_from_args(mock_args)
# Verify CLI parameter takes precedence
assert config.get_llm_config().model == 'current-dir-specific-model'
assert (
config.get_llm_config().api_key.get_secret_value()
== 'current-dir-specific-api-key'
)
@patch('openhands.core.config.utils.os.path.expanduser')
def test_current_dir_toml_precedence_over_user_config(
mock_expanduser, temp_config_files
):
"""Test that config.toml in current directory has precedence over ~/.openhands/config.toml."""
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock args without CLI parameters
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = None # No CLI parameter
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config without CLI parameters
with patch('os.path.exists', return_value=True):
config = setup_config_from_args(mock_args)
# Verify current directory config.toml takes precedence over user config
assert config.get_llm_config().model == 'current-dir-model'
assert config.get_llm_config().api_key.get_secret_value() == 'current-dir-api-key'
@patch('openhands.core.config.utils.os.path.expanduser')
def test_get_llm_config_arg_precedence(mock_expanduser, temp_config_files):
"""Test that get_llm_config_arg prioritizes the specified config file."""
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# First try to load from current directory config
with patch('os.path.exists', return_value=True):
llm_config = get_llm_config_arg(
'current-dir-llm', temp_config_files['current_dir_toml']
)
# Verify it loaded from current directory config
assert llm_config.model == 'current-dir-specific-model'
assert llm_config.api_key.get_secret_value() == 'current-dir-specific-api-key'
# Now try to load a config that doesn't exist
# We need to patch setup_config_from_args to handle the fallback to user config
with patch(
'os.path.exists',
return_value=False,
):
llm_config = get_llm_config_arg(
'user-llm', temp_config_files['current_dir_toml']
)
# Verify it returns None when config not found (no automatic fallback)
assert llm_config is None
@patch('openhands.core.config.utils.os.path.expanduser')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.FileSettingsStore.load')
def test_cli_main_settings_precedence(
mock_load, mock_get_instance, mock_expanduser, temp_config_files
):
"""Test that the CLI main.py correctly applies settings precedence."""
from openhands.cli.main import setup_config_from_args
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock settings
mock_settings = MagicMock()
mock_settings.llm_model = 'settings-store-model'
mock_settings.llm_api_key = 'settings-store-api-key'
mock_settings.llm_base_url = None
mock_settings.agent = 'CodeActAgent'
mock_settings.confirmation_mode = False
mock_settings.enable_default_condenser = True
# Setup mocks
mock_load.return_value = mock_settings
mock_get_instance.return_value = MagicMock()
# Create mock args with config file pointing to current directory config
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = None # No CLI parameter
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config using the actual CLI code path
with patch('os.path.exists', return_value=True):
config = setup_config_from_args(mock_args)
# Verify that config.toml values take precedence over settings.json
assert config.get_llm_config().model == 'current-dir-model'
assert config.get_llm_config().api_key.get_secret_value() == 'current-dir-api-key'
@patch('openhands.core.config.utils.os.path.expanduser')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.FileSettingsStore.load')
def test_cli_with_l_parameter_precedence(
mock_load, mock_get_instance, mock_expanduser, temp_config_files
):
"""Test that CLI -l parameter has highest precedence in CLI mode."""
from openhands.cli.main import setup_config_from_args
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock settings
mock_settings = MagicMock()
mock_settings.llm_model = 'settings-store-model'
mock_settings.llm_api_key = 'settings-store-api-key'
mock_settings.llm_base_url = None
mock_settings.agent = 'CodeActAgent'
mock_settings.confirmation_mode = False
mock_settings.enable_default_condenser = True
# Setup mocks
mock_load.return_value = mock_settings
mock_get_instance.return_value = MagicMock()
# Create mock args with -l parameter
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = 'current-dir-llm' # Specify LLM via CLI
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config using the actual CLI code path
with patch('os.path.exists', return_value=True):
config = setup_config_from_args(mock_args)
# Verify that -l parameter takes precedence over everything
assert config.get_llm_config().model == 'current-dir-specific-model'
assert (
config.get_llm_config().api_key.get_secret_value()
== 'current-dir-specific-api-key'
)
@patch('openhands.core.config.utils.os.path.expanduser')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.FileSettingsStore.load')
def test_cli_settings_json_not_override_config_toml(
mock_load, mock_get_instance, mock_expanduser, temp_config_files
):
"""Test that settings.json doesn't override config.toml in CLI mode."""
import importlib
import sys
from unittest.mock import patch
# First, ensure we can import the CLI main module
if 'openhands.cli.main' in sys.modules:
importlib.reload(sys.modules['openhands.cli.main'])
# Now import the specific function we want to test
from openhands.cli.main import setup_config_from_args
mock_expanduser.side_effect = lambda path: path.replace(
'~', temp_config_files['home_dir']
)
# Create mock settings with different values than config.toml
mock_settings = MagicMock()
mock_settings.llm_model = 'settings-json-model'
mock_settings.llm_api_key = 'settings-json-api-key'
mock_settings.llm_base_url = None
mock_settings.agent = 'CodeActAgent'
mock_settings.confirmation_mode = False
mock_settings.enable_default_condenser = True
# Setup mocks
mock_load.return_value = mock_settings
mock_get_instance.return_value = MagicMock()
# Create mock args with config file pointing to current directory config
mock_args = MagicMock()
mock_args.config_file = temp_config_files['current_dir_toml']
mock_args.llm_config = None # No CLI parameter
mock_args.agent_cls = None
mock_args.max_iterations = None
mock_args.max_budget_per_task = None
mock_args.selected_repo = None
# Load config using the actual CLI code path
with patch('os.path.exists', return_value=True):
setup_config_from_args(mock_args)
# Create a test LLM config to simulate the fix in CLI main.py
test_config = OpenHandsConfig()
test_llm_config = test_config.get_llm_config()
test_llm_config.model = 'config-toml-model'
test_llm_config.api_key = 'config-toml-api-key'
# Simulate the CLI main.py logic that we fixed
if not mock_args.llm_config and (test_llm_config.model or test_llm_config.api_key):
# Should NOT apply settings from settings.json
pass
else:
# This branch should not be taken in our test
test_llm_config.model = mock_settings.llm_model
test_llm_config.api_key = mock_settings.llm_api_key
# Verify that settings.json did not override config.toml
assert test_llm_config.model == 'config-toml-model'
assert test_llm_config.api_key == 'config-toml-api-key'

View File

@@ -0,0 +1,110 @@
"""Unit tests for file reader directory error handling."""
import tempfile
from pathlib import Path
import pytest
from openhands.runtime.plugins.agent_skills.file_reader.file_readers import (
_base64_img,
parse_audio,
parse_docx,
parse_latex,
parse_pdf,
parse_pptx,
)
class TestFileReaderDirectoryHandling:
"""Test file reader directory error handling."""
def setup_method(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.test_dir = Path(self.temp_dir) / 'test_directory'
self.test_dir.mkdir()
self.test_file = Path(self.temp_dir) / 'test_file.txt'
self.test_file.write_text('This is a test file.')
def teardown_method(self):
"""Clean up test fixtures."""
import shutil
shutil.rmtree(self.temp_dir)
def test_parse_latex_with_directory(self, capsys):
"""Test that parse_latex handles directory input gracefully."""
parse_latex(str(self.test_dir))
captured = capsys.readouterr()
assert 'ERROR: Cannot read directory as LaTeX file' in captured.out
assert str(self.test_dir) in captured.out
def test_parse_latex_with_nonexistent_file(self, capsys):
"""Test that parse_latex handles non-existent file gracefully."""
nonexistent_file = Path(self.temp_dir) / 'nonexistent.tex'
parse_latex(str(nonexistent_file))
captured = capsys.readouterr()
assert 'ERROR: File not found' in captured.out
assert str(nonexistent_file) in captured.out
def test_base64_img_with_directory(self):
"""Test that _base64_img raises IsADirectoryError for directory input."""
with pytest.raises(IsADirectoryError) as exc_info:
_base64_img(str(self.test_dir))
assert 'Cannot read directory as image file' in str(exc_info.value)
assert str(self.test_dir) in str(exc_info.value)
def test_base64_img_with_nonexistent_file(self):
"""Test that _base64_img raises FileNotFoundError for non-existent file."""
nonexistent_file = Path(self.temp_dir) / 'nonexistent.jpg'
with pytest.raises(FileNotFoundError) as exc_info:
_base64_img(str(nonexistent_file))
assert 'File not found' in str(exc_info.value)
assert str(nonexistent_file) in str(exc_info.value)
def test_parse_audio_with_directory(self, capsys):
"""Test that parse_audio handles directory input gracefully."""
parse_audio(str(self.test_dir))
captured = capsys.readouterr()
assert 'ERROR: Cannot read directory as audio file' in captured.out
assert str(self.test_dir) in captured.out
def test_parse_audio_with_nonexistent_file(self, capsys):
"""Test that parse_audio handles non-existent file gracefully."""
nonexistent_file = Path(self.temp_dir) / 'nonexistent.mp3'
parse_audio(str(nonexistent_file))
captured = capsys.readouterr()
assert 'ERROR: File not found' in captured.out
assert str(nonexistent_file) in captured.out
def test_parse_pdf_with_directory(self, capsys):
"""Test that parse_pdf handles directory input gracefully."""
parse_pdf(str(self.test_dir))
captured = capsys.readouterr()
assert 'ERROR: Cannot read directory as PDF file' in captured.out
assert str(self.test_dir) in captured.out
def test_parse_docx_with_directory(self, capsys):
"""Test that parse_docx handles directory input gracefully."""
parse_docx(str(self.test_dir))
captured = capsys.readouterr()
assert 'ERROR: Cannot read directory as DOCX file' in captured.out
assert str(self.test_dir) in captured.out
def test_parse_pptx_with_directory(self, capsys):
"""Test that parse_pptx handles directory input gracefully."""
parse_pptx(str(self.test_dir))
captured = capsys.readouterr()
assert 'ERROR: Cannot read directory as PowerPoint file' in captured.out
assert str(self.test_dir) in captured.out

View File

@@ -1,19 +1,12 @@
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pytest
from openhands.runtime.utils import git_changes, git_diff, git_handler
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
@pytest.mark.skipif(sys.platform == 'win32', reason='Windows is not supported')
class TestGitHandler(unittest.TestCase):
def setUp(self):
# Create temporary directories for our test repositories
@@ -27,17 +20,11 @@ class TestGitHandler(unittest.TestCase):
# Track executed commands for verification
self.executed_commands = []
self.created_files = []
# Initialize the GitHandler with our mock functions
self.git_handler = GitHandler(
execute_shell_fn=self._execute_command, create_file_fn=self._create_file
)
# Initialize the GitHandler with our real execute function
self.git_handler = GitHandler(self._execute_command)
self.git_handler.set_cwd(self.local_dir)
self.git_handler.git_changes_cmd = f'python3 {git_changes.__file__}'
self.git_handler.git_diff_cmd = f'python3 {git_diff.__file__} "{{file_path}}"'
# Set up the git repositories
self._setup_git_repos()
@@ -47,256 +34,202 @@ class TestGitHandler(unittest.TestCase):
def _execute_command(self, cmd, cwd=None):
"""Execute a shell command and return the result."""
result = subprocess.run(
args=cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
)
stderr = result.stderr or b''
stdout = result.stdout or b''
return CommandResult((stderr + stdout).decode(), result.returncode)
def run_command(self, cmd, cwd=None):
result = self._execute_command(cmd, cwd)
if result.exit_code != 0:
raise RuntimeError(
f'command_error:{cmd};{result.exit_code};{result.content}'
)
def _create_file(self, path, content):
"""Mock function for creating files."""
self.created_files.append((path, content))
self.executed_commands.append((cmd, cwd))
try:
with open(path, 'w') as f:
f.write(content)
return 0
except Exception:
return -1
def write_file(
self,
dir: str,
name: str,
additional_content: tuple[str, ...] = ('Line 1', 'Line 2', 'Line 3'),
):
with open(os.path.join(dir, name), 'w') as f:
f.write(name)
for line in additional_content:
f.write('\n')
f.write(line)
assert os.path.exists(os.path.join(dir, name))
result = subprocess.run(
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
)
return CommandResult(result.stdout, result.returncode)
except Exception as e:
return CommandResult(str(e), 1)
def _setup_git_repos(self):
"""Set up real git repositories for testing."""
# Set up origin repository
self.run_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
"git config user.email 'test@example.com'", self.origin_dir
'git --no-pager init --initial-branch=main', self.origin_dir
)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.origin_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Set up the initial state...
self.write_file(self.origin_dir, 'unchanged.txt')
self.write_file(self.origin_dir, 'committed_modified.txt')
self.write_file(self.origin_dir, 'staged_modified.txt')
self.write_file(self.origin_dir, 'unstaged_modified.txt')
self.write_file(self.origin_dir, 'committed_delete.txt')
self.write_file(self.origin_dir, 'staged_delete.txt')
self.write_file(self.origin_dir, 'unstaged_delete.txt')
self.run_command("git add . && git commit -m 'Initial Commit'", self.origin_dir)
# Create a file and commit it
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Original content')
self._execute_command('git --no-pager add file1.txt', self.origin_dir)
self._execute_command(
"git --no-pager commit -m 'Initial commit'", self.origin_dir
)
# Clone the origin repository to local
self.run_command(f'git clone "{self.origin_dir}" "{self.local_dir}"')
self._execute_command(
"git config user.email 'test@example.com'", self.local_dir
f'git --no-pager clone {self.origin_dir} {self.local_dir}'
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
self.run_command('git checkout -b feature-branch', self.local_dir)
# Setup committed changes...
self.write_file(self.local_dir, 'committed_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'committed_add.txt')
os.remove(os.path.join(self.local_dir, 'committed_delete.txt'))
self.run_command(
"git add . && git commit -m 'First batch of changes'", self.local_dir
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.local_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.local_dir
)
# Setup staged changes...
self.write_file(self.local_dir, 'staged_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'staged_add.txt')
os.remove(os.path.join(self.local_dir, 'staged_delete.txt'))
self.run_command('git add .', self.local_dir)
# Create a feature branch in the local repository
self._execute_command(
'git --no-pager checkout -b feature-branch', self.local_dir
)
# Setup unstaged changes...
self.write_file(self.local_dir, 'unstaged_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'unstaged_add.txt')
os.remove(os.path.join(self.local_dir, 'unstaged_delete.txt'))
# Modify a file and create a new file
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content')
def setup_nested(self):
nested_1 = Path(self.local_dir, 'nested 1')
nested_1.mkdir()
nested_1 = str(nested_1)
self.run_command('git init --initial-branch=main', nested_1)
self._execute_command("git config user.email 'test@example.com'", nested_1)
self._execute_command("git config user.name 'Test User'", nested_1)
self.write_file(nested_1, 'committed_add.txt')
self.run_command('git add .', nested_1)
self.run_command('git commit -m "Initial Commit"', nested_1)
self.write_file(nested_1, 'staged_add.txt')
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('New file content')
nested_2 = Path(self.local_dir, 'nested_2')
nested_2.mkdir()
nested_2 = str(nested_2)
self.run_command('git init --initial-branch=main', nested_2)
self._execute_command("git config user.email 'test@example.com'", nested_2)
self._execute_command("git config user.name 'Test User'", nested_2)
self.write_file(nested_2, 'committed_add.txt')
self.run_command('git add .', nested_2)
self.run_command('git commit -m "Initial Commit"', nested_2)
self.write_file(nested_2, 'unstaged_add.txt')
# Add and commit file1.txt changes to create a baseline
self._execute_command('git --no-pager add file1.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Update file1.txt'", self.local_dir
)
# Add and commit file2.txt, then modify it
self._execute_command('git --no-pager add file2.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file2.txt'", self.local_dir
)
# Modify file2.txt and stage it
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('Modified new file content')
self._execute_command('git --no-pager add file2.txt', self.local_dir)
# Create a file that will be deleted
with open(os.path.join(self.local_dir, 'file3.txt'), 'w') as f:
f.write('File to be deleted')
self._execute_command('git --no-pager add file3.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file3.txt'", self.local_dir
)
self._execute_command('git --no-pager rm file3.txt', self.local_dir)
# Modify file1.txt again but don't stage it (unstaged change)
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content again')
# Push the feature branch to origin
self._execute_command(
'git --no-pager push -u origin feature-branch', self.local_dir
)
def test_is_git_repo(self):
"""Test that _is_git_repo returns True for a git repository."""
self.assertTrue(self.git_handler._is_git_repo())
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager rev-parse --is-inside-work-tree'
for cmd, _ in self.executed_commands
)
)
def test_get_current_file_content(self):
"""Test that _get_current_file_content returns the current content of a file."""
content = self.git_handler._get_current_file_content('file1.txt')
self.assertEqual(content.strip(), 'Modified content again')
# Verify the command was executed
self.assertTrue(
any(cmd == 'cat file1.txt' for cmd, _ in self.executed_commands)
)
def test_get_git_changes(self):
"""
Test with unpushed commits, staged commits, and unstaged commits
"""
changes = self.git_handler.get_git_changes()
"""Test that get_git_changes returns the combined list of changed and untracked files."""
# Create an untracked file
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
f.write('Untracked file content')
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
assert changes == expected_changes
def test_get_git_changes_after_push(self):
"""
Test with staged commits, and unstaged commits
"""
self.run_command('git push -u origin feature-branch', self.local_dir)
changes = self.git_handler.get_git_changes()
expected_changes = [
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
assert changes == expected_changes
def test_get_git_changes_nested_repos(self):
"""
Test with staged commits, and unstaged commits
"""
self.setup_nested()
# Create a new file and stage it
with open(os.path.join(self.local_dir, 'new_file2.txt'), 'w') as f:
f.write('New file 2 content')
self._execute_command('git --no-pager add new_file2.txt', self.local_dir)
changes = self.git_handler.get_git_changes()
self.assertIsNotNone(changes)
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'nested 1/committed_add.txt'},
{'status': 'A', 'path': 'nested 1/staged_add.txt'},
{'status': 'A', 'path': 'nested_2/committed_add.txt'},
{'status': 'A', 'path': 'nested_2/unstaged_add.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
# Should include file1.txt (modified), file3.txt (deleted), new_file2.txt (added), and untracked.txt (untracked)
paths = [change['path'] for change in changes]
self.assertIn('file1.txt', paths)
self.assertIn('file3.txt', paths)
self.assertIn('new_file2.txt', paths)
self.assertIn('untracked.txt', paths)
assert changes == expected_changes
# Check that the changes include both changed and untracked files
statuses = [change['status'] for change in changes]
self.assertIn('M', statuses) # Modified
self.assertIn('A', statuses) # Added
self.assertIn('D', statuses) # Deleted
def test_get_git_diff_staged_modified(self):
"""Test on a staged modified"""
diff = self.git_handler.get_git_diff('staged_modified.txt')
expected_diff = {
'original': 'staged_modified.txt\nLine 1\nLine 2\nLine 3',
'modified': 'staged_modified.txt\nLine 4',
}
assert diff == expected_diff
def test_get_git_changes_multiple_repositories(self):
"""Test that get_git_changes can detect changes in multiple git repositories within a workspace."""
# Create a workspace directory with multiple git repositories
workspace_dir = os.path.join(self.test_dir, 'workspace')
repo1_dir = os.path.join(workspace_dir, 'repo1')
repo2_dir = os.path.join(workspace_dir, 'repo2')
non_git_dir = os.path.join(workspace_dir, 'non_git')
def test_get_git_diff_unchanged(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('unchanged.txt')
expected_diff = {
'original': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
'modified': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff
os.makedirs(workspace_dir, exist_ok=True)
os.makedirs(repo1_dir, exist_ok=True)
os.makedirs(repo2_dir, exist_ok=True)
os.makedirs(non_git_dir, exist_ok=True)
def test_get_git_diff_unpushed(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('committed_modified.txt')
expected_diff = {
'original': 'committed_modified.txt\nLine 1\nLine 2\nLine 3',
'modified': 'committed_modified.txt\nLine 4',
}
assert diff == expected_diff
# Set up repo1
self._execute_command('git --no-pager init', repo1_dir)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", repo1_dir
)
self._execute_command("git --no-pager config user.name 'Test User'", repo1_dir)
with open(os.path.join(repo1_dir, 'repo1_file.txt'), 'w') as f:
f.write('repo1 content')
self._execute_command('git --no-pager add repo1_file.txt', repo1_dir)
self._execute_command("git --no-pager commit -m 'Initial commit'", repo1_dir)
# Modify the file to create changes
with open(os.path.join(repo1_dir, 'repo1_file.txt'), 'w') as f:
f.write('repo1 modified content')
def test_get_git_diff_unstaged_add(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('unstaged_add.txt')
expected_diff = {
'original': '',
'modified': 'unstaged_add.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff
# Set up repo2
self._execute_command('git --no-pager init', repo2_dir)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", repo2_dir
)
self._execute_command("git --no-pager config user.name 'Test User'", repo2_dir)
with open(os.path.join(repo2_dir, 'repo2_file.txt'), 'w') as f:
f.write('repo2 content')
self._execute_command('git --no-pager add repo2_file.txt', repo2_dir)
self._execute_command("git --no-pager commit -m 'Initial commit'", repo2_dir)
# Add an untracked file
with open(os.path.join(repo2_dir, 'untracked.txt'), 'w') as f:
f.write('untracked content')
def test_get_git_changes_fallback(self):
"""Test that get_git_changes falls back to creating a script file when needed."""
# Add a file to the non-git directory (should be ignored)
with open(os.path.join(non_git_dir, 'ignored_file.txt'), 'w') as f:
f.write('ignored content')
# Break the git changes command
with patch(
'openhands.runtime.utils.git_handler.GIT_CHANGES_CMD',
'non-existant-command',
):
self.git_handler.git_changes_cmd = git_handler.GIT_CHANGES_CMD
# Create a GitHandler for the workspace directory
workspace_handler = GitHandler(self._execute_command)
workspace_handler.set_cwd(workspace_dir)
changes = self.git_handler.get_git_changes()
# Clear executed commands to start fresh
self.executed_commands = []
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
# Get changes from all repositories
changes = workspace_handler.get_git_changes()
self.assertIsNotNone(changes)
assert changes == expected_changes
def test_get_git_diff_fallback(self):
"""Test that get_git_diff delegates to the git_diff module."""
# Break the git diff command
with patch(
'openhands.runtime.utils.git_handler.GIT_DIFF_CMD', 'non-existant-command'
):
self.git_handler.git_diff_cmd = git_handler.GIT_DIFF_CMD
diff = self.git_handler.get_git_diff('unchanged.txt')
expected_diff = {
'original': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
'modified': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff
# Should find changes from both repositories
assert len(changes) == 2
assert {'status': 'M', 'path': 'repo1/repo1_file.txt'} in changes
assert {'status': 'A', 'path': 'repo2/untracked.txt'} in changes

View File

@@ -0,0 +1,120 @@
import os
import shutil
import subprocess
import tempfile
import unittest
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
class TestGitHandlerWithRealRepo(unittest.TestCase):
def setUp(self):
# Create temporary directories for our test repositories
self.test_dir = tempfile.mkdtemp()
self.origin_dir = os.path.join(self.test_dir, 'origin')
self.local_dir = os.path.join(self.test_dir, 'local')
# Create the directories
os.makedirs(self.origin_dir, exist_ok=True)
os.makedirs(self.local_dir, exist_ok=True)
# Set up the git repositories
self._setup_git_repos()
# Initialize the GitHandler with a real execute function
self.git_handler = GitHandler(self._execute_command)
self.git_handler.set_cwd(self.local_dir)
def tearDown(self):
# Clean up the temporary directories
shutil.rmtree(self.test_dir)
def _execute_command(self, cmd, cwd=None):
"""Execute a shell command and return the result."""
try:
result = subprocess.run(
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
)
return CommandResult(result.stdout, result.returncode)
except Exception as e:
return CommandResult(str(e), 1)
def _setup_git_repos(self):
"""Set up real git repositories for testing."""
# Set up origin repository
self._execute_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
"git config user.email 'test@example.com'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Create a file and commit it
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Original content')
self._execute_command('git add file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
# Clone the origin repository to local
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
self._execute_command(
"git config user.email 'test@example.com'", self.local_dir
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
# Create a feature branch in the local repository
self._execute_command('git checkout -b feature-branch', self.local_dir)
# Modify a file and create a new file
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content')
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('New file content')
# Add the new file but don't commit anything yet
self._execute_command('git add file2.txt', self.local_dir)
def test_is_git_repo(self):
"""Test that _is_git_repo returns True for a git repository."""
self.assertTrue(self.git_handler._is_git_repo())
def test_get_ref_content(self):
"""Test that _get_ref_content returns the content from a valid ref."""
# First commit the changes to make sure we have a valid ref
self._execute_command('git add file1.txt', self.local_dir)
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
# Get the content of file1.txt from the main branch
content = self.git_handler._get_ref_content('file1.txt')
self.assertEqual(content.strip(), 'Original content')
def test_get_current_file_content(self):
"""Test that _get_current_file_content returns the current content of a file."""
content = self.git_handler._get_current_file_content('file1.txt')
self.assertEqual(content.strip(), 'Modified content')
def test_get_git_changes(self):
"""Test that get_git_changes returns the combined list of changed and untracked files."""
# Create an untracked file
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
f.write('Untracked file content')
changes = self.git_handler.get_git_changes()
self.assertIsNotNone(changes)
# Should include file1.txt (modified), file2.txt (added), and untracked.txt (untracked)
paths = [change['path'] for change in changes]
self.assertIn('file1.txt', paths)
self.assertIn('file2.txt', paths)
self.assertIn('untracked.txt', paths)
def test_get_git_diff(self):
"""Test that get_git_diff returns the original and modified content of a file."""
diff = self.git_handler.get_git_diff('file1.txt')
self.assertEqual(diff['modified'].strip(), 'Modified content')
self.assertEqual(diff['original'].strip(), 'Original content')
if __name__ == '__main__':
unittest.main()

View File

@@ -1,7 +1,7 @@
from unittest.mock import patch
from unittest.mock import mock_open, patch
from openhands.core.config import OpenHandsConfig
from openhands.io import read_input
from openhands.io import read_input, read_task_from_file
def test_single_line_input():
@@ -25,3 +25,26 @@ def test_multiline_input():
with patch('builtins.input', side_effect=mock_inputs):
result = read_input(config.cli_multiline_input)
assert result == 'line 1\nline 2\nline 3'
def test_read_task_from_file():
"""Test that read_task_from_file works correctly for regular files"""
mock_content = 'This is a task from a file'
with (
patch('os.path.isdir', return_value=False),
patch('builtins.open', mock_open(read_data=mock_content)),
):
result = read_task_from_file('task.txt')
assert result == mock_content
def test_read_task_from_file_directory_error():
"""Test that read_task_from_file raises IsADirectoryError when trying to read a directory"""
with patch('os.path.isdir', return_value=True):
try:
read_task_from_file('/some/directory')
raise AssertionError('Expected IsADirectoryError to be raised')
except IsADirectoryError as e:
assert 'is a directory, not a file' in str(e)
assert '/some/directory' in str(e)

View File

@@ -733,31 +733,34 @@ def test_completion_with_litellm_mock(mock_litellm_completion, default_config):
@patch('openhands.llm.llm.litellm_completion')
def test_llm_gemini_thinking_parameter(mock_litellm_completion, default_config):
"""
Test that the 'thinking' parameter is correctly passed to litellm_completion
when a Gemini model is used with 'low' reasoning_effort.
"""
# Configure for Gemini model with low reasoning effort
gemini_config = copy.deepcopy(default_config)
gemini_config.model = 'gemini-2.5-pro'
gemini_config.reasoning_effort = 'low'
# Mock the response from litellm
mock_litellm_completion.return_value = {
'choices': [{'message': {'content': 'Test response'}}]
def test_completion_with_two_positional_args(mock_litellm_completion, default_config):
mock_response = {
'choices': [{'message': {'content': 'Response to positional args.'}}]
}
mock_litellm_completion.return_value = mock_response
# Initialize LLM and call completion
llm = LLM(config=gemini_config)
llm.completion(messages=[{'role': 'user', 'content': 'Hello!'}])
test_llm = LLM(config=default_config)
response = test_llm.completion(
'some-model-to-be-ignored',
[{'role': 'user', 'content': 'Hello from positional args!'}],
stream=False,
)
# Verify that litellm_completion was called with the 'thinking' parameter
# Assertions
assert (
response['choices'][0]['message']['content'] == 'Response to positional args.'
)
mock_litellm_completion.assert_called_once()
# Check if the correct arguments were passed to litellm_completion
call_args, call_kwargs = mock_litellm_completion.call_args
assert 'thinking' in call_kwargs
assert call_kwargs['thinking'] == {'budget_tokens': 128}
assert 'reasoning_effort' not in call_kwargs
assert (
call_kwargs['model'] == default_config.model
) # Should use the model from config, not the first arg
assert call_kwargs['messages'] == [
{'role': 'user', 'content': 'Hello from positional args!'}
]
assert not call_kwargs['stream']
# Ensure the first positional argument (model) was ignored
assert (
@@ -1108,203 +1111,3 @@ def test_azure_model_default_max_tokens():
# Verify the config has the default max_output_tokens value
assert llm.config.max_output_tokens is None # Default value
# Gemini Performance Optimization Tests
def test_gemini_model_keeps_none_reasoning_effort():
"""Test that Gemini models keep reasoning_effort=None for optimization."""
config = LLMConfig(model='gemini-2.5-pro', api_key='test_key')
# reasoning_effort should remain None for Gemini models
assert config.reasoning_effort is None
def test_non_gemini_model_gets_high_reasoning_effort():
"""Test that non-Gemini models get reasoning_effort='high' by default."""
config = LLMConfig(model='gpt-4o', api_key='test_key')
# Non-Gemini models should get reasoning_effort='high'
assert config.reasoning_effort == 'high'
def test_explicit_reasoning_effort_preserved():
"""Test that explicitly set reasoning_effort is preserved."""
config = LLMConfig(
model='gemini-2.5-pro', api_key='test_key', reasoning_effort='medium'
)
# Explicitly set reasoning_effort should be preserved
assert config.reasoning_effort == 'medium'
@patch('openhands.llm.llm.litellm_completion')
def test_gemini_none_reasoning_effort_uses_thinking_budget(mock_completion):
"""Test that Gemini with reasoning_effort=None uses thinking budget."""
config = LLMConfig(
model='gemini-2.5-pro', api_key='test_key', reasoning_effort=None
)
# Mock the completion response
mock_completion.return_value = {
'choices': [{'message': {'content': 'Test response'}}],
'usage': {'prompt_tokens': 10, 'completion_tokens': 5},
}
llm = LLM(config)
sample_messages = [{'role': 'user', 'content': 'Hello, how are you?'}]
llm.completion(messages=sample_messages)
# Verify that thinking budget was set and reasoning_effort was None
call_kwargs = mock_completion.call_args[1]
assert 'thinking' in call_kwargs
assert call_kwargs['thinking'] == {'budget_tokens': 128}
assert call_kwargs.get('reasoning_effort') is None
@patch('openhands.llm.llm.litellm_completion')
def test_gemini_low_reasoning_effort_uses_thinking_budget(mock_completion):
"""Test that Gemini with reasoning_effort='low' uses thinking budget."""
config = LLMConfig(
model='gemini-2.5-pro', api_key='test_key', reasoning_effort='low'
)
# Mock the completion response
mock_completion.return_value = {
'choices': [{'message': {'content': 'Test response'}}],
'usage': {'prompt_tokens': 10, 'completion_tokens': 5},
}
llm = LLM(config)
sample_messages = [{'role': 'user', 'content': 'Hello, how are you?'}]
llm.completion(messages=sample_messages)
# Verify that thinking budget was set and reasoning_effort was None
call_kwargs = mock_completion.call_args[1]
assert 'thinking' in call_kwargs
assert call_kwargs['thinking'] == {'budget_tokens': 128}
assert call_kwargs.get('reasoning_effort') is None
@patch('openhands.llm.llm.litellm_completion')
def test_gemini_medium_reasoning_effort_passes_through(mock_completion):
"""Test that Gemini with reasoning_effort='medium' passes through to litellm."""
config = LLMConfig(
model='gemini-2.5-pro', api_key='test_key', reasoning_effort='medium'
)
# Mock the completion response
mock_completion.return_value = {
'choices': [{'message': {'content': 'Test response'}}],
'usage': {'prompt_tokens': 10, 'completion_tokens': 5},
}
llm = LLM(config)
sample_messages = [{'role': 'user', 'content': 'Hello, how are you?'}]
llm.completion(messages=sample_messages)
# Verify that reasoning_effort was passed through and thinking budget was not set
call_kwargs = mock_completion.call_args[1]
assert 'thinking' not in call_kwargs
assert call_kwargs.get('reasoning_effort') == 'medium'
@patch('openhands.llm.llm.litellm_completion')
def test_gemini_high_reasoning_effort_passes_through(mock_completion):
"""Test that Gemini with reasoning_effort='high' passes through to litellm."""
config = LLMConfig(
model='gemini-2.5-pro', api_key='test_key', reasoning_effort='high'
)
# Mock the completion response
mock_completion.return_value = {
'choices': [{'message': {'content': 'Test response'}}],
'usage': {'prompt_tokens': 10, 'completion_tokens': 5},
}
llm = LLM(config)
sample_messages = [{'role': 'user', 'content': 'Hello, how are you?'}]
llm.completion(messages=sample_messages)
# Verify that reasoning_effort was passed through and thinking budget was not set
call_kwargs = mock_completion.call_args[1]
assert 'thinking' not in call_kwargs
assert call_kwargs.get('reasoning_effort') == 'high'
@patch('openhands.llm.llm.litellm_completion')
def test_non_gemini_uses_reasoning_effort(mock_completion):
"""Test that non-Gemini models use reasoning_effort instead of thinking budget."""
config = LLMConfig(model='o1', api_key='test_key', reasoning_effort='high')
# Mock the completion response
mock_completion.return_value = {
'choices': [{'message': {'content': 'Test response'}}],
'usage': {'prompt_tokens': 10, 'completion_tokens': 5},
}
llm = LLM(config)
sample_messages = [{'role': 'user', 'content': 'Hello, how are you?'}]
llm.completion(messages=sample_messages)
# Verify that reasoning_effort was used and thinking budget was not set
call_kwargs = mock_completion.call_args[1]
assert 'thinking' not in call_kwargs
assert call_kwargs.get('reasoning_effort') == 'high'
@patch('openhands.llm.llm.litellm_completion')
def test_non_reasoning_model_no_optimization(mock_completion):
"""Test that non-reasoning models don't get optimization parameters."""
config = LLMConfig(
model='gpt-3.5-turbo', # Not in REASONING_EFFORT_SUPPORTED_MODELS
api_key='test_key',
)
# Mock the completion response
mock_completion.return_value = {
'choices': [{'message': {'content': 'Test response'}}],
'usage': {'prompt_tokens': 10, 'completion_tokens': 5},
}
llm = LLM(config)
sample_messages = [{'role': 'user', 'content': 'Hello, how are you?'}]
llm.completion(messages=sample_messages)
# Verify that neither thinking budget nor reasoning_effort were set
call_kwargs = mock_completion.call_args[1]
assert 'thinking' not in call_kwargs
assert 'reasoning_effort' not in call_kwargs
@patch('openhands.llm.llm.litellm_completion')
def test_gemini_performance_optimization_end_to_end(mock_completion):
"""Test the complete Gemini performance optimization flow end-to-end."""
# Mock the completion response
mock_completion.return_value = {
'choices': [{'message': {'content': 'Optimized response'}}],
'usage': {'prompt_tokens': 50, 'completion_tokens': 25},
}
# Create Gemini configuration
config = LLMConfig(model='gemini-2.5-pro', api_key='test_key')
# Verify config has optimized defaults
assert config.reasoning_effort is None
# Create LLM and make completion
llm = LLM(config)
messages = [{'role': 'user', 'content': 'Solve this complex problem'}]
response = llm.completion(messages=messages)
# Verify response was generated
assert response['choices'][0]['message']['content'] == 'Optimized response'
# Verify optimization parameters were applied
call_kwargs = mock_completion.call_args[1]
assert 'thinking' in call_kwargs
assert call_kwargs['thinking'] == {'budget_tokens': 128}
assert call_kwargs.get('reasoning_effort') is None
# Verify temperature and top_p were removed for reasoning models
assert 'temperature' not in call_kwargs
assert 'top_p' not in call_kwargs

View File

@@ -1,118 +0,0 @@
"""Integration test for MCP settings merging in the full flow."""
from unittest.mock import AsyncMock, patch
import pytest
from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
from openhands.storage.data_models.settings import Settings
from openhands.storage.settings.file_settings_store import FileSettingsStore
@pytest.mark.asyncio
async def test_user_auth_mcp_merging_integration():
"""Test that MCP merging works in the user auth flow."""
# Mock config.toml settings
config_settings = Settings(
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://config-server.com')]
)
)
# Mock stored frontend settings
stored_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')]
),
)
# Create user auth instance
user_auth = DefaultUserAuth()
# Mock the settings store to return stored settings
mock_settings_store = AsyncMock(spec=FileSettingsStore)
mock_settings_store.load.return_value = stored_settings
with patch.object(
user_auth, 'get_user_settings_store', return_value=mock_settings_store
):
with patch.object(Settings, 'from_config', return_value=config_settings):
# Get user settings - this should trigger the merging
merged_settings = await user_auth.get_user_settings()
# Verify merging worked correctly
assert merged_settings is not None
assert merged_settings.llm_model == 'gpt-4'
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 2
# Config.toml server should come first (priority)
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.mcp_config.sse_servers[1].url == 'http://frontend-server.com'
@pytest.mark.asyncio
async def test_user_auth_caching_behavior():
"""Test that user auth caches the merged settings correctly."""
config_settings = Settings(
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://config-server.com')]
)
)
stored_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')]
),
)
user_auth = DefaultUserAuth()
mock_settings_store = AsyncMock(spec=FileSettingsStore)
mock_settings_store.load.return_value = stored_settings
with patch.object(
user_auth, 'get_user_settings_store', return_value=mock_settings_store
):
with patch.object(
Settings, 'from_config', return_value=config_settings
) as mock_from_config:
# First call should load and merge
settings1 = await user_auth.get_user_settings()
# Second call should use cached version
settings2 = await user_auth.get_user_settings()
# Verify both calls return the same merged settings
assert settings1 is settings2
assert len(settings1.mcp_config.sse_servers) == 2
# Settings store should only be called once (first time)
mock_settings_store.load.assert_called_once()
# from_config should only be called once (during merging)
mock_from_config.assert_called_once()
@pytest.mark.asyncio
async def test_user_auth_no_stored_settings():
"""Test behavior when no settings are stored (first time user)."""
user_auth = DefaultUserAuth()
# Mock settings store to return None (no stored settings)
mock_settings_store = AsyncMock(spec=FileSettingsStore)
mock_settings_store.load.return_value = None
with patch.object(
user_auth, 'get_user_settings_store', return_value=mock_settings_store
):
settings = await user_auth.get_user_settings()
# Should return None when no settings are stored
assert settings is None

View File

@@ -1,144 +0,0 @@
"""Test MCP settings merging functionality."""
from unittest.mock import patch
import pytest
from openhands.core.config.mcp_config import (
MCPConfig,
MCPSSEServerConfig,
MCPStdioServerConfig,
)
from openhands.storage.data_models.settings import Settings
@pytest.mark.asyncio
async def test_mcp_settings_merge_config_only():
"""Test merging when only config.toml has MCP settings."""
# Mock config.toml with MCP settings
mock_config_settings = Settings(
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://config-server.com')]
)
)
# Frontend settings without MCP config
frontend_settings = Settings(llm_model='gpt-4')
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should use config.toml MCP settings
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.llm_model == 'gpt-4'
@pytest.mark.asyncio
async def test_mcp_settings_merge_frontend_only():
"""Test merging when only frontend has MCP settings."""
# Mock config.toml without MCP settings
mock_config_settings = Settings(llm_model='claude-3')
# Frontend settings with MCP config
frontend_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')]
),
)
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend MCP settings
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://frontend-server.com'
assert merged_settings.llm_model == 'gpt-4'
@pytest.mark.asyncio
async def test_mcp_settings_merge_both_present():
"""Test merging when both config.toml and frontend have MCP settings."""
# Mock config.toml with MCP settings
mock_config_settings = Settings(
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://config-server.com')],
stdio_servers=[
MCPStdioServerConfig(
name='config-stdio', command='config-cmd', args=['arg1']
)
],
)
)
# Frontend settings with different MCP config
frontend_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')],
stdio_servers=[
MCPStdioServerConfig(
name='frontend-stdio', command='frontend-cmd', args=['arg2']
)
],
),
)
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should merge both with config.toml taking priority (appearing first)
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 2
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.mcp_config.sse_servers[1].url == 'http://frontend-server.com'
assert len(merged_settings.mcp_config.stdio_servers) == 2
assert merged_settings.mcp_config.stdio_servers[0].name == 'config-stdio'
assert merged_settings.mcp_config.stdio_servers[1].name == 'frontend-stdio'
assert merged_settings.llm_model == 'gpt-4'
@pytest.mark.asyncio
async def test_mcp_settings_merge_no_config():
"""Test merging when config.toml has no MCP settings."""
# Mock config.toml without MCP settings
mock_config_settings = None
# Frontend settings with MCP config
frontend_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')]
),
)
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend settings unchanged
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://frontend-server.com'
assert merged_settings.llm_model == 'gpt-4'
@pytest.mark.asyncio
async def test_mcp_settings_merge_neither_present():
"""Test merging when neither config.toml nor frontend have MCP settings."""
# Mock config.toml without MCP settings
mock_config_settings = Settings(llm_model='claude-3')
# Frontend settings without MCP config
frontend_settings = Settings(llm_model='gpt-4')
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend settings unchanged
assert merged_settings.mcp_config is None
assert merged_settings.llm_model == 'gpt-4'