Compare commits

..

34 Commits

Author SHA1 Message Date
Xingyao Wang 877a857e55 Merge branch 'main' into fix-timeout-message-tests 2025-08-15 15:40:45 -04:00
openhands 2a52902db8 Revert implementation changes - keep main branch implementation 2025-08-15 19:39:05 +00:00
openhands 02a5b7c307 Remove prompt changes - keep only timeout message fixes 2025-08-15 19:35:20 +00:00
openhands 4d4645068c Fix timeout message format to match main branch
- Updated TIMEOUT_MESSAGE_TEMPLATE in bash_constants.py to include specific key combinations ('C-c', 'C-z', 'C-d')
- Modified bash.py to use TIMEOUT_MESSAGE_TEMPLATE instead of hardcoded message
- Modified windows_bash.py to use TIMEOUT_MESSAGE_TEMPLATE instead of hardcoded message
- Ensures consistent timeout message format across all bash implementations
- Fixes failing runtime tests related to timeout error prompt changes
2025-08-15 19:24:03 +00:00
openhands 8e30ecca11 Revert unintended default system prompt change
The change to set system_prompt_filename default to 'system_prompt_tech_philosophy.j2'
was unintended. Reverting back to the original default 'system_prompt.j2' and
updating the test accordingly.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 18:42:34 +00:00
openhands 9722d23f38 Fix test to match new default system prompt filename
The default system_prompt_filename was changed from 'system_prompt.j2' to
'system_prompt_tech_philosophy.j2' in the AgentConfig, so the test needed
to be updated to reflect this change.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 18:26:44 +00:00
Xingyao Wang 587b4c311a runtime(bash): clarify guidance when previous command still running; recommend execute_bash timeout (issue #10350) (#10389)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 02:15:05 +08:00
Xingyao Wang 7a86402c9c Add process management guidance to system prompt (#10083)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 02:09:20 +08:00
Insop 06d283dfa0 Temp fix for docker file and debug log (#10345) 2025-08-15 15:34:46 +00:00
sp.wack a6a4246e30 fix(frontend): Failing tests (#10369) 2025-08-15 15:18:35 +00:00
Xingyao Wang 4830b9a67d fix(llm): include gpt-5 to fn call model; set top p default value to None (#10363) 2025-08-15 15:08:01 +00:00
Neeraj Panwar d4489d62d7 fix(llm): set AWS credentials in config.toml (#10351) 2025-08-15 22:16:50 +08:00
Ryan H. Tran e41c020073 [CLI] Fix MCP toml formatting issue (#10312)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 13:56:46 +00:00
Ryan H. Tran 3f44c8436f Fix swebench modal patch eval intermittent crash (#10353) 2025-08-15 21:51:03 +08:00
Graham Neubig b740944075 Split E2E settings and conversation tests; run settings first in workflow (#10359)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 09:19:27 -04:00
dependabot[bot] 5618a3eebb chore(deps): bump the version-all group in /frontend with 9 updates (#10318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-15 16:51:41 +04:00
Hiep Le a1ffe5c936 fix(frontend): frontend UI keep flashing (#10352) 2025-08-15 12:19:30 +04:00
Hiep Le f8376a9702 fix(frontend): status message missing (#10349) 2025-08-15 12:11:42 +07:00
Tim O'Farrell 985a634d60 Fix for issue where static system commands are truncated (#10292)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 04:16:50 +00:00
Xingyao Wang e40681ca61 fix: increase max branches limit to 5000 to fix #10332 (#10333) 2025-08-14 20:44:12 +00:00
mamoodi 228e50df9c Release 0.53.0 (#10314) 2025-08-14 16:43:01 -04:00
llamantino fd805eb835 fix(cli): remove unused mouse support and fix settings autocomplete (#10329) 2025-08-15 02:59:41 +08:00
Graham Neubig 426350224b Add Playwright-based end-to-end testing workflow (#10116)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-14 18:59:06 +00:00
Xingyao Wang 9b78a5e200 update default prompt name 2025-08-14 13:49:50 -04:00
Xingyao Wang 1ce3723b60 rename and add file back 2025-08-14 13:48:44 -04:00
Xingyao Wang 95a32ae459 set default 2025-08-14 13:18:22 -04:00
Xingyao Wang 9be0acea9c improve prompt 2025-08-14 13:15:01 -04:00
test 1a5965b951 Merge system prompt templates into experimental version
- Combine system_prompt_interactive.j2 and system_prompt_roleplay.j2 into system_prompt_experimental.j2
- Use include directive to avoid code duplication and maintain DRY principle
- Integrate interactive rules for handling vague instructions and clarification protocols
- Incorporate Linus Torvalds technical philosophy with constructive communication style
- Replace harsh language with professional, educational feedback approach
- Maintain technical rigor while ensuring respectful user interactions

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-14 17:03:53 +00:00
Xingyao Wang f5cbb26770 minor tweak and reformat 2025-08-14 12:49:49 -04:00
Xingyao Wang 8caad14eb8 update prompt 2025-08-14 12:15:06 -04:00
Tim O'Farrell 3e36911038 Add unit test to detect circular imports (#10233)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-14 09:02:09 -06:00
Xingyao Wang 43e6ce631a roleplay prompt 2025-08-14 00:11:38 -04:00
Graham Neubig 4c3ba62665 Fix i18n language code handling to prevent 404 errors on first load (#10257)
Co-authored-by: OpenHands <openhands@all-hands.dev>
2025-08-14 00:10:49 -04:00
Bashwara Undupitiya f5e7c602dc Jira, Jira DC and Linear integration UI tweaks (#10285) 2025-08-14 00:02:59 -04:00
74 changed files with 2566 additions and 880 deletions
+223
View File
@@ -0,0 +1,223 @@
name: End-to-End Tests
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
branches:
- main
- develop
workflow_dispatch:
jobs:
e2e-tests:
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
GITHUB_REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v3
with:
poetry-version: 2.1.3
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'poetry'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
- name: Setup environment for end-to-end tests
run: |
# Create test results directory
mkdir -p test-results
# Create downloads directory for OpenHands (use a directory in the home folder)
mkdir -p $HOME/downloads
sudo chown -R $USER:$USER $HOME/downloads
sudo chmod -R 755 $HOME/downloads
- name: Build OpenHands
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
INSTALL_DOCKER: 1
RUNTIME: docker
FRONTEND_PORT: 12000
FRONTEND_HOST: 0.0.0.0
BACKEND_HOST: 0.0.0.0
BACKEND_PORT: 3000
ENABLE_BROWSER: true
INSTALL_PLAYWRIGHT: 1
run: |
# Fix poetry.lock file if needed
echo "Fixing poetry.lock file if needed..."
poetry lock
# Build OpenHands using make build
echo "Running make build..."
make build
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
echo "Installing Chromium Headless Shell for Playwright..."
poetry run playwright install chromium-headless-shell
# Verify Playwright browsers are installed (for e2e tests only)
echo "Verifying Playwright browsers installation for e2e tests..."
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
echo "ERROR: Chromium browser not found or not working for e2e tests"
echo "$BROWSER_CHECK"
exit 1
else
echo "Playwright browsers are properly installed for e2e tests."
fi
# Docker runtime will handle workspace directory creation
# Start the application using make run with custom parameters and reduced logging
echo "Starting OpenHands using make run..."
# Set environment variables to reduce logging verbosity
export PYTHONUNBUFFERED=1
export LOG_LEVEL=WARNING
export UVICORN_LOG_LEVEL=warning
export OPENHANDS_LOG_LEVEL=WARNING
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
# Store the PID of the make run process
MAKE_PID=$!
echo "OpenHands started with PID: $MAKE_PID"
# Wait for the application to start
echo "Waiting for OpenHands to start..."
max_attempts=15
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
# Check if the process is still running
if ! ps -p $MAKE_PID > /dev/null; then
echo "ERROR: OpenHands process has terminated unexpectedly"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Check if frontend port is open
if nc -z localhost 12000; then
# Verify we can get HTML content
if curl -s http://localhost:12000 | grep -q "<html"; then
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
break
else
echo "Port 12000 is open but not serving HTML content yet"
fi
else
echo "Frontend port 12000 is not open yet"
fi
# Show log output on each attempt
echo "Recent log output:"
tail -n 20 /tmp/openhands-e2e-test.log
# Wait before next attempt
echo "Waiting 10 seconds before next check..."
sleep 10
attempt=$((attempt + 1))
# Exit if we've reached the maximum number of attempts
if [ $attempt -gt $max_attempts ]; then
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
done
# Final verification that the app is running
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
echo "ERROR: OpenHands is not running properly on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Print success message
echo "OpenHands is running successfully on port 12000"
- name: Run end-to-end tests
env:
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
# Check if the application is running
if ! nc -z localhost 12000; then
echo "ERROR: OpenHands is not running on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Run the tests with detailed output
cd tests/e2e
poetry run python -m pytest test_settings.py::test_github_token_configuration test_conversation.py::test_conversation_start -v --no-header --capture=no --timeout=600
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/e2e/test-results/
retention-days: 30
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v4
with:
name: openhands-logs
path: |
/tmp/openhands-e2e-test.log
/tmp/openhands-e2e-build.log
/tmp/openhands-backend.log
/tmp/openhands-frontend.log
/tmp/backend-health-check.log
/tmp/frontend-check.log
/tmp/vite-config.log
/tmp/makefile-contents.log
retention-days: 30
- name: Cleanup
if: always()
run: |
# Stop OpenHands processes
echo "Stopping OpenHands processes..."
pkill -f "python -m openhands.server" || true
pkill -f "npm run dev" || true
pkill -f "make run" || true
# Print process status for debugging
echo "Checking if any OpenHands processes are still running:"
ps aux | grep -E "openhands|npm run dev" || true
-2
View File
@@ -51,8 +51,6 @@ jobs:
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv ./tests/unit
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
- name: Run E2E Tests
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest -svv tests/e2e
# Run specific Windows python tests
test-on-windows:
+3
View File
@@ -254,3 +254,6 @@ containers/runtime/Dockerfile
containers/runtime/project.tar.gz
containers/runtime/code
**/node_modules/
# test results
test-results
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.52-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.53-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-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.52
docker.all-hands.dev/all-hands-ai/openhands:0.53
```
</details>
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-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.52
docker.all-hands.dev/all-hands-ai/openhands:0.53
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-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.52
docker.all-hands.dev/all-hands-ai/openhands:0.53
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.52-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.53-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.53-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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

@@ -78,6 +78,14 @@ description: Complete guide for setting up Jira Data Center integration with Ope
- **Service Account API Key**: The personal access token from Step 2 above
- Ensure **Active** toggle is enabled
<Note>
Workspace name is the host name of your Jira Data Center instance.
Eg: http://jira.all-hands.dev/projects/OH/issues/OH-77
Here the workspace name is **jira.all-hands.dev**.
</Note>
3. **Complete OAuth Flow**
- You'll be redirected to Jira Data Center to complete OAuth verification
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
@@ -101,18 +109,18 @@ description: Complete guide for setting up Jira Data Center integration with Ope
<AccordionGroup>
<Accordion title="Workspace link flow">
![workspace-link.png](/static/img/workspace-link.png)
![workspace-link.png](/static/img/jira-dc-user-link.png)
</Accordion>
<Accordion title="Workspace Configure flow">
![workspace-link.png](/static/img/workspace-configure.png)
![workspace-link.png](/static/img/jira-dc-admin-configure.png)
</Accordion>
<Accordion title="Edit view as a user">
![workspace-link.png](/static/img/workspace-user-edit.png)
![workspace-link.png](/static/img/jira-dc-user-unlink.png)
</Accordion>
<Accordion title="Edit view as the workspace creator">
![workspace-link.png](/static/img/workspace-admin-edit.png)
![workspace-link.png](/static/img/jira-dc-admin-edit.png)
</Accordion>
</AccordionGroup>
@@ -15,28 +15,27 @@ description: Complete guide for setting up Jira Cloud integration with OpenHands
- Go to **Directory** > **Users**
2. **Create OpenHands Service Account**
- Click **Add user**
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
- Display name: `OpenHands Agent`
- Send invitation: **No** (you'll set password manually)
- Click **Add user**
3. **Configure Account**
- Locate the created user and click on it
- Set a secure password
- Add to relevant Jira projects with appropriate permissions
- Click **Service accounts**
- Click **Create a service account**
- Name: `OpenHands Agent`
- Click **Next**
- Select **User** role for Jira app
- Click **Create**
### Step 2: Generate API Token
1. **Access API Token Management**
- Log in as the OpenHands service account
- Go to [API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
2. **Create API Token**
1. **Access Service Account Configuration**
- Locate the created service account from above step and click on it
- Click **Create API token**
- Label: `OpenHands Cloud Integration`
- Expiry: Set appropriate expiration (recommend 1 year)
- Click **Create**
- Set the expiry to 365 days (maximum allowed value)
- Click **Next**
- In **Select token scopes** screen, filter by following values
- App: Jira
- Scope type: Classic
- Scope actions: Write, Read
- Select `read:jira-work` and `write:jira-work` scopes
- Click **Next**
- Review and create API token
- **Important**: Copy and securely store the token immediately
### Step 3: Configure Webhook
@@ -83,6 +82,14 @@ description: Complete guide for setting up Jira Cloud integration with OpenHands
- **Service Account API Key**: The API token from Step 2 above
- Ensure **Active** toggle is enabled
<Note>
Workspace name is the host name when accessing a resource in Jira Cloud.
Eg: https://all-hands.atlassian.net/browse/OH-55
Here the workspace name is **all-hands**.
</Note>
3. **Complete OAuth Flow**
- You'll be redirected to Jira Cloud to complete OAuth verification
- Grant the necessary permissions to verify your workspace access.
@@ -106,18 +113,18 @@ description: Complete guide for setting up Jira Cloud integration with OpenHands
<AccordionGroup>
<Accordion title="Workspace link flow">
![workspace-link.png](/static/img/workspace-link.png)
![workspace-link.png](/static/img/jira-user-link.png)
</Accordion>
<Accordion title="Workspace Configure flow">
![workspace-link.png](/static/img/workspace-configure.png)
![workspace-link.png](/static/img/jira-admin-configure.png)
</Accordion>
<Accordion title="Edit view as a user">
![workspace-link.png](/static/img/workspace-user-edit.png)
![workspace-link.png](/static/img/jira-user-unlink.png)
</Accordion>
<Accordion title="Edit view as the workspace creator">
![workspace-link.png](/static/img/workspace-admin-edit.png)
![workspace-link.png](/static/img/jira-admin-edit.png)
</Accordion>
</AccordionGroup>
@@ -28,7 +28,7 @@ description: Complete guide for setting up Linear integration with OpenHands Clo
1. **Access API Settings**
- Log in as the service account
- Go to **Settings** > **API**
- Go to **Settings** > **Security & access**
2. **Create Personal API Key**
- Click **Create new key**
@@ -82,6 +82,14 @@ description: Complete guide for setting up Linear integration with OpenHands Clo
- **Service Account API Key**: The API key from Step 2 above
- Ensure **Active** toggle is enabled
<Note>
Workspace name is the identifier after the host name when accessing a resource in Linear.
Eg: https://linear.app/allhands/issue/OH-37
Here the workspace name is **allhands**.
</Note>
3. **Complete OAuth Flow**
- You'll be redirected to Linear to complete OAuth verification
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
@@ -105,15 +113,15 @@ description: Complete guide for setting up Linear integration with OpenHands Clo
<AccordionGroup>
<Accordion title="Workspace link flow">
![workspace-link.png](/static/img/workspace-link.png)
![workspace-link.png](/static/img/linear-user-link.png)
</Accordion>
<Accordion title="Workspace Configure flow">
![workspace-link.png](/static/img/workspace-configure.png)
![workspace-link.png](/static/img/linear-admin-configure.png)
</Accordion>
<Accordion title="Edit view as a user">
![workspace-link.png](/static/img/workspace-user-edit.png)
![workspace-link.png](/static/img/linear-admin-edit.png)
</Accordion>
<Accordion title="Edit view as the workspace creator">
@@ -58,17 +58,18 @@ The OpenHands agent needs to identify which Git repository to work with when pro
### Platform Configuration Issues
- **Webhook not triggering**: Verify the webhook URL is correct and the proper event types are selected (Comment, Issue updated)
- **API authentication failing**: Check API key/token validity and ensure required scopes are granted
- **API authentication failing**: Check API key/token validity and ensure required scopes are granted. If your current API token is expired, make sure to update it in the respective integration settings
- **Permission errors**: Ensure the service account has access to relevant projects/teams and appropriate permissions
### Workspace Integration Issues
- **Workspace linking requests credentials**: If there are no active workspace integrations for the workspace you specified, you need to configure it first. Contact your platform administrator that you want to integrate with (eg: Jira, Linear)
- **OAuth flow fails**: Ensure you're signing in with the same Git provider account that contains the repositories you want OpenHands to work on
- **Integration not found**: Verify the workspace name matches exactly and that platform configuration was completed first
- **OAuth flow fails**: Make sure that you're authorizing with the correct account with proper workspace access
### General Issues
- **Agent not responding**: Check webhook logs in your platform settings and verify service account status
- **Authentication errors**: Verify Git provider permissions and OpenHands Cloud access
- **Agent fails to identify git repo**: Ensure you're signing in with the same Git provider account that contains the repositories you want OpenHands to work on
- **Partial functionality**: Ensure both platform configuration and workspace integration are properly completed
### Getting Help
+2 -2
View File
@@ -119,7 +119,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.52-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -128,7 +128,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.52 \
docker.all-hands.dev/all-hands-ai/openhands:0.53 \
python -m openhands.cli.main --override-cli-mode true
```
+2 -2
View File
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-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.52 \
docker.all-hands.dev/all-hands-ai/openhands:0.53 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+4 -4
View File
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-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.52
docker.all-hands.dev/all-hands-ai/openhands:0.53
```
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.52
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.53
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
+3 -3
View File
@@ -109,17 +109,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
<Accordion title="Docker Command (Click to expand)">
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.53-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.52
docker.all-hands.dev/all-hands-ai/openhands:0.53
```
</Accordion>
+60 -1
View File
@@ -1,5 +1,5 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import i18n from "../../src/i18n";
import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu";
import { renderWithProviders } from "../../test-utils";
@@ -17,4 +17,63 @@ describe("Translations", () => {
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should not attempt to load unsupported language codes", async () => {
// Test that the configuration prevents 404 errors by not attempting to load
// unsupported language codes like 'en-US@posix'
const originalLanguage = i18n.language;
try {
// With nonExplicitSupportedLngs: false, i18next will not attempt to load
// unsupported language codes, preventing 404 errors
// Test with a language code that includes region but is not in supportedLngs
await i18n.changeLanguage("en-US@posix");
// Since "en-US@posix" is not in supportedLngs and nonExplicitSupportedLngs is false,
// i18next should fall back to the fallbackLng ("en")
expect(i18n.language).toBe("en");
// Test another unsupported region code
await i18n.changeLanguage("ja-JP");
// Even with nonExplicitSupportedLngs: false, i18next still falls back to base language
// if it exists in supportedLngs, but importantly, it won't make a 404 request first
expect(i18n.language).toBe("ja");
// Test that supported languages still work
await i18n.changeLanguage("ja");
expect(i18n.language).toBe("ja");
await i18n.changeLanguage("zh-CN");
expect(i18n.language).toBe("zh-CN");
} finally {
// Restore the original language
await i18n.changeLanguage(originalLanguage);
}
});
it("should have proper i18n configuration", () => {
// Test that the i18n instance has the expected configuration
expect(i18n.options.supportedLngs).toBeDefined();
// nonExplicitSupportedLngs should be false to prevent 404 errors
expect(i18n.options.nonExplicitSupportedLngs).toBe(false);
// fallbackLng can be a string or array, check if it includes "en"
const fallbackLng = i18n.options.fallbackLng;
if (Array.isArray(fallbackLng)) {
expect(fallbackLng).toContain("en");
} else {
expect(fallbackLng).toBe("en");
}
// Test that supported languages include both base and region-specific codes
const supportedLngs = i18n.options.supportedLngs as string[];
expect(supportedLngs).toContain("en");
expect(supportedLngs).toContain("zh-CN");
expect(supportedLngs).toContain("zh-TW");
expect(supportedLngs).toContain("ko-KR");
});
});
@@ -1,6 +1,6 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
@@ -36,6 +36,8 @@ vi.mock("react-i18next", async () => {
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$NAV_USER": "User",
"SETTINGS$NAV_SECRETS": "Secrets",
"SETTINGS$NAV_MCP": "MCP",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
@@ -47,8 +49,33 @@ vi.mock("react-i18next", async () => {
};
});
// Mock useConfig hook
const { mockUseConfig } = vi.hoisted(() => ({
mockUseConfig: vi.fn(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: mockUseConfig,
}));
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
beforeEach(() => {
// Set default config to OSS mode
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
},
isLoading: false,
});
});
const RoutesStub = createRoutesStub([
{
@@ -79,19 +106,7 @@ describe("Settings Billing", () => {
});
it("should not render the credits tab if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
// OSS mode is set by default in beforeEach
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
@@ -100,17 +115,20 @@ describe("Settings Billing", () => {
});
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
},
isLoading: false,
});
renderSettingsScreen();
@@ -121,17 +139,20 @@ describe("Settings Billing", () => {
it("should render the billing settings if clicking the credits item", async () => {
const user = userEvent.setup();
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
},
isLoading: false,
});
renderSettingsScreen();
+11 -11
View File
@@ -19,6 +19,9 @@ vi.mock("react-i18next", async () => {
SETTINGS$NAV_CREDITS: "Credits",
SETTINGS$NAV_API_KEYS: "API Keys",
SETTINGS$NAV_LLM: "LLM",
SETTINGS$NAV_SECRETS: "Secrets",
SETTINGS$NAV_MCP: "MCP",
SETTINGS$NAV_USER: "User",
SETTINGS$TITLE: "Settings",
};
return translations[key] || key;
@@ -119,22 +122,21 @@ describe("Settings Screen", () => {
});
it("should render the saas navbar", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
const saasConfig = { APP_MODE: "saas" };
// Clear any existing query data and set the config
mockQueryClient.clear();
mockQueryClient.setQueryData(["config"], saasConfig);
const sectionsToInclude = [
"user",
"integrations",
"application",
"credits", // The nav item shows "credits" text but routes to /billing
"secrets",
"api keys",
];
const sectionsToExclude = ["llm"];
// Clear any existing query data
mockQueryClient.clear();
const sectionsToExclude = ["llm", "mcp"];
renderSettingsScreen();
@@ -151,8 +153,6 @@ describe("Settings Screen", () => {
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should not be able to access saas-only routes in oss mode", async () => {
+182 -145
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.52.1",
"version": "0.53.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.52.1",
"version": "0.53.0",
"dependencies": {
"@heroui/react": "^2.8.2",
"@heroui/use-infinite-scroll": "^2.2.10",
@@ -18,9 +18,9 @@
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.84.2",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.3",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -30,14 +30,14 @@
"date-fns": "^4.1.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.12",
"i18next": "^25.3.2",
"i18next": "^25.3.6",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.539.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.259.0",
"posthog-js": "^1.260.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -60,8 +60,8 @@
"ws": "^8.18.2"
},
"devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.2",
@@ -69,7 +69,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.2.0",
@@ -226,13 +226,12 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"license": "MIT",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dependencies": {
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/types": "^7.28.2",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -459,12 +458,11 @@
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
"dependencies": {
"@babel/types": "^7.28.0"
"@babel/types": "^7.28.2"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -616,17 +614,16 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"license": "MIT",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/generator": "^7.28.3",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.0",
"@babel/types": "^7.28.2",
"debug": "^4.3.1"
},
"engines": {
@@ -3405,6 +3402,15 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -5899,26 +5905,24 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
"integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
"license": "MIT",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz",
"integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.5.1",
"lightningcss": "1.30.1",
"magic-string": "^0.30.17",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.11"
"tailwindcss": "4.1.12"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
"integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz",
"integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.4",
"tar": "^7.4.3"
@@ -5927,28 +5931,27 @@
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.11",
"@tailwindcss/oxide-darwin-arm64": "4.1.11",
"@tailwindcss/oxide-darwin-x64": "4.1.11",
"@tailwindcss/oxide-freebsd-x64": "4.1.11",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.11",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.11",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.11",
"@tailwindcss/oxide-linux-x64-musl": "4.1.11",
"@tailwindcss/oxide-wasm32-wasi": "4.1.11",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.11",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.11"
"@tailwindcss/oxide-android-arm64": "4.1.12",
"@tailwindcss/oxide-darwin-arm64": "4.1.12",
"@tailwindcss/oxide-darwin-x64": "4.1.12",
"@tailwindcss/oxide-freebsd-x64": "4.1.12",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.12",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.12",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.12",
"@tailwindcss/oxide-linux-x64-musl": "4.1.12",
"@tailwindcss/oxide-wasm32-wasi": "4.1.12",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.12",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.12"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz",
"integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz",
"integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -5958,13 +5961,12 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz",
"integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz",
"integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -5974,13 +5976,12 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz",
"integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz",
"integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -5990,13 +5991,12 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz",
"integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz",
"integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -6006,13 +6006,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz",
"integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz",
"integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6022,13 +6021,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz",
"integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz",
"integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6038,13 +6036,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz",
"integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz",
"integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6054,13 +6051,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz",
"integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz",
"integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6070,13 +6066,12 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz",
"integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz",
"integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -6086,9 +6081,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz",
"integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz",
"integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -6100,28 +6095,80 @@
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@emnapi/wasi-threads": "^1.0.2",
"@napi-rs/wasm-runtime": "^0.2.11",
"@tybys/wasm-util": "^0.9.0",
"@emnapi/core": "^1.4.5",
"@emnapi/runtime": "^1.4.5",
"@emnapi/wasi-threads": "^1.0.4",
"@napi-rs/wasm-runtime": "^0.2.12",
"@tybys/wasm-util": "^0.10.0",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.5",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.5",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
"integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
"integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -6131,13 +6178,12 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz",
"integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz",
"integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -6147,16 +6193,15 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
"integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
"license": "MIT",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz",
"integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.11",
"@tailwindcss/oxide": "4.1.11",
"@tailwindcss/node": "4.1.12",
"@tailwindcss/oxide": "4.1.12",
"postcss": "^8.4.41",
"tailwindcss": "4.1.11"
"tailwindcss": "4.1.12"
}
},
"node_modules/@tailwindcss/typography": {
@@ -6176,14 +6221,13 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz",
"integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==",
"license": "MIT",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz",
"integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==",
"dependencies": {
"@tailwindcss/node": "4.1.11",
"@tailwindcss/oxide": "4.1.11",
"tailwindcss": "4.1.11"
"@tailwindcss/node": "4.1.12",
"@tailwindcss/oxide": "4.1.12",
"tailwindcss": "4.1.12"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7"
@@ -6207,22 +6251,20 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.83.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.1.tgz",
"integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==",
"license": "MIT",
"version": "5.85.3",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz",
"integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.85.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.0.tgz",
"integrity": "sha512-t1HMfToVMGfwEJRya6GG7gbK0luZJd+9IySFNePL1BforU1F3LqQ3tBC2Rpvr88bOrlU6PXyMLgJD0Yzn4ztUw==",
"license": "MIT",
"version": "5.85.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz",
"integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==",
"dependencies": {
"@tanstack/query-core": "5.83.1"
"@tanstack/query-core": "5.85.3"
},
"funding": {
"type": "github",
@@ -6280,17 +6322,15 @@
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.4.tgz",
"integrity": "sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ==",
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.7.0.tgz",
"integrity": "sha512-RI2e97YZ7MRa+vxP4UUnMuMFL2buSsf0ollxUbTgrbPLKhMn8KVTx7raS6DYjC7v1NDVrioOvaShxsguLNISCA==",
"dev": true,
"license": "MIT",
"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"
},
@@ -11080,9 +11120,9 @@
}
},
"node_modules/i18next": {
"version": "25.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.4.tgz",
"integrity": "sha512-AHklEYFLiRRxW1Cb6zE9lfnEtYvsydRC8nRS3RSKGX3zCqZ8nLZwMaUsrb80YuccPNv2RNokDL8LkTNnp+6mDw==",
"version": "25.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.6.tgz",
"integrity": "sha512-dThZ0CTCM3sUG/qS0ZtQYZQcUI6DtBN8yBHK+SKEqihPcEYmjVWh/YJ4luic73Iq6Uxhp6q7LJJntRK5+1t7jQ==",
"funding": [
{
"type": "individual",
@@ -11097,7 +11137,6 @@
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -14763,10 +14802,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.259.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.259.0.tgz",
"integrity": "sha512-6usLnJshky8fQ82ask7PIJh4BSFOU0VkRbFg8Zanm/HIlYMG1VOdRWlToA63JXeO7Bzm9TuREq1wFm5U2VEVCg==",
"license": "SEE LICENSE IN LICENSE",
"version": "1.260.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.260.1.tgz",
"integrity": "sha512-DD8ZSRpdScacMqtqUIvMFme8lmOWkOvExG8VvjONE7Cm3xpRH5xXpfrwMJE4bayTGWKMx4ij6SfphK6dm/o2ug==",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",
@@ -16975,10 +17013,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"license": "MIT"
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="
},
"node_modules/tapable": {
"version": "2.2.2",
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.52.1",
"version": "0.53.0",
"private": true,
"type": "module",
"engines": {
@@ -17,9 +17,9 @@
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.84.2",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.3",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -29,14 +29,14 @@
"date-fns": "^4.1.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.12",
"i18next": "^25.3.2",
"i18next": "^25.3.6",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.539.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.259.0",
"posthog-js": "^1.260.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -84,8 +84,8 @@
]
},
"devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.2",
@@ -93,7 +93,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.2.0",
@@ -100,6 +100,17 @@ export function ConfigureModal({
}
}, [isOpen, existingWorkspace, isWorkspaceEditable]);
// Helper function to get platform-specific placeholder
const getWorkspacePlaceholder = () => {
if (platform === "jira") {
return I18nKey.PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER;
}
if (platform === "jira-dc") {
return I18nKey.PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER;
}
return I18nKey.PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER;
};
// Validation states
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [webhookSecretError, setWebhookSecretError] = useState<string | null>(
@@ -268,8 +279,11 @@ export function ConfigureModal({
<BaseModalDescription>
{showConfigurationFields ? (
<Trans
i18nKey={I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION}
i18nKey={
I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2
}
components={{
b: <b />,
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
@@ -280,41 +294,41 @@ export function ConfigureModal({
Check the document for more information
</a>
),
b: <b />,
}}
/>
) : (
<p className="mt-4">
<Trans
i18nKey={
I18nKey.PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION
}
components={{
b: <b />,
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Check the document for more information
</a>
),
}}
/>
</p>
<Trans
i18nKey={
I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1
}
components={{
b: <b />,
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Check the document for more information
</a>
),
}}
/>
)}
<p className="mt-4">
{t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT, {
platform: platformName,
})}
</p>
</BaseModalDescription>
<div className="w-full flex flex-col gap-4 mt-4">
<div className="w-full flex flex-col gap-4 mt-1">
<div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<SettingsInput
label={t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER,
)}
placeholder={t(getWorkspacePlaceholder())}
value={workspace}
onChange={handleWorkspaceChange}
className="w-full"
@@ -418,7 +432,7 @@ export function ConfigureModal({
>
{(() => {
if (existingWorkspace && showConfigurationFields) {
return t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL);
return t(I18nKey.PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL);
}
return t(I18nKey.PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL);
})()}
+15 -14
View File
@@ -1,5 +1,5 @@
import clsx from "clsx";
import React, { useEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { NavTab } from "./nav-tab";
import { ScrollLeftButton } from "./scroll-left-button";
import { ScrollRightButton } from "./scroll-right-button";
@@ -25,18 +25,12 @@ export function Container({
children,
className,
}: ContainerProps) {
const [containerWidth, setContainerWidth] = useState(0);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [showScrollButtons, setShowScrollButtons] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Track container width using ResizeObserver
useTrackElementWidth({
elementRef: containerRef,
callback: setContainerWidth,
});
// Check scroll position and update button states
const updateScrollButtons = () => {
if (scrollContainerRef.current) {
@@ -47,10 +41,19 @@ export function Container({
}
};
// Update scroll buttons when tabs change or container width changes
useEffect(() => {
updateScrollButtons();
}, [labels, containerWidth]);
// Track container width using ResizeObserver
useTrackElementWidth({
elementRef: containerRef,
callback: (width: number) => {
// Only update scroll button visibility when crossing the threshold
const shouldShowScrollButtons =
width < 598 && Boolean(labels) && labels!.length > 0;
if (shouldShowScrollButtons) {
setShowScrollButtons(shouldShowScrollButtons);
}
updateScrollButtons();
},
});
// Scroll functions
const scrollLeft = () => {
@@ -65,8 +68,6 @@ export function Container({
}
};
const showScrollButtons = containerWidth < 598 && labels && labels.length > 0;
return (
<div
ref={containerRef}
+27 -3
View File
@@ -1,18 +1,38 @@
import { useEffect } from "react";
import { useEffect, useCallback, useRef } from "react";
interface UseTrackElementWidthProps {
elementRef: React.RefObject<HTMLElement | null>;
callback: (width: number) => void;
delay?: number; // Optional delay parameter with default
}
export const useTrackElementWidth = ({
elementRef,
callback,
delay = 100, // Default 100ms delay
}: UseTrackElementWidthProps) => {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Create debounced callback that only fires after delay
const debouncedCallback = useCallback(
(width: number) => {
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
callback(width);
}, delay);
},
[callback, delay],
);
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
callback(entry.contentRect.width);
debouncedCallback(entry.contentRect.width);
}
});
@@ -21,7 +41,11 @@ export const useTrackElementWidth = ({
}
return () => {
// Clean up timeout and observer
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
resizeObserver.disconnect();
};
}, []);
}, [debouncedCallback]);
};
+8 -4
View File
@@ -749,8 +749,15 @@ export enum I18nKey {
PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE = "PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE",
PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL = "PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL",
PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL = "PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL",
PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL = "PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL",
PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE",
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1 = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1",
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2 = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2",
PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT = "PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT",
PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL = "PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL",
PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER",
PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER",
PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER",
PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER",
PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL",
PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER",
PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL = "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL",
@@ -759,9 +766,6 @@ export enum I18nKey {
PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER = "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER",
PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL = "PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL",
PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL = "PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL",
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION",
PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE",
PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION = "PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION",
PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR = "PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR",
PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR",
PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR = "PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR",
+9 -1
View File
@@ -27,7 +27,15 @@ i18n
.init({
fallbackLng: "en",
debug: import.meta.env.NODE_ENV === "development",
load: "currentOnly",
// Define supported languages explicitly to prevent 404 errors
// According to i18next documentation, this is the recommended way to prevent
// 404 requests for unsupported language codes like 'en-US@posix'
supportedLngs: AvailableLanguages.map((lang) => lang.value),
// Do NOT set nonExplicitSupportedLngs: true as it causes 404 errors
// for region-specific codes not in supportedLngs (per i18next developer)
nonExplicitSupportedLngs: false,
});
export default i18n;
+127 -63
View File
@@ -11983,6 +11983,86 @@
"de": "Bearbeiten",
"uk": "Редагувати"
},
"PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL": {
"en": "Update",
"ja": "更新",
"zh-CN": "更新",
"zh-TW": "更新",
"ko-KR": "업데이트",
"no": "Oppdater",
"it": "Aggiorna",
"pt": "Atualizar",
"es": "Actualizar",
"ar": "تحديث",
"fr": "Mettre à jour",
"tr": "Güncelle",
"de": "Aktualisieren",
"uk": "Оновити"
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE": {
"en": "Configure {{platform}} Integration",
"ja": "{{platform}}統合を設定",
"zh-CN": "配置{{platform}}集成",
"zh-TW": "配置{{platform}}集成",
"ko-KR": "{{platform}} 통합 구성",
"no": "Konfigurer {{platform}}-integrasjon",
"it": "Configura integrazione {{platform}}",
"pt": "Configurar integração {{platform}}",
"es": "Configurar integración de {{platform}}",
"ar": "تكوين تكامل {{platform}}",
"fr": "Configurer l'intégration {{platform}}",
"tr": "{{platform}} Entegrasyonunu Yapılandır",
"de": "{{platform}}-Integration konfigurieren",
"uk": "Налаштувати інтеграцію {{platform}}"
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1": {
"en": "<b>Important: </b>Make sure the workspace integration for your target workspace is already configured. Check the <a>documentation</a> for more information.",
"ja": "<b>重要: </b>対象のワークスペースのワークスペース統合がすでに設定されていることを確認してください。詳しくは<a>ドキュメント</a>をご覧ください。",
"zh-CN": "<b>重要提示:</b>请确保目标工作区的工作区集成已配置完毕。查看<a>文档</a>了解更多信息。",
"zh-TW": "<b>重要提示:</b>請確保目標工作區的工作區整合已設定完成。查看<a>文件</a>以了解更多資訊。",
"ko-KR": "<b>중요:</b>대상 작업 공간에 대한 작업 공간 통합이 이미 구성되어 있는지 확인하세요. 자세한 내용은 <a>설명서</a>를 참조하세요.",
"no": "<b>Viktig:</b>Sørg for at arbeidsområdeintegrasjonen for målarbeidsområdet ditt allerede er konfigurert. Se <a>dokumentasjonen</a> for mer informasjon.",
"it": "<b>Importante: </b>Assicurati che l'integrazione dell'area di lavoro per l'area di lavoro di destinazione sia già configurata. Consulta la <a>documentazione</a> per ulteriori informazioni.",
"pt": "<b>Importante:</b>Certifique-se de que a integração do workspace de destino já esteja configurada. Consulte a <a>documentação</a> para obter mais informações.",
"es": "Importante: Asegúrate de que la integración del espacio de trabajo de destino ya esté configurada. Consulta la documentación para obtener más información.",
"ar": "<b>هام: </b>تأكد من إعداد تكامل مساحة العمل لمساحة العمل المستهدفة. <a>راجع المستند لمزيد من المعلومات</a>.",
"fr": "<b>Important :</b>Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la <a>documentation</a> pour plus d'informations.",
"tr": "<b>Önemli: </b>Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b>Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо: </b>Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте <a>документацію</a> для отримання додаткової інформації."
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2": {
"en": "<b>Important:</b> Check the <a>documentation</a> for more information about configuring the workspace integration or updating an existing integration.",
"ja": "<b>重要:</b> ワークスペース統合の設定や既存の統合の更新について詳しくは<a>ドキュメント</a>をご確認ください。",
"zh-CN": "<b>重要提示:</b>有关配置工作区集成或更新现有集成的更多信息,请查看<a>文档</a>。",
"zh-TW": "<b>重要提示:</b>有關配置工作區整合或更新現有整合的更多資訊,請查看<a>文件</a>。",
"ko-KR": "<b>중요:</b> 작업공간 통합 구성이나 기존 통합 업데이트에 대한 자세한 내용은 <a>설명서</a>를 확인하세요.",
"no": "<b>Viktig:</b> Se <a>dokumentasjonen</a> for mer informasjon om konfigurering av arbeidsområdeintegrasjon eller oppdatering av eksisterende integrasjon.",
"it": "<b>Importante:</b> Consulta la <a>documentazione</a> per ulteriori informazioni sulla configurazione dell'integrazione dell'area di lavoro o sull'aggiornamento di un'integrazione esistente.",
"pt": "<b>Importante:</b> Consulte a <a>documentação</a> para obter mais informações sobre como configurar a integração do workspace ou atualizar uma integração existente.",
"es": "<b>Importante:</b> Consulte la <a>documentación</a> para obtener más información sobre la configuración de la integración del espacio de trabajo o la actualización de una integración existente.",
"ar": "<b>هام:</b> راجع <a>الوثائق</a> لمزيد من المعلومات حول تكوين تكامل مساحة العمل أو تحديث تكامل موجود.",
"fr": "<b>Important :</b> Consultez la <a>documentation</a> pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.",
"tr": "<b>Önemli:</b> Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b> Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо:</b> Перегляньте <a>документацію</a> для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції."
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT": {
"en": "Workspace name can be found in the browser URL when you're accessing a resource (eg: issue) in {{platform}}.",
"ja": "ワークスペース名は、{{platform}}のリソース(例:イシュー)にアクセスする際のブラウザURLで確認できます。",
"zh-CN": "工作区名称可以在访问{{platform}}资源(如:问题)时的浏览器URL中找到。",
"zh-TW": "工作區名稱可以在存取{{platform}}資源(如:問題)時的瀏覽器URL中找到。",
"ko-KR": "작업공간 이름은 {{platform}}의 리소스(예: 이슈)에 액세스할 때 브라우저 URL에서 찾을 수 있습니다.",
"no": "Arbeidsområdenavn kan finnes i nettleser-URL-en når du får tilgang til en ressurs (f.eks: sak) i {{platform}}.",
"it": "Il nome dell'area di lavoro può essere trovato nell'URL del browser quando stai accedendo a una risorsa (es: issue) in {{platform}}.",
"pt": "O nome do workspace pode ser encontrado na URL do navegador quando você está acessando um recurso (ex: issue) em {{platform}}.",
"es": "El nombre del espacio de trabajo se puede encontrar en la URL del navegador cuando accedes a un recurso (ej: issue) en {{platform}}.",
"ar": "يمكن العثور على اسم مساحة العمل في عنوان URL للمتصفح عند الوصول إلى مورد (مثل: مشكلة) في {{platform}}.",
"fr": "Le nom de l'espace de travail peut être trouvé dans l'URL du navigateur lorsque vous accédez à une ressource (ex : issue) dans {{platform}}.",
"tr": "Çalışma alanı adı, {{platform}}'da bir kaynağa (örn: sorun) erişirken tarayıcı URL'sinde bulunabilir.",
"de": "Der Arbeitsbereichsname ist in der Browser-URL zu finden, wenn Sie auf eine Ressource (z.B.: Issue) in {{platform}} zugreifen.",
"uk": "Назву робочого простору можна знайти в URL браузера під час доступу до ресурсу (наприклад: проблема) в {{platform}}."
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL": {
"en": "Workspace Name",
"ja": "ワークスペース名",
@@ -11999,21 +12079,53 @@
"de": "Arbeitsbereichsname",
"uk": "Назва робочої області"
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER": {
"en": "myworkspace",
"ja": "私のワークスペース",
"zh-CN": "我的工作空间",
"zh-TW": "我的工作區",
"ko-KR": "내워크스페이스",
"no": "mittarbeidsområde",
"it": "mioworkspace",
"pt": "meuworkspace",
"es": "miespaciodetrabajo",
"ar": "مساحةعملي",
"fr": "monworkspace",
"tr": "benimworkspace",
"de": "meinarbeitsbereich",
"uk": "моя-робоча-область"
"PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER": {
"en": "yourcompany.atlassian.net",
"ja": "yourcompany.atlassian.net",
"zh-CN": "yourcompany.atlassian.net",
"zh-TW": "yourcompany.atlassian.net",
"ko-KR": "yourcompany.atlassian.net",
"no": "yourcompany.atlassian.net",
"it": "yourcompany.atlassian.net",
"pt": "yourcompany.atlassian.net",
"es": "yourcompany.atlassian.net",
"ar": "yourcompany.atlassian.net",
"fr": "yourcompany.atlassian.net",
"tr": "yourcompany.atlassian.net",
"de": "yourcompany.atlassian.net",
"uk": "yourcompany.atlassian.net"
},
"PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER": {
"en": "jira.yourcompany.com",
"ja": "jira.yourcompany.com",
"zh-CN": "jira.yourcompany.com",
"zh-TW": "jira.yourcompany.com",
"ko-KR": "jira.yourcompany.com",
"no": "jira.yourcompany.com",
"it": "jira.yourcompany.com",
"pt": "jira.yourcompany.com",
"es": "jira.yourcompany.com",
"ar": "jira.yourcompany.com",
"fr": "jira.yourcompany.com",
"tr": "jira.yourcompany.com",
"de": "jira.yourcompany.com",
"uk": "jira.yourcompany.com"
},
"PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER": {
"en": "yourcompany",
"ja": "yourcompany",
"zh-CN": "yourcompany",
"zh-TW": "yourcompany",
"ko-KR": "yourcompany",
"no": "yourcompany",
"it": "yourcompany",
"pt": "yourcompany",
"es": "yourcompany",
"ar": "yourcompany",
"fr": "yourcompany",
"tr": "yourcompany",
"de": "yourcompany",
"uk": "yourcompany"
},
"PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL": {
"en": "Webhook Secret",
@@ -12143,54 +12255,6 @@
"de": "Aktiv",
"uk": "Активний"
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION": {
"en": "<b>Important:</b> Check the <a>documentation</a> for more information about configuring the workspace integration or updating an existing integration.",
"ja": "<b>重要:</b> ワークスペース統合の設定や既存の統合の更新について詳しくは<a>ドキュメント</a>をご確認ください。",
"zh-CN": "<b>重要提示:</b>有关配置工作区集成或更新现有集成的更多信息,请查看<a>文档</a>。",
"zh-TW": "<b>重要提示:</b>有關配置工作區整合或更新現有整合的更多資訊,請查看<a>文件</a>。",
"ko-KR": "<b>중요:</b> 작업공간 통합 구성이나 기존 통합 업데이트에 대한 자세한 내용은 <a>설명서</a>를 확인하세요.",
"no": "<b>Viktig:</b> Se <a>dokumentasjonen</a> for mer informasjon om konfigurering av arbeidsområdeintegrasjon eller oppdatering av eksisterende integrasjon.",
"it": "<b>Importante:</b> Consulta la <a>documentazione</a> per ulteriori informazioni sulla configurazione dell'integrazione dell'area di lavoro o sull'aggiornamento di un'integrazione esistente.",
"pt": "<b>Importante:</b> Consulte a <a>documentação</a> para obter mais informações sobre como configurar a integração do workspace ou atualizar uma integração existente.",
"es": "<b>Importante:</b> Consulte la <a>documentación</a> para obtener más información sobre la configuración de la integración del espacio de trabajo o la actualización de una integración existente.",
"ar": "<b>هام:</b> راجع <a>الوثائق</a> لمزيد من المعلومات حول تكوين تكامل مساحة العمل أو تحديث تكامل موجود.",
"fr": "<b>Important :</b> Consultez la <a>documentation</a> pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.",
"tr": "<b>Önemli:</b> Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b> Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо:</b> Перегляньте <a>документацію</a> для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції."
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE": {
"en": "Configure {{platform}} Integration",
"ja": "{{platform}}統合を設定",
"zh-CN": "配置{{platform}}集成",
"zh-TW": "配置{{platform}}集成",
"ko-KR": "{{platform}} 통합 구성",
"no": "Konfigurer {{platform}}-integrasjon",
"it": "Configura integrazione {{platform}}",
"pt": "Configurar integração {{platform}}",
"es": "Configurar integración de {{platform}}",
"ar": "تكوين تكامل {{platform}}",
"fr": "Configurer l'intégration {{platform}}",
"tr": "{{platform}} Entegrasyonunu Yapılandır",
"de": "{{platform}}-Integration konfigurieren",
"uk": "Налаштувати інтеграцію {{platform}}"
},
"PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION": {
"en": "<b>Important: </b>Make sure the workspace integration for your target workspace is already configured. Check the <a>documentation</a> for more information.",
"ja": "<b>重要: </b>対象のワークスペースのワークスペース統合がすでに設定されていることを確認してください。詳しくは<a>ドキュメント</a>をご覧ください。",
"zh-CN": "<b>重要提示:</b>请确保目标工作区的工作区集成已配置完毕。查看<a>文档</a>了解更多信息。",
"zh-TW": "<b>重要提示:</b>請確保目標工作區的工作區整合已設定完成。查看<a>文件</a>以了解更多資訊。",
"ko-KR": "<b>중요:</b>대상 작업 공간에 대한 작업 공간 통합이 이미 구성되어 있는지 확인하세요. 자세한 내용은 <a>설명서</a>를 참조하세요.",
"no": "<b>Viktig:</b>Sørg for at arbeidsområdeintegrasjonen for målarbeidsområdet ditt allerede er konfigurert. Se <a>dokumentasjonen</a> for mer informasjon.",
"it": "<b>Importante: </b>Assicurati che l'integrazione dell'area di lavoro per l'area di lavoro di destinazione sia già configurata. Consulta la <a>documentazione</a> per ulteriori informazioni.",
"pt": "<b>Importante:</b>Certifique-se de que a integração do workspace de destino já esteja configurada. Consulte a <a>documentação</a> para obter mais informações.",
"es": "Importante: Asegúrate de que la integración del espacio de trabajo de destino ya esté configurada. Consulta la documentación para obtener más información.",
"ar": "<b>هام: </b>تأكد من إعداد تكامل مساحة العمل لمساحة العمل المستهدفة. <a>راجع المستند لمزيد من المعلومات</a>.",
"fr": "<b>Important :</b>Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la <a>documentation</a> pour plus d'informations.",
"tr": "<b>Önemli: </b>Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b>Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо: </b>Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте <a>документацію</a> для отримання додаткової інформації."
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR": {
"en": "Workspace name can only contain letters, numbers, hyphens, and underscores",
"ja": "ワークスペース名は文字、数字、ハイフン、アンダースコアのみ使用できます",
+1 -1
View File
@@ -25,7 +25,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
provider_tokens_set: { github: null, gitlab: null, bitbucket: null },
provider_tokens_set: {},
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
enable_proactive_conversation_starters:
+1 -1
View File
@@ -122,5 +122,5 @@ export function getStatusCode(
return runtimeStatus;
}
return "STATUS$ERROR"; // illegal state
return I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE;
}
+46
View File
@@ -31,6 +31,52 @@ export default defineConfig(({ mode }) => {
svgr(),
tailwindcss(),
],
optimizeDeps: {
include: [
// Pre-bundle ALL dependencies to prevent runtime optimization and page reloads
// These are discovered during initial app load:
"react-redux",
"posthog-js",
"@tanstack/react-query",
"react-hot-toast",
"@reduxjs/toolkit",
"i18next",
"i18next-http-backend",
"i18next-browser-languagedetector",
"react-i18next",
"axios",
"date-fns",
"@uidotdev/usehooks",
"react-icons/fa6",
"react-icons/fa",
"clsx",
"tailwind-merge",
"@heroui/react",
"lucide-react",
"react-select",
"react-select/async",
"@microlink/react-json-view",
"socket.io-client",
// These are discovered when launching conversations:
"react-icons/vsc",
"react-icons/lu",
"react-icons/di",
"react-icons/io5",
"react-icons/io", // Added to prevent runtime optimization
"@monaco-editor/react",
"react-textarea-autosize",
"react-markdown",
"remark-gfm",
"remark-breaks",
"react-syntax-highlighter",
"react-syntax-highlighter/dist/esm/styles/prism",
"react-syntax-highlighter/dist/esm/styles/hljs",
// Terminal dependencies - added to prevent runtime optimization
"@xterm/addon-fit",
"@xterm/xterm",
"@xterm/xterm/css/xterm.css",
],
},
server: {
port: FE_PORT,
host: true,
@@ -98,3 +98,11 @@ Your primary role is to assist users by executing commands, modifying code, and
- Confirm whether they want it as a separate file or just in the conversation
- Ask if they want documentation files to be included in version control
</DOCUMENTATION>
<PROCESS_MANAGEMENT>
* When terminating processes:
- Do NOT use general keywords with commands like `pkill -f server` or `pkill -f python` as this might accidentally kill other important servers or processes
- Always use specific keywords that uniquely identify the target process
- Prefer using `ps aux` to find the exact process ID (PID) first, then kill that specific PID
- When possible, use more targeted approaches like finding the PID from a pidfile or using application-specific shutdown commands
</PROCESS_MANAGEMENT>
+31 -4
View File
@@ -4,7 +4,7 @@ import sys
from pathlib import Path
from typing import Any
import toml
import tomlkit
from prompt_toolkit import HTML, print_formatted_text
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import clear, print_container
@@ -520,7 +520,7 @@ def load_config_file(file_path: Path) -> dict:
if file_path.exists():
try:
with open(file_path, 'r') as f:
return toml.load(f)
return dict(tomlkit.load(f))
except Exception:
pass
@@ -530,9 +530,36 @@ def load_config_file(file_path: Path) -> dict:
def save_config_file(config_data: dict, file_path: Path) -> None:
"""Save the config file."""
"""Save the config file with proper MCP formatting."""
doc = tomlkit.document()
for key, value in config_data.items():
if key == 'mcp':
# Handle MCP section specially
mcp_section = tomlkit.table()
for mcp_key, mcp_value in value.items():
# Create array with inline tables for server configurations
server_array = tomlkit.array()
for server_config in mcp_value:
if isinstance(server_config, dict):
# Create inline table for each server
inline_table = tomlkit.inline_table()
for server_key, server_val in server_config.items():
inline_table[server_key] = server_val
server_array.append(inline_table)
else:
# Handle non-dict values (like string URLs)
server_array.append(server_config)
mcp_section[mcp_key] = server_array
doc[key] = mcp_section
else:
# Handle non-MCP sections normally
doc[key] = value
with open(file_path, 'w') as f:
toml.dump(config_data, f)
f.write(tomlkit.dumps(doc))
def _ensure_mcp_config_structure(config_data: dict) -> None:
+3 -3
View File
@@ -241,7 +241,7 @@ async def modify_llm_settings_basic(
provider_list = [p for p in provider_list if p not in verified_providers]
provider_list = verified_providers + provider_list
provider_completer = FuzzyWordCompleter(provider_list)
provider_completer = FuzzyWordCompleter(provider_list, WORD=True)
session = PromptSession(key_bindings=kb_cancel())
current_provider, current_model, current_api_key = (
@@ -392,7 +392,7 @@ async def modify_llm_settings_basic(
)
if change_model:
model_completer = FuzzyWordCompleter(provider_models)
model_completer = FuzzyWordCompleter(provider_models, WORD=True)
# Define a validator function that allows custom models but shows a warning
def model_validator(x):
@@ -528,7 +528,7 @@ async def modify_llm_settings_advanced(
)
agent_list = Agent.list_agents()
agent_completer = FuzzyWordCompleter(agent_list)
agent_completer = FuzzyWordCompleter(agent_list, WORD=True)
agent = await get_validated_input(
session,
'(Step 4/6) Agent (TAB for options, CTRL-c to cancel): ',
-1
View File
@@ -906,7 +906,6 @@ def cli_confirm(
layout=layout,
key_bindings=kb,
style=style,
mouse_support=True,
full_screen=False,
)
+10
View File
@@ -183,3 +183,13 @@ class LLMConfig(BaseModel):
# Azure issue: https://github.com/All-Hands-AI/OpenHands/issues/7755
if self.model.startswith('azure') and self.api_version is None:
self.api_version = '2024-12-01-preview'
# Set AWS credentials as environment variables for LiteLLM Bedrock
if self.aws_access_key_id:
os.environ['AWS_ACCESS_KEY_ID'] = self.aws_access_key_id.get_secret_value()
if self.aws_secret_access_key:
os.environ['AWS_SECRET_ACCESS_KEY'] = (
self.aws_secret_access_key.get_secret_value()
)
if self.aws_region_name:
os.environ['AWS_REGION_NAME'] = self.aws_region_name
+3 -1
View File
@@ -19,7 +19,9 @@ class CmdRunAction(Action):
blocking: bool = False # if True, the command will be run in a blocking manner, but a timeout must be set through _set_hard_timeout
is_static: bool = False # if True, runs the command in a separate process
cwd: str | None = None # current working directory, only used if is_static is True
hidden: bool = False
hidden: bool = (
False # if True, this command does not go through the LLM or event stream
)
action: str = ActionType.RUN
runnable: ClassVar[bool] = True
confirmation_state: ActionConfirmationStatus = ActionConfirmationStatus.CONFIRMED
+5 -2
View File
@@ -115,9 +115,12 @@ class CmdOutputObservation(Observation):
**kwargs: Any,
) -> None:
# Truncate content before passing it to parent
truncated_content = self._maybe_truncate(content)
# Hidden commands don't go through LLM/event stream, so no need to truncate
truncate = not hidden
if truncate:
content = self._maybe_truncate(content)
super().__init__(truncated_content)
super().__init__(content)
self.command = command
self.observation = observation
@@ -496,15 +496,15 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
"""Get branches for a repository"""
url = f'{self.BASE_URL}/repos/{repository}/branches'
# Set maximum branches to fetch (10 pages with 100 per page)
MAX_BRANCHES = 1000
# Set maximum branches to fetch (100 per page)
MAX_BRANCHES = 5_000
PER_PAGE = 100
all_branches: list[Branch] = []
page = 1
# Fetch up to 10 pages of branches
while page <= 10 and len(all_branches) < MAX_BRANCHES:
while len(all_branches) < MAX_BRANCHES:
params = {'per_page': str(PER_PAGE), 'page': str(page)}
response, headers = await self._make_request(url, params)
+8 -106
View File
@@ -3,7 +3,6 @@ import os
import time
import warnings
from functools import partial
from threading import RLock
from typing import Any, Callable
import httpx
@@ -95,6 +94,7 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
'kimi-k2-instruct',
'Qwen3-Coder-480B-A35B-Instruct',
'qwen3-coder', # this will match both qwen3-coder-480b (openhands provider) and qwen3-coder (for openrouter)
'gpt-5',
'gpt-5-2025-08-07',
]
@@ -109,7 +109,9 @@ REASONING_EFFORT_SUPPORTED_MODELS = [
'o4-mini-2025-04-16',
'gemini-2.5-flash',
'gemini-2.5-pro',
'gpt-5',
'gpt-5-2025-08-07',
'claude-opus-4-1-20250805', # we need to remove top_p for opus 4.1
]
MODELS_WITHOUT_STOP_WORDS = [
@@ -143,7 +145,6 @@ class LLM(RetryMixin, DebugMixin):
metrics: The metrics to use.
"""
self._tried_model_info = False
self._lock = RLock()
self.metrics: Metrics = (
metrics if metrics is not None else Metrics(model_name=config.model)
)
@@ -159,22 +160,11 @@ class LLM(RetryMixin, DebugMixin):
)
os.makedirs(self.config.log_completions_folder, exist_ok=True)
# Initialize core internals in a single pass
self._initialize_core()
def _initialize_core(self) -> None:
"""Initialize or re-initialize all components derived from config.
This centralizes initialization to avoid duplication between __init__ and reinit().
"""
# call init_model_info to initialize config.max_output_tokens used in partial
# call init_model_info to initialize config.max_output_tokens
# which is used in partial function
with warnings.catch_warnings():
warnings.simplefilter('ignore')
self.init_model_info()
# Recompute function-calling capability regardless of model_info cache
self._compute_function_calling_active()
if self.vision_is_active():
logger.debug('LLM: model has vision enabled')
if self.is_caching_prompt_active():
@@ -188,17 +178,6 @@ class LLM(RetryMixin, DebugMixin):
else:
self.tokenizer = None
# Initialize the completion function
self._build_completion_function()
# Build the completion wrapper with retry logic
self._rebuild_completion_wrapper()
def _build_completion_function(self) -> None:
"""Build the completion function based on current configuration.
This method creates the partial function that will be used for LLM completions.
It can be called multiple times to rebuild the function when configuration changes.
"""
# set up the completion function
kwargs: dict[str, Any] = {
'temperature': self.config.temperature,
@@ -275,78 +254,6 @@ class LLM(RetryMixin, DebugMixin):
self._completion_unwrapped = self._completion
def reinit(self, new_config: LLMConfig) -> None:
"""Reinitialize the LLM with a new configuration.
This is a threadsafe operation that updates configuration and rebuilds
the completion pipeline (completion function + retry wrapper). It also
refreshes model info and tokenizer as needed.
"""
with self._lock:
# Reset capability/cost flags so the new config gets a clean slate
self.cost_metric_supported = True
old_model = self.config.model
old_tokenizer = self.config.custom_tokenizer
# Update the configuration (deep copy to avoid external mutation)
self.config = copy.deepcopy(new_config)
# Update metrics model name if model changed and refresh model info
if old_model != new_config.model:
self.metrics.model_name = new_config.model
logger.debug(
f'LLM model changed from {old_model} to {new_config.model}'
)
# Reset model info to force re-initialization
self._tried_model_info = False
self.model_info = None
with warnings.catch_warnings():
warnings.simplefilter('ignore')
self.init_model_info()
# Log new capabilities
if self.vision_is_active():
logger.debug('LLM: model has vision enabled')
if self.is_caching_prompt_active():
logger.debug('LLM: caching prompt enabled')
if self.is_function_calling_active():
logger.debug('LLM: model supports function calling')
# Update tokenizer if custom_tokenizer changed
if old_tokenizer != new_config.custom_tokenizer:
if new_config.custom_tokenizer is not None:
self.tokenizer = create_pretrained_tokenizer(
new_config.custom_tokenizer
)
logger.debug(
f'LLM tokenizer updated to {new_config.custom_tokenizer}'
)
else:
self.tokenizer = None
logger.debug('LLM tokenizer reset to default')
# Handle log completions folder creation if needed
if new_config.log_completions:
if new_config.log_completions_folder is None:
raise RuntimeError(
'log_completions_folder is required when log_completions is enabled'
)
os.makedirs(new_config.log_completions_folder, exist_ok=True)
# Re-initialize core internals (model info, tokenizer, completion & wrapper)
self._initialize_core()
logger.debug('LLM reinitialized successfully')
# Backward-compat: keep update_config as an alias
def update_config(self, new_config: LLMConfig) -> None:
self.reinit(new_config)
def _rebuild_completion_wrapper(self) -> None:
"""Rebuild the completion wrapper with retry decorator.
This method recreates the wrapper function that includes retry logic
and other processing around the base completion function.
"""
@self.retry_decorator(
num_retries=self.config.num_retries,
retry_exceptions=LLM_RETRY_EXCEPTIONS,
@@ -649,19 +556,14 @@ class LLM(RetryMixin, DebugMixin):
self.config.max_output_tokens = self.model_info['max_tokens']
# Initialize function calling capability
self._compute_function_calling_active()
def _compute_function_calling_active(self) -> None:
"""Compute and cache whether function calling is active for current config.
Respects user override via native_tool_calling. Otherwise bases decision on
supported model names.
"""
# Check if model name is in our supported list
model_name_supported = (
self.config.model in FUNCTION_CALLING_SUPPORTED_MODELS
or self.config.model.split('/')[-1] in FUNCTION_CALLING_SUPPORTED_MODELS
or any(m in self.config.model for m in FUNCTION_CALLING_SUPPORTED_MODELS)
)
# Handle native_tool_calling user-defined configuration
if self.config.native_tool_calling is None:
self._function_calling_active = model_name_supported
else:
+3 -1
View File
@@ -1038,7 +1038,9 @@ fi
self, command: str, cwd: str | None
) -> CommandResult:
"""This function is used by the GitHandler to execute shell commands."""
obs = self.run(CmdRunAction(command=command, is_static=True, cwd=cwd))
obs = self.run(
CmdRunAction(command=command, is_static=True, hidden=True, cwd=cwd)
)
exit_code = 0
content = ''
+12 -9
View File
@@ -37,7 +37,7 @@ class DockerRuntimeBuilder(RuntimeBuilder):
@staticmethod
def check_buildx(is_podman: bool = False) -> bool:
"""Check if Docker Buildx is available"""
"""Check if Docker Buildx is available."""
try:
result = subprocess.run(
['docker' if not is_podman else 'podman', 'buildx', 'version'],
@@ -176,29 +176,32 @@ class DockerRuntimeBuilder(RuntimeBuilder):
bufsize=1,
)
output_lines = []
if process.stdout:
for line in iter(process.stdout.readline, ''):
line = line.strip()
if line:
output_lines.append(line) # Store all output lines
self._output_logs(line)
return_code = process.wait()
if return_code != 0:
# Use the collected output for error reporting
output_str = '\n'.join(output_lines)
raise subprocess.CalledProcessError(
return_code,
process.args,
output=process.stdout.read() if process.stdout else None,
stderr=process.stderr.read() if process.stderr else None,
output=output_str, # Use the collected output
stderr=None,
)
except subprocess.CalledProcessError as e:
logger.error(f'Image build failed:\n{e}') # TODO: {e} is empty
logger.error(f'Command output:\n{e.output}')
if self.rolling_logger.is_enabled():
logger.error(
'Docker build output:\n' + self.rolling_logger.all_lines
) # Show the error
logger.error(f'Image build failed with exit code {e.returncode}')
if e.output:
logger.error(f'Command output:\n{e.output}')
elif self.rolling_logger.is_enabled() and self.rolling_logger.all_lines:
logger.error(f'Docker build output:\n{self.rolling_logger.all_lines}')
raise
except subprocess.TimeoutExpired:
@@ -322,6 +322,8 @@ class ActionExecutionClient(Runtime):
)
assert response.is_closed
output = response.json()
if getattr(action, 'hidden', False):
output.get('extras')['hidden'] = True
obs = observation_from_dict(output)
obs._cause = action.id # type: ignore[attr-defined]
except httpx.TimeoutException:
+1 -1
View File
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.52-nikolaik"
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.53-nikolaik"
```
#### Additional Kubernetes Options
+10 -5
View File
@@ -312,7 +312,11 @@ class BashSession:
return command_output.rstrip()
def _handle_completed_command(
self, command: str, pane_content: str, ps1_matches: list[re.Match]
self,
command: str,
pane_content: str,
ps1_matches: list[re.Match],
hidden: bool,
) -> CmdOutputObservation:
is_special_key = self._is_special_key(command)
assert len(ps1_matches) >= 1, (
@@ -359,6 +363,7 @@ class BashSession:
content=command_output,
command=command,
metadata=metadata,
hidden=hidden,
)
def _handle_nochange_timeout_command(
@@ -549,11 +554,9 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[Your command "{command}" is NOT executed. '
f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'By setting `is_input` to `true`, you can interact with the current process: '
"You may wait longer to see additional output of the previous command by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
logger.debug(f'PREVIOUS COMMAND OUTPUT: {raw_command_output}')
command_output = self._get_command_output(
@@ -566,6 +569,7 @@ class BashSession:
command=command,
content=command_output,
metadata=metadata,
hidden=getattr(action, 'hidden', False),
)
# Send actual command/inputs to the pane
@@ -616,6 +620,7 @@ class BashSession:
command,
pane_content=cur_pane_output,
ps1_matches=ps1_matches,
hidden=getattr(action, 'hidden', False),
)
# Timeout checks should only trigger if a new prompt hasn't appeared yet.
+1 -1
View File
@@ -2,6 +2,6 @@
TIMEOUT_MESSAGE_TEMPLATE = (
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'send keys to interrupt/kill the command, '
'send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command, '
'or use the timeout parameter in execute_bash for future commands.'
)
@@ -1,5 +1,7 @@
FROM {{ base_image }}
SHELL ["/bin/bash", "-c"]
# Shared environment variables
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
MAMBA_ROOT_PREFIX=/openhands/micromamba \
@@ -94,13 +96,8 @@ RUN \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
# Add the repository to Apt sources
# For Debian, if it's noble (testing/unstable), use bookworm (stable) repository
if [ "$(lsb_release -cs)" = "noble" ]; then \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
else \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
fi; \
# Add the repository to Apt sources (default to bookworm for stability)
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
fi && \
# Install Docker Engine, containerd, and Docker Compose
apt-get update && \
+1 -3
View File
@@ -855,9 +855,7 @@ class WindowsPowershellSession:
f'\n[Your command "{command}" is NOT executed. '
f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'By setting `is_input` to `true`, you can interact with the current process: '
"You may wait longer to see additional output of the previous command by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
return CmdOutputObservation(
Generated
+89 -8
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "aiofiles"
@@ -3462,7 +3462,6 @@ files = [
{file = "greenlet-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:eeb27bece45c0c2a5842ac4c5a1b5c2ceaefe5711078eed4e8043159fa05c834"},
{file = "greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485"},
]
markers = {test = "platform_python_implementation == \"CPython\""}
[package.extras]
docs = ["Sphinx", "furo"]
@@ -7063,7 +7062,7 @@ version = "1.52.0"
description = "A high-level API to automate web browsers"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation"]
groups = ["main", "evaluation", "test"]
files = [
{file = "playwright-1.52.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:19b2cb9d4794062008a635a99bd135b03ebb782d460f96534a91cb583f549512"},
{file = "playwright-1.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0797c0479cbdc99607412a3c486a3a2ec9ddc77ac461259fd2878c975bcbb94a"},
@@ -7737,7 +7736,7 @@ version = "13.0.0"
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
groups = ["main", "evaluation", "test"]
files = [
{file = "pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498"},
{file = "pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37"},
@@ -7975,6 +7974,25 @@ pytest = ">=8.2,<9"
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
description = "pytest plugin for URL based testing"
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6"},
{file = "pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45"},
]
[package.dependencies]
pytest = ">=7.0.0"
requests = ">=2.9"
[package.extras]
test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-localserver (>=0.7.1)", "tox (>=3.24.5)"]
[[package]]
name = "pytest-cov"
version = "6.2.1"
@@ -8011,6 +8029,39 @@ files = [
py = "*"
pytest = ">=3.10"
[[package]]
name = "pytest-playwright"
version = "0.7.0"
description = "A pytest wrapper with fixtures for Playwright to automate web browsers"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pytest_playwright-0.7.0-py3-none-any.whl", hash = "sha256:2516d0871fa606634bfe32afbcc0342d68da2dbff97fe3459849e9c428486da2"},
{file = "pytest_playwright-0.7.0.tar.gz", hash = "sha256:b3f2ea514bbead96d26376fac182f68dcd6571e7cb41680a89ff1673c05d60b6"},
]
[package.dependencies]
playwright = ">=1.18"
pytest = ">=6.2.4,<9.0.0"
pytest-base-url = ">=1.0.0,<3.0.0"
python-slugify = ">=6.0.0,<9.0.0"
[[package]]
name = "pytest-timeout"
version = "2.4.0"
description = "pytest plugin to abort hanging tests"
optional = false
python-versions = ">=3.7"
groups = ["test"]
files = [
{file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
{file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
]
[package.dependencies]
pytest = ">=7.0.0"
[[package]]
name = "pytest-xdist"
version = "3.8.0"
@@ -8177,6 +8228,24 @@ Pillow = ">=3.3.2"
typing-extensions = ">=4.9.0"
XlsxWriter = ">=0.5.7"
[[package]]
name = "python-slugify"
version = "8.0.4"
description = "A Python slugify application that also handles Unicode"
optional = false
python-versions = ">=3.7"
groups = ["test"]
files = [
{file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"},
{file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"},
]
[package.dependencies]
text-unidecode = ">=1.3"
[package.extras]
unidecode = ["Unidecode (>=1.1.1)"]
[[package]]
name = "python-socketio"
version = "5.13.0"
@@ -8769,7 +8838,7 @@ version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation", "runtime"]
groups = ["main", "evaluation", "runtime", "test"]
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
@@ -9741,7 +9810,7 @@ test = ["pytest", "pytest-cov"]
type = "git"
url = "https://github.com/ryanhoangt/SWE-bench.git"
reference = "fix-modal-patch-eval"
resolved_reference = "03846bfaa2f1bad5e72354094ac308590d5e2b37"
resolved_reference = "aa0f1ed4c3b57828c8b9b2821b0ce74e201cf813"
[[package]]
name = "swegym"
@@ -9896,6 +9965,18 @@ aiohttp = ">=3.8,<4.0"
huggingface-hub = ">=0.12,<1.0"
pydantic = ">2,<3"
[[package]]
name = "text-unidecode"
version = "1.3"
description = "The most basic Text::Unidecode port"
optional = false
python-versions = "*"
groups = ["test"]
files = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
]
[[package]]
name = "tifffile"
version = "2025.6.1"
@@ -10655,7 +10736,7 @@ version = "2.4.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation", "runtime"]
groups = ["main", "evaluation", "runtime", "test"]
files = [
{file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"},
{file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"},
@@ -11797,4 +11878,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "9fd177a2dfa1eebb9212e515db93c58f82d6126cc2d131de5321d68772bc2a59"
content-hash = "dbcab8224ee537e465f51c5170d8c19e749236c7ba01268f459140c95266afd7"
+3 -1
View File
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.52.1"
version = "0.53.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -126,6 +126,8 @@ pytest-cov = "*"
pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
pytest-playwright = "^0.7.0"
pytest-timeout = "^2.4.0"
openai = "*"
pandas = "*"
reportlab = "*"
+112
View File
@@ -0,0 +1,112 @@
# OpenHands End-to-End Tests
This directory contains end-to-end tests for the OpenHands application. These tests use Playwright to interact with the OpenHands UI and verify that the application works correctly.
## Running the Tests
### Prerequisites
- Python 3.12 or later
- Poetry
- Node.js
- Playwright
### Environment Variables
The following environment variables are required:
- `GITHUB_TOKEN`: A GitHub token with access to the repositories you want to test
- `LLM_MODEL`: The LLM model to use (e.g., "gpt-4o")
- `LLM_API_KEY`: The API key for the LLM model
Optional environment variables:
- `LLM_BASE_URL`: The base URL for the LLM API (if using a custom endpoint)
### Running Locally
To run the full end-to-end test suite locally:
```bash
cd tests/e2e
poetry run pytest test_e2e_workflow.py -v
```
This runs all tests in sequence:
1. GitHub token configuration
2. Conversation start
### Running Individual Tests
You can run individual tests directly:
```bash
cd tests/e2e
# Run the GitHub token configuration test
poetry run pytest test_e2e_workflow.py::test_github_token_configuration -v
# Run the conversation start test
poetry run pytest test_e2e_workflow.py::test_conversation_start -v
```
### Running with Visible Browser
To run the tests with a visible browser (non-headless mode) so you can watch the browser interactions:
```bash
cd tests/e2e
poetry run pytest test_e2e_workflow.py::test_github_token_configuration -v --no-headless --slow-mo=50
poetry run pytest test_e2e_workflow.py::test_conversation_start -v --no-headless --slow-mo=50
```
### GitHub Workflow
The tests can also be run as part of a GitHub workflow. The workflow is triggered by:
1. Adding the "end-to-end" label to a pull request
2. Manually triggering the workflow from the GitHub Actions tab
## Test Descriptions
### GitHub Token Configuration Test
The GitHub token configuration test (`test_github_token_configuration`) performs the following steps:
1. Navigates to the OpenHands application
2. Checks if the GitHub token is already configured:
- If not configured, it navigates to the settings page and configures it
- If already configured, it verifies the repository selection is available
3. Verifies that the GitHub token is saved and the repository selection is available
### Conversation Start Test
The conversation start test (`test_conversation_start`) performs the following steps:
1. Navigates to the OpenHands application (assumes GitHub token is already configured)
2. Selects the "openhands-agent/OpenHands" repository
3. Clicks the "Launch" button
4. Waits for the conversation interface to load
5. Waits for the agent to initialize
6. Asks "How many lines are there in the main README.md file?"
7. Waits for and verifies the agent's response
### Simple Browser Navigation Test
A simple test (`test_simple_browser_navigation`) that just navigates to the OpenHands GitHub repository to verify the browser setup works correctly.
### Local Runtime Test
A separate test (`test_headless_mode_with_dummy_agent_no_browser` in `test_local_runtime.py`) that tests the local runtime with a dummy agent in headless mode.
## Troubleshooting
If the tests fail, check the following:
1. Make sure all required environment variables are set
2. Check the logs in `/tmp/openhands-e2e-test.log` and `/tmp/openhands-e2e-build.log`
3. Verify that the OpenHands application is running correctly
4. Check the Playwright test results in the `test-results` directory
+15
View File
@@ -0,0 +1,15 @@
import sys
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
if p.chromium.executable_path:
print('chromium_found')
sys.exit(0)
else:
print('chromium_not_found')
sys.exit(1)
except Exception as e:
print(f'error: {e}')
sys.exit(1)
+46
View File
@@ -0,0 +1,46 @@
import pytest
def pytest_addoption(parser):
"""Add command-line options for controlling browser behavior."""
parser.addoption(
'--headless',
action='store_true',
default=True,
help='Run browser in headless mode (default)',
)
parser.addoption(
'--no-headless',
action='store_false',
dest='headless',
help='Run browser in non-headless mode to watch the browser',
)
parser.addoption(
'--slow-mo',
action='store',
default=0,
type=int,
help='Add delay between actions in milliseconds (default: 0)',
)
@pytest.fixture(scope='session')
def browser_context_args(browser_context_args):
"""Return the browser context args."""
return browser_context_args
@pytest.fixture(scope='session')
def browser_type_launch_args(request):
"""Override the browser launch arguments based on command-line options."""
headless = request.config.getoption('--headless')
slow_mo = request.config.getoption('--slow-mo')
args = {
'headless': headless,
}
if slow_mo > 0:
args['slow_mo'] = slow_mo
return args
+6
View File
@@ -0,0 +1,6 @@
[pytest]
testpaths = tests/e2e
python_files = test_*.py
python_classes = Test*
python_functions = test_*
timeout = 300
+604
View File
@@ -0,0 +1,604 @@
"""
E2E: Conversation start test
This test assumes the GitHub token has already been configured (by the
settings test) and verifies that a conversation can be started and the
agent responds to a README line-count question.
"""
import os
import time
from playwright.sync_api import Page, expect
def get_readme_line_count():
"""Get the line count of the main README.md file for verification."""
current_dir = os.getcwd()
if current_dir.endswith('tests/e2e'):
repo_root = os.path.abspath(os.path.join(current_dir, '../..'))
else:
repo_root = current_dir
readme_path = os.path.join(repo_root, 'README.md')
print(f'Looking for README.md at: {readme_path}')
try:
with open(readme_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
return len(lines)
except (FileNotFoundError, IOError, OSError) as e:
print(f'Error reading README.md: {e}')
return 0
def test_conversation_start(page: Page):
"""
Test starting a conversation with the OpenHands agent:
1. Navigate to OpenHands (assumes GitHub token is already configured)
2. Select the OpenHands repository
3. Click Launch
4. Wait for the agent to initialize
5. Ask a question about the README.md file
6. Verify the agent responds correctly
"""
# Create test-results directory if it doesn't exist
os.makedirs('test-results', exist_ok=True)
expected_line_count = get_readme_line_count()
print(f'Expected README.md line count: {expected_line_count}')
# Navigate to the OpenHands application
print('Step 1: Navigating to OpenHands application...')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle', timeout=30000)
# Take initial screenshot
page.screenshot(path='test-results/conv_01_initial_load.png')
print('Screenshot saved: conv_01_initial_load.png')
# Step 2: Select the OpenHands repository
print('Step 2: Selecting openhands-agent/OpenHands repository...')
# Wait for the home screen to load
home_screen = page.locator('[data-testid="home-screen"]')
expect(home_screen).to_be_visible(timeout=15000)
print('Home screen is visible')
# Look for the repository dropdown/selector
repo_dropdown = page.locator('[data-testid="repo-dropdown"]')
expect(repo_dropdown).to_be_visible(timeout=15000)
print('Repository dropdown is visible')
# Click on the repository input to open dropdown
repo_dropdown.click()
page.wait_for_timeout(1000)
# Type the repository name
try:
page.keyboard.press('Control+a') # Select all
page.keyboard.type('openhands-agent/OpenHands')
print('Used keyboard.type() for React Select component')
except Exception as e:
print(f'Keyboard input failed: {e}')
page.wait_for_timeout(2000) # Wait for search results
# Try to find and click the repository option
option_selectors = [
'[data-testid="repo-dropdown"] [role="option"]:has-text("openhands-agent/OpenHands")',
'[data-testid="repo-dropdown"] [role="option"]:has-text("OpenHands")',
'[data-testid="repo-dropdown"] div[id*="option"]:has-text("openhands-agent/OpenHands")',
'[data-testid="repo-dropdown"] div[id*="option"]:has-text("OpenHands")',
'[role="option"]:has-text("openhands-agent/OpenHands")',
'[role="option"]:has-text("OpenHands")',
'div:has-text("openhands-agent/OpenHands"):not([id="aria-results"])',
'div:has-text("OpenHands"):not([id="aria-results"])',
]
option_found = False
for selector in option_selectors:
try:
option = page.locator(selector).first
if option.is_visible(timeout=3000):
print(f'Found repository option with selector: {selector}')
try:
option.click(force=True)
print('Successfully clicked option with force=True')
option_found = True
page.wait_for_timeout(2000)
break
except Exception:
continue
except Exception:
continue
if not option_found:
print(
'Could not find repository option in dropdown, trying keyboard navigation'
)
page.keyboard.press('ArrowDown')
page.wait_for_timeout(500)
page.keyboard.press('Enter')
print('Used keyboard navigation to select option')
page.screenshot(path='test-results/conv_02_repo_selected.png')
print('Screenshot saved: conv_02_repo_selected.png')
# Step 3: Click Launch button
print('Step 3: Clicking Launch button...')
launch_button = page.locator('[data-testid="repo-launch-button"]')
expect(launch_button).to_be_visible(timeout=10000)
# Wait for the button to be enabled (not disabled)
max_wait_attempts = 30
button_enabled = False
for attempt in range(max_wait_attempts):
try:
is_disabled = launch_button.is_disabled()
if not is_disabled:
print(
f'Repository Launch button is now enabled (attempt {attempt + 1})'
)
button_enabled = True
break
else:
print(
f'Launch button still disabled, waiting... (attempt {attempt + 1}/{max_wait_attempts})'
)
page.wait_for_timeout(2000)
except Exception as e:
print(f'Error checking button state (attempt {attempt + 1}): {e}')
page.wait_for_timeout(2000)
try:
if button_enabled:
launch_button.click()
print('Launch button clicked normally')
else:
print('Launch button still disabled, trying JavaScript force click...')
result = page.evaluate("""() => {
const button = document.querySelector('[data-testid="repo-launch-button"]');
if (button) {
console.log('Found button, removing disabled attribute');
button.removeAttribute('disabled');
console.log('Clicking button');
button.click();
return true;
}
return false;
}""")
if result:
print('Successfully force-clicked Launch button with JavaScript')
else:
print('JavaScript could not find the Launch button')
except Exception as e:
print(f'Error clicking Launch button: {e}')
page.screenshot(path='test-results/conv_03_launch_error.png')
print('Screenshot saved: conv_03_launch_error.png')
raise
# Step 4: Wait for conversation interface to load
print('Step 4: Waiting for conversation interface to load...')
navigation_timeout = 300000 # 5 minutes
check_interval = 10000 # 10 seconds
page.screenshot(path='test-results/conv_04_after_launch.png')
print('Screenshot saved: conv_04_after_launch.png')
loading_selectors = [
'[data-testid="loading-indicator"]',
'[data-testid="loading-spinner"]',
'.loading-spinner',
'.spinner',
'div:has-text("Loading...")',
'div:has-text("Initializing...")',
'div:has-text("Please wait...")',
]
for selector in loading_selectors:
try:
loading = page.locator(selector)
if loading.is_visible(timeout=5000):
print(f'Found loading indicator with selector: {selector}')
print('Waiting for loading to complete...')
expect(loading).not_to_be_visible(timeout=120000)
print('Loading completed')
break
except Exception:
continue
try:
current_url = page.url
print(f'Current URL: {current_url}')
if '/conversation/' in current_url or '/chat/' in current_url:
print('URL indicates conversation page has loaded')
except Exception as e:
print(f'Error checking URL: {e}')
start_time = time.time()
conversation_loaded = False
while time.time() - start_time < navigation_timeout / 1000:
try:
selectors = [
'.scrollbar.flex.flex-col.grow',
'[data-testid="chat-input"]',
'[data-testid="app-route"]',
'[data-testid="conversation-screen"]',
'[data-testid="message-input"]',
'.conversation-container',
'.chat-container',
'textarea',
'form textarea',
'div[role="main"]',
'main',
]
for selector in selectors:
try:
element = page.locator(selector)
if element.is_visible(timeout=2000):
print(
f'Found conversation interface element with selector: {selector}'
)
conversation_loaded = True
break
except Exception:
continue
if conversation_loaded:
break
if (time.time() - start_time) % (check_interval / 1000) < 1:
elapsed = int(time.time() - start_time)
page.screenshot(path=f'test-results/conv_05_waiting_{elapsed}s.png')
print(f'Screenshot saved: conv_05_waiting_{elapsed}s.png')
page.wait_for_timeout(5000)
except Exception as e:
print(f'Error checking for conversation interface: {e}')
page.wait_for_timeout(5000)
if not conversation_loaded:
print('Timed out waiting for conversation interface to load')
page.screenshot(path='test-results/conv_06_timeout.png')
print('Screenshot saved: conv_06_timeout.png')
raise TimeoutError('Timed out waiting for conversation interface to load')
# Step 5: Wait for agent to initialize
print('Step 5: Waiting for agent to initialize...')
try:
chat_input = page.locator('[data-testid="chat-input"]')
expect(chat_input).to_be_visible(timeout=60000)
submit_button = page.locator('[data-testid="chat-input"] button[type="submit"]')
expect(submit_button).to_be_visible(timeout=10000)
print('Agent interface is loaded')
page.wait_for_timeout(10000)
except Exception as e:
print(f'Could not confirm agent interface is loaded: {e}')
page.screenshot(path='test-results/conv_07_agent_ready.png')
print('Screenshot saved: conv_07_agent_ready.png')
# Step 6: Wait for agent to be fully ready for input
print('Step 6: Waiting for agent to be fully ready for input...')
max_wait_time = 480
start_time = time.time()
agent_ready = False
print(f'Waiting up to {max_wait_time} seconds for agent to be ready...')
while time.time() - start_time < max_wait_time:
elapsed = int(time.time() - start_time)
if elapsed % 30 == 0 and elapsed > 0:
page.screenshot(path=f'test-results/conv_waiting_{elapsed}s.png')
print(f'Screenshot saved: conv_waiting_{elapsed}s.png (waiting {elapsed}s)')
try:
status_messages = []
status_bar_selector = '.bg-base-secondary .text-stone-400'
try:
status_elements = page.locator(status_bar_selector)
if status_elements.count() > 0:
for i in range(status_elements.count()):
text = status_elements.nth(i).text_content()
if text and text.strip():
status_messages.append(text.strip())
except Exception:
pass
ready_indicators = [
'div:has-text("Agent is ready")',
'div:has-text("Waiting for user input")',
'div:has-text("Awaiting input")',
'div:has-text("Task completed")',
'div:has-text("Agent has finished")',
]
input_ready = False
submit_ready = False
try:
input_field = page.locator('[data-testid="chat-input"] textarea')
submit_button = page.locator(
'[data-testid="chat-input"] button[type="submit"]'
)
if (
input_field.is_visible(timeout=2000)
and input_field.is_enabled(timeout=2000)
and submit_button.is_visible(timeout=2000)
and submit_button.is_enabled(timeout=2000)
):
print(
'Chat input field and submit button are both visible and enabled'
)
input_ready = True
submit_ready = True
except Exception:
pass
connecting_or_starting = any(
msg
for msg in status_messages
if 'connecting' in msg.lower()
or 'starting' in msg.lower()
or 'runtime to start' in msg.lower()
)
has_ready_indicator = False
for indicator in ready_indicators:
try:
element = page.locator(indicator)
if element.is_visible(timeout=2000):
print(f'Agent appears ready (found: {indicator})')
has_ready_indicator = True
break
except Exception:
continue
if (
(has_ready_indicator or not connecting_or_starting)
and input_ready
and submit_ready
):
print(
'✅ Agent is ready for user input - input field and submit button are enabled'
)
agent_ready = True
break
elif (
not connecting_or_starting
and not status_messages
and input_ready
and submit_ready
):
print(
'No status messages found and input is ready, agent appears ready...'
)
agent_ready = True
break
except Exception as e:
print(f'Error checking agent ready state: {e}')
page.wait_for_timeout(2000)
if not agent_ready:
page.screenshot(path='test-results/conv_timeout_waiting_for_agent.png')
raise AssertionError(
f'Agent did not become ready for input within {max_wait_time} seconds'
)
# Step 7: Ask a question about the README.md file
print('Step 7: Asking question about README.md file...')
input_selectors = [
'[data-testid="chat-input"] textarea',
'[data-testid="message-input"]',
'textarea',
'form textarea',
'input[type="text"]',
'[placeholder*="message"]',
'[placeholder*="question"]',
'[placeholder*="ask"]',
'[contenteditable="true"]',
]
message_input = None
for selector in input_selectors:
try:
input_element = page.locator(selector)
if input_element.is_visible(timeout=5000):
print(f'Found message input with selector: {selector}')
message_input = input_element
break
except Exception:
continue
if not message_input:
print('Could not find message input, trying to reload the page')
page.screenshot(path='test-results/conv_08_no_input_found.png')
print('Screenshot saved: conv_08_no_input_found.png')
try:
print('Reloading the page...')
page.reload()
page.wait_for_load_state('networkidle', timeout=30000)
print('Page reloaded')
for selector in input_selectors:
try:
input_element = page.locator(selector)
if input_element.is_visible(timeout=5000):
print(
f'Found message input after reload with selector: {selector}'
)
message_input = input_element
break
except Exception:
continue
except Exception as e:
print(f'Error reloading page: {e}')
if not message_input:
print('Still could not find message input, taking final screenshot')
page.screenshot(path='test-results/conv_09_reload_failed.png')
print('Screenshot saved: conv_09_reload_failed.png')
raise AssertionError('Could not find message input field after reload')
message_input.fill(
'How many lines are there in the README.md file in the root directory of this repository? Please use wc -l README.md to count the lines.'
)
print('Entered question about README.md line count')
submit_selectors = [
'[data-testid="chat-input"] button[type="submit"]',
'button[type="submit"]',
'button:has-text("Send")',
'button:has-text("Submit")',
'button svg[data-testid="send-icon"]',
'button.send-button',
'form button',
'button:right-of(textarea)',
'button:right-of(input[type="text"])',
]
submit_button = None
for selector in submit_selectors:
try:
button_element = page.locator(selector)
if button_element.is_visible(timeout=5000):
print(f'Found submit button with selector: {selector}')
submit_button = button_element
break
except Exception:
continue
button_enabled = False
if submit_button:
max_wait_time = 60
start_time = time.time()
while time.time() - start_time < max_wait_time:
try:
if not submit_button.is_disabled():
button_enabled = True
print('Submit button is enabled')
break
print(
f'Waiting for submit button to be enabled... ({int(time.time() - start_time)}s)'
)
except Exception as e:
print(f'Error checking if button is disabled: {e}')
page.wait_for_timeout(2000)
if not submit_button or not button_enabled:
print('Submit button not found or never became enabled, trying alternatives')
try:
message_input.press('Enter')
print('Pressed Enter key to submit')
button_enabled = True
except Exception as e:
print(f'Error pressing Enter key: {e}')
if submit_button:
try:
page.evaluate("""() => {
const button = document.querySelector('[data-testid="chat-input"] button[type="submit"]');
if (button) {
button.removeAttribute('disabled');
button.click();
return true;
}
return false;
}""")
print('Used JavaScript to force click submit button')
button_enabled = True
except Exception as e2:
print(f'JavaScript force click failed: {e2}')
if not button_enabled:
page.screenshot(path='test-results/conv_09_submit_failed.png')
print('Screenshot saved: conv_09_submit_failed.png')
raise RuntimeError('Could not submit message')
else:
submit_button.click()
print('Clicked submit button')
page.screenshot(path='test-results/conv_08_question_sent.png')
print('Screenshot saved: conv_08_question_sent.png')
print('Step 8: Waiting for agent response to README question...')
response_wait_time = 180
response_start_time = time.time()
while time.time() - response_start_time < response_wait_time:
elapsed = int(time.time() - response_start_time)
if elapsed % 30 == 0 and elapsed > 0:
page.screenshot(path=f'test-results/conv_response_wait_{elapsed}s.png')
print(
f'Screenshot saved: conv_response_wait_{elapsed}s.png (waiting {elapsed}s for response)'
)
try:
agent_messages = page.locator('[data-testid="agent-message"]').all()
if elapsed % 30 == 0:
print(f'Found {len(agent_messages)} agent messages')
for i, msg in enumerate(agent_messages):
try:
content = msg.text_content()
if content and len(content.strip()) > 10:
content_lower = content.lower()
import re
line_count_pattern = r'\b(\d{3})\b'
line_counts = re.findall(line_count_pattern, content)
if (
(
str(expected_line_count) in content
and 'readme' in content_lower
)
or (
'line' in content_lower
and 'readme' in content_lower
and any(
num in content
for num in ['183', str(expected_line_count)]
)
)
or (
'line' in content_lower
and 'readme' in content_lower
and line_counts
and any(100 <= int(num) <= 300 for num in line_counts)
)
):
print(
'✅ Found agent response about README.md with line count!'
)
page.screenshot(
path='test-results/conv_09_agent_response.png'
)
print('Screenshot saved: conv_09_agent_response.png')
page.screenshot(path='test-results/conv_10_final_state.png')
print('Screenshot saved: conv_10_final_state.png')
print(
'✅ Test completed successfully - agent provided correct README line count'
)
return
except Exception as e:
print(f'Error processing agent message {i}: {e}')
continue
except Exception as e:
print(f'Error checking for agent messages: {e}')
page.wait_for_timeout(5000)
print('❌ Did not find agent response with README line count within time limit')
page.screenshot(path='test-results/conv_09_agent_response.png')
print('Screenshot saved: conv_09_agent_response.png')
page.screenshot(path='test-results/conv_10_final_state.png')
print('Screenshot saved: conv_10_final_state.png')
raise AssertionError(
'Agent response did not include README line count within time limit'
)
+290
View File
@@ -0,0 +1,290 @@
"""
E2E: Settings configuration test (GitHub token)
This test navigates to OpenHands, configures the LLM API key if prompted,
then ensures the GitHub token is set in Settings → Integrations and that the
home screen shows the repository selector.
"""
import os
from playwright.sync_api import Page, expect
def test_github_token_configuration(page: Page):
"""
Test the GitHub token configuration flow:
1. Navigate to OpenHands
2. Configure LLM API key if needed
3. Check if GitHub token is already configured
4. If not, navigate to settings and configure it
5. Verify the token is saved and repository selection is available
"""
# Create test-results directory if it doesn't exist
os.makedirs('test-results', exist_ok=True)
# Navigate to the OpenHands application
print('Step 1: Navigating to OpenHands application...')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle', timeout=30000)
# Take initial screenshot
page.screenshot(path='test-results/token_01_initial_load.png')
print('Screenshot saved: token_01_initial_load.png')
# Step 1.5: Handle any initial modals that might appear (LLM API key configuration)
try:
# Check for AI Provider Configuration modal
config_modal = page.locator('text=AI Provider Configuration')
if config_modal.is_visible(timeout=5000):
print('AI Provider Configuration modal detected')
# Fill in the LLM API key if available
llm_api_key_input = page.locator('[data-testid="llm-api-key-input"]')
if llm_api_key_input.is_visible(timeout=3000):
llm_api_key = os.getenv('LLM_API_KEY', 'test-key')
llm_api_key_input.fill(llm_api_key)
print(f'Filled LLM API key (length: {len(llm_api_key)})')
# Click the Save button
save_button = page.locator('button:has-text("Save")')
if save_button.is_visible(timeout=3000):
save_button.click()
page.wait_for_timeout(2000)
print('Saved LLM API key configuration')
# Check for Privacy Preferences modal
privacy_modal = page.locator('text=Your Privacy Preferences')
if privacy_modal.is_visible(timeout=5000):
print('Privacy Preferences modal detected')
confirm_button = page.locator('button:has-text("Confirm Preferences")')
if confirm_button.is_visible(timeout=3000):
confirm_button.click()
page.wait_for_timeout(2000)
print('Confirmed privacy preferences')
except Exception as e:
print(f'Error handling initial modals: {e}')
page.screenshot(path='test-results/token_01_5_modal_error.png')
print('Screenshot saved: token_01_5_modal_error.png')
# Step 2: Check if GitHub token is already configured or needs to be set
print('Step 2: Checking if GitHub token is configured...')
try:
# First, check if we're already on the home screen with repository selection
# This means the GitHub token is already configured in ~/.openhands/settings.json
connect_to_provider = page.locator('text=Connect to a Repository')
if connect_to_provider.is_visible(timeout=3000):
print('Found "Connect to a Repository" section')
# Check if we need to configure a provider (GitHub token)
navigate_to_settings_button = page.locator(
'[data-testid="navigate-to-settings-button"]'
)
if navigate_to_settings_button.is_visible(timeout=3000):
print('GitHub token not configured. Need to navigate to settings...')
# Click the Settings button to navigate to the settings page
navigate_to_settings_button.click()
page.wait_for_load_state('networkidle', timeout=10000)
page.wait_for_timeout(3000) # Wait for navigation to complete
# We should now be on the /settings/integrations page
print('Navigated to settings page, looking for GitHub token input...')
# Check if we're on the settings page with the integrations tab
settings_screen = page.locator('[data-testid="settings-screen"]')
if settings_screen.is_visible(timeout=5000):
print('Settings screen is visible')
# Make sure we're on the Integrations tab
integrations_tab = page.locator('text=Integrations')
if integrations_tab.is_visible(timeout=3000):
# Check if we need to click the tab
if not page.url.endswith('/settings/integrations'):
print('Clicking Integrations tab...')
integrations_tab.click()
page.wait_for_load_state('networkidle')
page.wait_for_timeout(2000)
# Now look for the GitHub token input
github_token_input = page.locator(
'[data-testid="github-token-input"]'
)
if github_token_input.is_visible(timeout=5000):
print('Found GitHub token input field')
# Fill in the GitHub token from environment variable
github_token = os.getenv('GITHUB_TOKEN', '')
if github_token:
# Clear the field first, then fill it
github_token_input.clear()
github_token_input.fill(github_token)
print(
f'Filled GitHub token from environment variable (length: {len(github_token)})'
)
# Verify the token was filled
filled_value = github_token_input.input_value()
if filled_value:
print(
f'Token field now contains value of length: {len(filled_value)}'
)
else:
print(
'WARNING: Token field appears to be empty after filling'
)
# Look for the Save Changes button and ensure it's enabled
save_button = page.locator('[data-testid="submit-button"]')
if save_button.is_visible(timeout=3000):
# Check if button is enabled
is_disabled = save_button.is_disabled()
print(
f'Save Changes button found, disabled: {is_disabled}'
)
if not is_disabled:
print('Clicking Save Changes button...')
save_button.click()
# Wait for the save operation to complete
try:
# Wait for the button to show "Saving..." (if it does)
page.wait_for_timeout(1000)
# Wait for the save to complete - button should be disabled again
page.wait_for_function(
'document.querySelector(\'[data-testid="submit-button"]\').disabled === true',
timeout=10000,
)
print(
'Save operation completed - form is now clean'
)
except Exception:
print(
'Save operation completed (timeout waiting for form clean state)'
)
# Navigate back to home page after successful save
print('Navigating back to home page...')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle')
page.wait_for_timeout(
5000
) # Wait longer for providers to be updated
else:
print(
'Save Changes button is disabled - form may be invalid'
)
else:
print('Save Changes button not found')
else:
print('No GitHub token found in environment variables')
else:
print('GitHub token input field not found on settings page')
# Take a screenshot to see what's on the page
page.screenshot(path='test-results/token_02_settings_debug.png')
print('Debug screenshot saved: token_02_settings_debug.png')
else:
print('Settings screen not found')
else:
# Branch 2: GitHub token is already configured, repository selection is available
print(
'GitHub token is already configured, repository selection is available'
)
# Check if we need to update the token by going to settings manually
settings_button = page.locator('button:has-text("Settings")')
if settings_button.is_visible(timeout=3000):
print(
'Settings button found, clicking to navigate to settings page...'
)
settings_button.click()
page.wait_for_load_state('networkidle', timeout=10000)
page.wait_for_timeout(3000) # Wait for navigation to complete
# Navigate to the Integrations tab
integrations_tab = page.locator('text=Integrations')
if integrations_tab.is_visible(timeout=3000):
print('Clicking Integrations tab...')
integrations_tab.click()
page.wait_for_load_state('networkidle')
page.wait_for_timeout(2000)
# Now look for the GitHub token input
github_token_input = page.locator(
'[data-testid="github-token-input"]'
)
if github_token_input.is_visible(timeout=5000):
print('Found GitHub token input field')
# Fill in the GitHub token from environment variable
github_token = os.getenv('GITHUB_TOKEN', '')
if github_token:
# Clear the field first, then fill it
github_token_input.clear()
github_token_input.fill(github_token)
print(
f'Filled GitHub token from environment variable (length: {len(github_token)})'
)
# Look for the Save Changes button and ensure it's enabled
save_button = page.locator(
'[data-testid="submit-button"]'
)
if (
save_button.is_visible(timeout=3000)
and not save_button.is_disabled()
):
print('Clicking Save Changes button...')
save_button.click()
page.wait_for_timeout(3000)
# Navigate back to home page
print('Navigating back to home page...')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle')
page.wait_for_timeout(3000)
else:
print(
'GitHub token input field not found, going back to home page'
)
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle')
else:
print('Integrations tab not found, going back to home page')
page.goto('http://localhost:12000')
page.wait_for_load_state('networkidle')
else:
print('Settings button not found, continuing with existing token')
else:
print('Could not find "Connect to a Repository" section')
page.screenshot(path='test-results/token_03_after_settings.png')
print('Screenshot saved: token_03_after_settings.png')
except Exception as e:
print(f'Error checking GitHub token configuration: {e}')
page.screenshot(path='test-results/token_04_error.png')
print('Screenshot saved: token_04_error.png')
# Step 3: Verify we're back on the home screen with repository selection available
print('Step 3: Verifying repository selection is available...')
# Wait for the home screen to load
home_screen = page.locator('[data-testid="home-screen"]')
expect(home_screen).to_be_visible(timeout=15000)
print('Home screen is visible')
# Look for the repository dropdown/selector
repo_dropdown = page.locator('[data-testid="repo-dropdown"]')
expect(repo_dropdown).to_be_visible(timeout=15000)
print('Repository dropdown is visible')
# Success - we've verified the GitHub token configuration
print('GitHub token configuration verified successfully')
page.screenshot(path='test-results/token_05_success.png')
print('Screenshot saved: token_05_success.png')
+1 -352
View File
@@ -1104,219 +1104,7 @@ def test_azure_model_default_max_tokens():
assert llm.config.max_output_tokens is None # Default value
def test_llm_update_config_basic(default_config):
"""Test basic LLM configuration update functionality."""
llm = LLM(default_config)
# Verify initial state
assert llm.config.model == 'gpt-4o'
assert llm.config.temperature == 0.0
assert llm.metrics.model_name == 'gpt-4o'
# Create new config with different settings
new_config = LLMConfig(
model='claude-3-5-sonnet-20241022',
api_key='new_test_key',
temperature=0.7,
max_output_tokens=2000,
top_p=0.9,
)
# Update the configuration
llm.update_config(new_config)
# Verify the configuration was updated
assert llm.config.model == 'claude-3-5-sonnet-20241022'
assert llm.config.api_key.get_secret_value() == 'new_test_key'
assert llm.config.temperature == 0.7
assert llm.config.max_output_tokens == 2000
assert llm.config.top_p == 0.9
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
def test_llm_update_config_model_change_resets_model_info(default_config):
"""Test that changing model resets model info for re-initialization."""
llm = LLM(default_config)
# Set some model info to verify it gets reset
llm.model_info = {'test': 'info'}
llm._tried_model_info = True
# Create new config with different model
new_config = copy.deepcopy(default_config)
new_config.model = 'claude-3-5-sonnet-20241022'
# Update the configuration
llm.update_config(new_config)
# Verify model info was reset and metrics updated
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
# _tried_model_info should be reset to False to force re-initialization
# (it will be set back to True after init_model_info() is called)
def test_llm_update_config_same_model_preserves_model_info(default_config):
"""Test that keeping the same model preserves model info."""
llm = LLM(default_config)
# Create new config with same model but different other settings
new_config = copy.deepcopy(default_config)
new_config.temperature = 0.5
new_config.max_output_tokens = 1500
# Update the configuration
llm.update_config(new_config)
# Verify model info was preserved but other settings changed
assert llm.config.temperature == 0.5
assert llm.config.max_output_tokens == 1500
assert llm.metrics.model_name == 'gpt-4o' # Same model
def test_llm_update_config_custom_tokenizer_update(default_config):
"""Test updating custom tokenizer configuration."""
llm = LLM(default_config)
# Initially no custom tokenizer
assert llm.config.custom_tokenizer is None
assert llm.tokenizer is None
# Update config with custom tokenizer
new_config = copy.deepcopy(default_config)
new_config.custom_tokenizer = 'gpt2'
llm.update_config(new_config)
# Verify tokenizer was updated
assert llm.config.custom_tokenizer == 'gpt2'
assert llm.tokenizer is not None
# Update back to no custom tokenizer
new_config.custom_tokenizer = None
llm.update_config(new_config)
# Verify tokenizer was reset
assert llm.config.custom_tokenizer is None
assert llm.tokenizer is None
def test_llm_update_config_log_completions_folder_creation(default_config):
"""Test that log completions folder is created when needed."""
with tempfile.TemporaryDirectory() as temp_dir:
log_folder = Path(temp_dir) / 'test_completions'
llm = LLM(default_config)
# Update config to enable log completions
new_config = copy.deepcopy(default_config)
new_config.log_completions = True
new_config.log_completions_folder = str(log_folder)
# Folder shouldn't exist yet
assert not log_folder.exists()
# Update configuration
llm.update_config(new_config)
# Verify folder was created
assert log_folder.exists()
assert log_folder.is_dir()
def test_llm_update_config_log_completions_folder_required():
"""Test that log_completions_folder is required when log_completions is True."""
config = LLMConfig(model='gpt-4o', api_key='test_key')
llm = LLM(config)
# Create config with log_completions=True but no folder
new_config = copy.deepcopy(config)
new_config.log_completions = True
new_config.log_completions_folder = None
# Should raise RuntimeError
with pytest.raises(RuntimeError, match='log_completions_folder is required'):
llm.update_config(new_config)
def test_llm_update_config_completion_function_rebuilt(default_config):
"""Test that completion function is rebuilt after config update."""
llm = LLM(default_config)
# Store reference to original completion function
# Update configuration
new_config = copy.deepcopy(default_config)
new_config.temperature = 0.8
new_config.max_output_tokens = 1500
llm.update_config(new_config)
# Verify completion functions exist (they should be rebuilt)
assert llm._completion is not None
assert llm._completion_unwrapped is not None
# The functions should be different objects since they were rebuilt
# (though this is implementation detail, the important thing is they work)
assert callable(llm._completion)
assert callable(llm._completion_unwrapped)
def test_llm_update_config_preserves_metrics_and_retry_listener(default_config):
"""Test that metrics and retry listener are preserved during config update."""
# Create custom metrics and retry listener
custom_metrics = Metrics(model_name='initial_model')
retry_listener = MagicMock()
llm = LLM(default_config, metrics=custom_metrics, retry_listener=retry_listener)
# Verify initial state
assert llm.metrics is custom_metrics
assert llm.retry_listener is retry_listener
# Update configuration
new_config = copy.deepcopy(default_config)
new_config.model = 'claude-3-5-sonnet-20241022'
llm.update_config(new_config)
# Verify metrics and retry listener are preserved
assert llm.metrics is custom_metrics
assert llm.retry_listener is retry_listener
# But metrics model name should be updated
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
def test_llm_update_config_deep_copy_independence():
"""Test that config update uses deep copy and doesn't affect original config."""
original_config = LLMConfig(
model='gpt-4o',
api_key='test_key',
temperature=0.0,
)
llm = LLM(original_config)
# Create new config
new_config = LLMConfig(
model='claude-3-5-sonnet-20241022',
api_key='new_key',
temperature=0.7,
)
# Update LLM config
llm.update_config(new_config)
# Modify the new_config after update
new_config.temperature = 0.9
new_config.model = 'different-model'
# LLM config should not be affected by external changes
assert llm.config.temperature == 0.7
assert llm.config.model == 'claude-3-5-sonnet-20241022'
# Original config should also be unchanged
assert original_config.temperature == 0.0
assert original_config.model == 'gpt-4o'
# Gemini Performance Optimization Tests
def test_gemini_model_keeps_none_reasoning_effort():
@@ -1514,142 +1302,3 @@ def test_gemini_performance_optimization_end_to_end(mock_completion):
# Verify temperature and top_p were removed for reasoning models
assert 'temperature' not in call_kwargs
assert 'top_p' not in call_kwargs
def test_llm_reinit_basic(default_config):
"""Reinit should update config and metrics like update_config."""
llm = LLM(default_config)
assert llm.metrics.model_name == 'gpt-4o'
new_config = LLMConfig(
model='claude-3-5-sonnet-20241022',
api_key='new_test_key',
temperature=0.7,
max_output_tokens=1234,
)
llm.reinit(new_config)
assert llm.config.model == 'claude-3-5-sonnet-20241022'
assert llm.config.api_key.get_secret_value() == 'new_test_key'
assert llm.config.temperature == 0.7
assert llm.config.max_output_tokens == 1234
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
assert callable(llm._completion)
assert callable(llm._completion_unwrapped)
def test_llm_reinit_recomputes_function_calling_capability():
"""Reinit should recompute function-calling capability even if model doesn't change."""
base = LLMConfig(model='gpt-3.5-turbo', api_key='key')
llm = LLM(base)
# gpt-3.5-turbo is not in FUNCTION_CALLING_SUPPORTED_MODELS
assert llm.is_function_calling_active() is False
# Turn on native tool calling; same model
cfg_on = copy.deepcopy(base)
cfg_on.native_tool_calling = True
llm.reinit(cfg_on)
assert llm.is_function_calling_active() is True
# Turn off explicitly
cfg_off = copy.deepcopy(base)
cfg_off.native_tool_calling = False
llm.reinit(cfg_off)
assert llm.is_function_calling_active() is False
def test_llm_reinit_resets_cost_flag(default_config):
"""Reinit should reset cost_metric_supported so a new model can report cost."""
llm = LLM(default_config)
llm.cost_metric_supported = False
# Same model, but reinit should reset the flag to True
llm.reinit(copy.deepcopy(default_config))
assert llm.cost_metric_supported is True
def test_llm_reinit_thread_safety_with_inflight_completion(default_config):
"""Concurrent reinit during an in-flight completion should not raise,
and subsequent completions should use the new config.
"""
import threading
import time
from unittest.mock import patch
llm = LLM(default_config)
calls = []
def fake_completion(*args, **kwargs):
# Simulate provider latency and record the model used
calls.append(kwargs.get('model'))
time.sleep(0.2)
return {
'id': 'test-1',
'choices': [{'message': {'content': 'ok'}}],
'usage': {'prompt_tokens': 1, 'completion_tokens': 1},
}
with patch('openhands.llm.llm.litellm_completion') as mock_completion:
mock_completion.side_effect = fake_completion
# Start an in-flight completion with the initial model
t = threading.Thread(
target=llm.completion, args=([{'role': 'user', 'content': 'hi'}],)
)
t.start()
# Reinit while the completion is in-flight
time.sleep(0.05)
new_cfg = copy.deepcopy(default_config)
new_cfg.model = 'claude-3-5-sonnet-20241022'
llm.reinit(new_cfg)
t.join()
# Subsequent completion should use the new config
llm.completion(messages=[{'role': 'user', 'content': 'again'}])
# Ensure the latest call used the new model and metrics reflect it
assert len(calls) >= 1
assert calls[-1] == 'claude-3-5-sonnet-20241022'
assert llm.metrics.model_name == 'claude-3-5-sonnet-20241022'
@patch('openhands.llm.llm.litellm_completion')
def test_llm_reinit_provider_mappings(mock_completion, default_config):
"""Reinit should apply provider-specific mappings (openhands proxy, azure max_tokens)."""
# Return a minimal, valid response
mock_completion.return_value = {
'id': 'call',
'choices': [{'message': {'content': 'ok'}}],
'usage': {'prompt_tokens': 1, 'completion_tokens': 1},
}
llm = LLM(default_config)
# 1) OpenHands provider rewrite to litellm_proxy
cfg_proxy = copy.deepcopy(default_config)
cfg_proxy.model = 'openhands/qwen3-coder'
llm.reinit(cfg_proxy)
llm.completion(messages=[{'role': 'user', 'content': 'x'}])
model_arg = mock_completion.call_args.kwargs.get('model')
base_url_arg = mock_completion.call_args.kwargs.get('base_url')
assert model_arg == 'litellm_proxy/qwen3-coder'
assert base_url_arg == 'https://llm-proxy.app.all-hands.dev/'
# 2) Azure mapping: max_completion_tokens -> max_tokens
mock_completion.reset_mock()
cfg_azure = copy.deepcopy(default_config)
cfg_azure.model = 'azure/gpt-4o'
cfg_azure.max_output_tokens = 777
cfg_azure.api_version = '2024-06-01'
llm.reinit(cfg_azure)
llm.completion(messages=[{'role': 'user', 'content': 'y'}])
kwargs = mock_completion.call_args.kwargs
assert kwargs.get('model') == 'azure/gpt-4o'
assert 'max_tokens' in kwargs and kwargs['max_tokens'] == 777
assert 'max_completion_tokens' not in kwargs
+397
View File
@@ -0,0 +1,397 @@
import os
import re
import unittest
class TestCircularImports(unittest.TestCase):
"""Test to detect circular imports in the codebase."""
def test_no_circular_imports_in_key_modules(self):
"""
Test that there are no circular imports in key modules that were previously problematic.
This test specifically checks the modules that were involved in a previous circular import issue:
- openhands.utils.prompt
- openhands.agenthub.codeact_agent.tools.bash
- openhands.agenthub.codeact_agent.tools.prompt
- openhands.memory.memory
- openhands.memory.conversation_memory
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.utils.prompt': os.path.join(
project_root, 'openhands/utils/prompt.py'
),
'openhands.agenthub.codeact_agent.tools.bash': os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/bash.py'
),
'openhands.agenthub.codeact_agent.tools.prompt': os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/prompt.py'
),
'openhands.memory.memory': os.path.join(
project_root, 'openhands/memory/memory.py'
),
'openhands.memory.conversation_memory': os.path.join(
project_root, 'openhands/memory/conversation_memory.py'
),
}
# Check for the specific circular import pattern that was problematic
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(f'Circular imports detected:\n{circular_import_str}')
def _find_circular_imports(
self, module_paths: dict[str, str]
) -> list[tuple[str, str]]:
"""
Find circular imports between modules.
Args:
module_paths: Dictionary mapping module names to file paths
Returns:
List of tuples (module1, module2) where module1 imports module2 and module2 imports module1
"""
# Dictionary to store imports for each module
module_imports = {}
# Extract imports for each module
for module_name, file_path in module_paths.items():
if os.path.exists(file_path):
with open(file_path, 'r') as f:
source_code = f.read()
# Extract import statements
import_lines = [
line.strip()
for line in source_code.split('\n')
if line.strip().startswith(('import ', 'from '))
and not line.strip().startswith('# ')
]
# Parse import statements to get imported modules
imported_modules = []
for line in import_lines:
if line.startswith('import '):
# Handle "import module" or "import module as alias"
parts = line[7:].split(',')
for part in parts:
module_part = part.strip().split(' as ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
elif line.startswith('from '):
# Handle "from module import name" or "from module import name as alias"
module_part = line[5:].split(' import ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
module_imports[module_name] = imported_modules
# Check for circular imports
circular_imports = []
for module1, imports1 in module_imports.items():
for module2 in imports1:
if module2 in module_imports and module1 in module_imports[module2]:
# Found a circular import
circular_imports.append((module1, module2))
return circular_imports
def test_specific_circular_import_pattern(self):
"""
Test for the specific circular import pattern that caused the issue in the stack trace.
The problematic pattern was:
openhands.utils.prompt imports from openhands.agenthub.codeact_agent.tools.bash
openhands.agenthub.codeact_agent.tools.bash imports from openhands.agenthub.codeact_agent.tools.prompt
openhands.agenthub.codeact_agent.tools.prompt imports from openhands.utils.prompt
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Check if the problematic pattern exists
prompt_path = os.path.join(project_root, 'openhands/utils/prompt.py')
bash_path = os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/bash.py'
)
tools_prompt_path = os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/prompt.py'
)
# Check if all files exist
if not all(
os.path.exists(path) for path in [prompt_path, bash_path, tools_prompt_path]
):
self.skipTest('One or more required files do not exist')
# Read the files
with open(prompt_path, 'r') as f:
prompt_code = f.read()
with open(bash_path, 'r') as f:
bash_code = f.read()
with open(tools_prompt_path, 'r') as f:
tools_prompt_code = f.read()
# Check for the problematic imports
prompt_imports_bash = (
re.search(
r'from openhands\.agenthub\.codeact_agent\.tools\.bash import',
prompt_code,
)
is not None
)
bash_imports_tools_prompt = (
re.search(
r'from openhands\.agenthub\.codeact_agent\.tools\.prompt import',
bash_code,
)
is not None
)
tools_prompt_imports_prompt = (
re.search(r'from openhands\.utils\.prompt import', tools_prompt_code)
is not None
)
# If all three imports exist, we have a circular import
if (
prompt_imports_bash
and bash_imports_tools_prompt
and tools_prompt_imports_prompt
):
self.fail(
'Circular import pattern detected:\n'
'openhands.utils.prompt imports from openhands.agenthub.codeact_agent.tools.bash\n'
'openhands.agenthub.codeact_agent.tools.bash imports from openhands.agenthub.codeact_agent.tools.prompt\n'
'openhands.agenthub.codeact_agent.tools.prompt imports from openhands.utils.prompt'
)
def test_detect_circular_imports_in_server_modules(self):
"""
Test for circular imports in the server modules that were involved in the stack trace.
The problematic modules were:
- openhands.server.shared
- openhands.server.conversation_manager.conversation_manager
- openhands.server.session.agent_session
- openhands.server.session
- openhands.server.session.session
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.server.shared': os.path.join(
project_root, 'openhands/server/shared.py'
),
'openhands.server.conversation_manager.conversation_manager': os.path.join(
project_root,
'openhands/server/conversation_manager/conversation_manager.py',
),
'openhands.server.session.agent_session': os.path.join(
project_root, 'openhands/server/session/agent_session.py'
),
'openhands.server.session.__init__': os.path.join(
project_root, 'openhands/server/session/__init__.py'
),
'openhands.server.session.session': os.path.join(
project_root, 'openhands/server/session/session.py'
),
}
# Check for circular imports
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(
f'Circular imports detected in server modules:\n{circular_import_str}'
)
def test_detect_circular_imports_in_mcp_modules(self):
"""
Test for circular imports in the MCP modules that were involved in the stack trace.
The problematic modules were:
- openhands.mcp
- openhands.mcp.utils
- openhands.memory.memory
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.mcp.__init__': os.path.join(
project_root, 'openhands/mcp/__init__.py'
),
'openhands.mcp.utils': os.path.join(project_root, 'openhands/mcp/utils.py'),
'openhands.memory.memory': os.path.join(
project_root, 'openhands/memory/memory.py'
),
}
# Check for circular imports
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(
f'Circular imports detected in MCP modules:\n{circular_import_str}'
)
def test_detect_complex_circular_import_chains(self):
"""
Test for complex circular import chains involving multiple modules.
This test checks for circular dependencies that involve more than two modules,
such as A imports B, B imports C, and C imports A.
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Define the modules involved in the stack trace
modules = [
'openhands.utils.prompt',
'openhands.agenthub.codeact_agent.tools.bash',
'openhands.agenthub.codeact_agent.tools.prompt',
'openhands.memory.memory',
'openhands.memory.conversation_memory',
'openhands.server.shared',
'openhands.server.conversation_manager.conversation_manager',
'openhands.server.session.agent_session',
'openhands.server.session.__init__',
'openhands.server.session.session',
'openhands.mcp.__init__',
'openhands.mcp.utils',
]
# Map module names to file paths
module_paths = {}
for module in modules:
if module.endswith('.__init__'):
# Handle __init__.py files
module_path = module[:-9].replace('.', '/')
file_path = os.path.join(project_root, f'{module_path}/__init__.py')
else:
# Handle regular .py files
module_path = module.replace('.', '/')
file_path = os.path.join(project_root, f'{module_path}.py')
if os.path.exists(file_path):
module_paths[module] = file_path
# Build the import graph
import_graph = {}
for module_name, file_path in module_paths.items():
with open(file_path, 'r') as f:
source_code = f.read()
# Extract import statements
import_lines = [
line.strip()
for line in source_code.split('\n')
if line.strip().startswith(('import ', 'from '))
and not line.strip().startswith('# ')
]
# Parse import statements to get imported modules
imported_modules = []
for line in import_lines:
if line.startswith('import '):
# Handle "import module" or "import module as alias"
parts = line[7:].split(',')
for part in parts:
module_part = part.strip().split(' as ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
elif line.startswith('from '):
# Handle "from module import name" or "from module import name as alias"
module_part = line[5:].split(' import ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
import_graph[module_name] = [
m for m in imported_modules if m in module_paths
]
# Check for circular import chains
circular_chains = self._find_circular_chains(import_graph)
# If there are any circular chains, fail the test
if circular_chains:
circular_chain_str = '\n'.join(
[' -> '.join(chain) for chain in circular_chains]
)
self.fail(f'Complex circular import chains detected:\n{circular_chain_str}')
def _find_circular_chains(
self, import_graph: dict[str, list[str]]
) -> list[list[str]]:
"""
Find circular import chains in the import graph.
Args:
import_graph: Dictionary mapping module names to lists of imported modules
Returns:
List of circular import chains, where each chain is a list of module names
"""
circular_chains = []
def dfs(module: str, path: list[str], visited: set[str]):
"""
Depth-first search to find circular import chains.
Args:
module: Current module being visited
path: Current path in the DFS
visited: Set of modules visited in the current DFS path
"""
if module in visited:
# Found a circular import chain
cycle_start = path.index(module)
circular_chains.append(path[cycle_start:] + [module])
return
visited.add(module)
path.append(module)
for imported_module in import_graph.get(module, []):
dfs(imported_module, path.copy(), visited.copy())
# Start DFS from each module
for module in import_graph:
dfs(module, [], set())
return circular_chains
if __name__ == '__main__':
unittest.main()