mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
3 Commits
0.51.0
...
fix-direct
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c25795e094 | ||
|
|
7bb84c1d02 | ||
|
|
a88f8d3851 |
2
.github/workflows/fe-unit-tests.yml
vendored
2
.github/workflows/fe-unit-tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
8
Makefile
8
Makefile
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -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` を実行してください。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,7 +72,6 @@ describe("HomeHeader", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// expect to be redirected to /conversations/:conversationId
|
||||
|
||||
@@ -209,7 +209,6 @@ describe("RepoConnector", () => {
|
||||
undefined,
|
||||
"main",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ describe("TaskCard", () => {
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
2569
frontend/package-lock.json
generated
2569
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -56,9 +56,6 @@ export interface GetConfigResponse {
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
HIDE_MICROAGENT_MANAGEMENT?: boolean;
|
||||
};
|
||||
MAINTENANCE?: {
|
||||
startTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetVSCodeUrlResponse {
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "マイクロエージェントを起動するためのリポジトリが見つかりません",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)}))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
88
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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 = "*"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
83
tests/unit/test_cli_runtime_directory_fix.py
Normal file
83
tests/unit/test_cli_runtime_directory_fix.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
110
tests/unit/test_file_reader_directory_fix.py
Normal file
110
tests/unit/test_file_reader_directory_fix.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
120
tests/unit/test_git_handler_real.py
Normal file
120
tests/unit/test_git_handler_real.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user