Compare commits

..

7 Commits

Author SHA1 Message Date
openhands eb348a5f3d Remove KeyboardInterrupt exit behavior from main chat loop
- Change KeyboardInterrupt handler to continue loop instead of exiting
- Let signal handler manage Ctrl+C behavior completely
- Only exit on explicit /exit command or outer KeyboardInterrupt

This ensures that Ctrl+C during agent processing returns to chat loop
instead of exiting the entire application.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 17:09:16 +00:00
openhands 099dcb787f Fix Ctrl+C behavior to return to chat loop instead of exiting
- Remove os._exit(1) from second Ctrl+C handler
- Reset Ctrl+C counter after force killing process
- Add graceful handling in SimpleProcessRunner for killed processes
- Show user-friendly message that they can continue sending messages

This allows users to stop a running agent and continue with new messages
instead of having to restart the entire CLI application.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 17:07:47 +00:00
openhands b3034a0d75 Fix multiprocessing serialization issues in SimpleProcessRunner
- Pass conversation_id and message_data instead of full objects to subprocess
- Recreate conversation and message objects in the subprocess
- Extract text content from Message objects for serialization
- Store conversation_id as string for subprocess recreation

This fixes the 'cannot pickle _asyncio.Future object' error by avoiding
passing non-serializable objects between processes.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:59:29 +00:00
openhands 459e224d37 Fix Message creation to include required role field
- Add role='user' to Message constructor in agent_chat.py
- This fixes the validation error when processing user messages

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:57:02 +00:00
openhands 97f13b7100 Fix SimpleProcessRunner to use proper SDK imports
- Replace incorrect openhands.core.main imports with openhands.sdk
- Use existing ConversationRunner from runner.py instead of run_controller
- Update SimpleProcessRunner to accept BaseConversation instead of setup function
- Update agent_chat.py to create conversation first, then pass to SimpleProcessRunner
- Fix process_message to use proper Message object with TextContent

This ensures the openhands-cli remains standalone and only uses the SDK library
as intended, without importing from the main OpenHands codebase.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:51:47 +00:00
openhands 6ecaca5b3c Simplify Ctrl+C handling implementation
- Replace complex ProcessSignalHandler with SimpleSignalHandler
  - Direct signal handling in main process instead of queue communication
  - Simple Ctrl+C counting with immediate force kill on second press
  - Reset functionality to clear count when starting new operations

- Replace ProcessBasedConversationRunner with SimpleProcessRunner
  - Minimal multiprocessing - only process_message runs in subprocess
  - Direct method calls for status, settings, and other operations
  - No unnecessary queue communication

- Update agent_chat.py to use simplified components
  - Reset Ctrl+C count when starting new message processing
  - Direct method calls for commands that don't need process isolation
  - Cleaner error handling and resource cleanup

- Update simple_main.py imports

Fixes issues where second Ctrl+C wouldn't register properly due to
complex queue-based communication and race conditions.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:46:43 +00:00
openhands 5351702d3a Implement improved Ctrl+C handling for OpenHands CLI
- First Ctrl+C attempts graceful pause of agent
- Second Ctrl+C (within 3 seconds) kills process immediately
- Added SignalHandler and ProcessSignalHandler classes for signal management
- Implemented ProcessBasedConversationRunner for separate process execution
- Modified pause_listener to remove Ctrl+C handling (now handled by signal handler)
- Updated agent_chat.py to use process-based runner with new signal management
- Updated simple_main.py to install basic signal handler
- Added comprehensive test script and documentation

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:24:23 +00:00
201 changed files with 4379 additions and 6685 deletions
-1
View File
@@ -1 +0,0 @@
This way of running OpenHands is not officially supported. It is maintained by the community.
-3
View File
@@ -7,8 +7,5 @@ git config --global --add safe.directory "$(realpath .)"
# Install `nc`
sudo apt update && sudo apt install netcat -y
# Install `uv` and `uvx`
wget -qO- https://astral.sh/uv/install.sh | sh
# Do common setup tasks
source .openhands/setup.sh
-1
View File
@@ -13,7 +13,6 @@
- [ ] Other (dependency update, docs, typo fixes, etc.)
## Checklist
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
- [ ] I have read and reviewed the code and I understand what the code is doing.
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import os
import re
import sys
def find_version_references(directory: str) -> tuple[set[str], set[str]]:
openhands_versions = set()
runtime_versions = set()
version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
for root, _, files in os.walk(directory):
# Skip .git directory and docs/build directory
if '.git' in root or 'docs/build' in root:
continue
for file in files:
if file.endswith(
('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find all openhands version references
matches = version_pattern_openhands.findall(content)
if matches:
print(f'Found openhands version {matches} in {file_path}')
openhands_versions.update(matches)
# Find all runtime version references
matches = version_pattern_runtime.findall(content)
if matches:
print(f'Found runtime version {matches} in {file_path}')
runtime_versions.update(matches)
except Exception as e:
print(f'Error reading {file_path}: {e}', file=sys.stderr)
return openhands_versions, runtime_versions
def main():
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
print(f'Checking version consistency in {repo_root}')
openhands_versions, runtime_versions = find_version_references(repo_root)
print(f'Found openhands versions: {sorted(openhands_versions)}')
print(f'Found runtime versions: {sorted(runtime_versions)}')
exit_code = 0
if len(openhands_versions) > 1:
print('Error: Multiple openhands versions found:', file=sys.stderr)
print('Found versions:', sorted(openhands_versions), file=sys.stderr)
exit_code = 1
elif len(openhands_versions) == 0:
print('Warning: No openhands version references found', file=sys.stderr)
if len(runtime_versions) > 1:
print('Error: Multiple runtime versions found:', file=sys.stderr)
print('Found versions:', sorted(runtime_versions), file=sys.stderr)
exit_code = 1
elif len(runtime_versions) == 0:
print('Warning: No runtime version references found', file=sys.stderr)
sys.exit(exit_code)
if __name__ == '__main__':
main()
@@ -1,65 +0,0 @@
name: Check Package Versions
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check-package-versions:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check for any 'rev' fields in pyproject.toml
run: |
python - <<'PY'
import sys, tomllib, pathlib
path = pathlib.Path("pyproject.toml")
if not path.exists():
print("❌ ERROR: pyproject.toml not found")
sys.exit(1)
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except Exception as e:
print(f"❌ ERROR: Failed to parse pyproject.toml: {e}")
sys.exit(1)
poetry = data.get("tool", {}).get("poetry", {})
sections = {
"dependencies": poetry.get("dependencies", {}),
}
errors = []
print("🔍 Checking for any dependencies with 'rev' fields...\n")
for section_name, deps in sections.items():
if not isinstance(deps, dict):
continue
for pkg_name, cfg in deps.items():
if isinstance(cfg, dict) and "rev" in cfg:
msg = f" ✖ {pkg_name} in [{section_name}] uses rev='{cfg['rev']}' (NOT ALLOWED)"
print(msg)
errors.append(msg)
else:
print(f" • {pkg_name}: OK")
if errors:
print("\n❌ FAILED: Found dependencies using 'rev' fields:\n" + "\n".join(errors))
print("\nPlease use versioned releases instead, e.g.:")
print(' my-package = "1.0.0"')
sys.exit(1)
print("\n✅ SUCCESS: No 'rev' fields found. All dependencies are using proper versioned releases.")
PY
+6 -6
View File
@@ -86,7 +86,7 @@ jobs:
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
name: Build Image
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
@@ -256,7 +256,7 @@ jobs:
test_runtime_root:
name: RT Unit Tests (Root)
needs: [ghcr_build_runtime, define-matrix]
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-ubuntu-2204
strategy:
fail-fast: false
matrix:
@@ -298,7 +298,7 @@ jobs:
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flakey tests
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
@@ -311,14 +311,14 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
name: RT Unit Tests (openhands)
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: [ghcr_build_runtime, define-matrix]
strategy:
matrix:
@@ -370,7 +370,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
+199
View File
@@ -0,0 +1,199 @@
name: Run Integration Tests
on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual trigger'
required: true
default: ''
schedule:
- cron: '30 22 * * *' # Runs at 10:30pm UTC every day
env:
N_PROCESSES: 10 # Global configuration for number of parallel processes for evaluation
jobs:
run-integration-tests:
if: github.event.label.name == 'integration-test' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: "read"
id-token: "write"
pull-requests: "write"
issues: "write"
strategy:
matrix:
python-version: ["3.12"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Node.js
uses: useblacksmith/setup-node@v5
with:
node-version: '22.x'
- name: Comment on PR if 'integration-test' label is present
if: github.event_name == 'pull_request' && github.event.label.name == 'integration-test'
uses: KeisukeYamashita/create-comment@v1
with:
unique: false
comment: |
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime,evaluation
- name: Configure config.toml for testing with Haiku
env:
LLM_MODEL: "litellm_proxy/claude-3-5-haiku-20241022"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 10
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Build environment
run: make build
- name: Run integration test evaluation for Haiku
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'haiku_run'
# get integration tests report
REPORT_FILE_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/*haiku*_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE_HAIKU"
echo "INTEGRATION_TEST_REPORT_HAIKU<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_HAIKU >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Wait a little bit
run: sleep 10
- name: Configure config.toml for testing with DeepSeek
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 10
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DeepSeek
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'deepseek_run'
# get integration tests report
REPORT_FILE_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/deepseek*_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
- name: Wait a little bit (again)
run: sleep 5
- name: Configure config.toml for testing VisualBrowsingAgent (DeepSeek)
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 15
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for VisualBrowsingAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
# Find and export the visual browsing agent test results
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE_VISUALBROWSING_DEEPSEEK: $REPORT_FILE_VISUALBROWSING_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_VISUALBROWSING_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create archive of evaluation outputs
run: |
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
- name: Upload evaluation results as artifact
uses: actions/upload-artifact@v4
id: upload_results_artifact
with:
name: integration-test-outputs-${{ github.run_id }}-${{ github.run_attempt }}
path: integration_tests_*.tar.gz
- name: Get artifact URLs
run: |
echo "ARTIFACT_URL=${{ steps.upload_results_artifact.outputs.artifact-url }}" >> $GITHUB_ENV
- name: Set timestamp and trigger reason
run: |
echo "TIMESTAMP=$(date +'%Y-%m-%d-%H-%M')" >> $GITHUB_ENV
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "TRIGGER_REASON=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "TRIGGER_REASON=manual-${{ github.event.inputs.reason }}" >> $GITHUB_ENV
else
echo "TRIGGER_REASON=nightly-scheduled" >> $GITHUB_ENV
fi
- name: Comment with results and artifact link
id: create_comment
uses: KeisukeYamashita/create-comment@v1
with:
# if triggered by PR, use PR number, otherwise use 9745 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 9745 }}
unique: false
comment: |
Trigger by: ${{ github.event_name == 'pull_request' && format('Pull Request (integration-test label on PR #{0})', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && format('Manual Trigger: {0}', github.event.inputs.reason)) || 'Nightly Scheduled Run' }}
Commit: ${{ github.sha }}
**Integration Tests Report (Haiku)**
Haiku LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_HAIKU }}
---
**Integration Tests Report (DeepSeek)**
DeepSeek LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_DEEPSEEK }}
---
**Integration Tests Report VisualBrowsing (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}
---
Download testing outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})
+13
View File
@@ -90,3 +90,16 @@ jobs:
- name: Run pre-commit hooks
working-directory: ./openhands-cli
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
# Check version consistency across documentation
check-version-consistency:
name: Check version consistency
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Set up python
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
- name: Run version consistency check
run: .github/scripts/check_version_consistency.py
+34 -6
View File
@@ -48,10 +48,7 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install Python dependencies using Poetry
run: |
poetry install --with dev,test,runtime
poetry run pip install pytest-xdist
poetry run pip install pytest-rerunfailures
run: poetry install --with dev,test,runtime
- name: Build Environment
run: make build
- name: Run Unit Tests
@@ -59,7 +56,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
@@ -70,7 +67,37 @@ jobs:
.coverage.${{ matrix.python_version }}
.coverage.runtime.${{ matrix.python_version }}
include-hidden-files: true
# Run specific Windows python tests
test-on-windows:
name: Python Tests on Windows
runs-on: windows-latest
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- name: Install pipx
run: pip install pipx
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/runtime/utils/test_windows_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
DEBUG: "1"
- name: Run Windows runtime tests with LocalRuntime
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
TEST_RUNTIME: local
DEBUG: "1"
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -146,6 +173,7 @@ jobs:
path: ".coverage.openhands-cli.${{ matrix.python-version }}"
include-hidden-files: true
coverage-comment:
name: Coverage Comment
if: github.event_name == 'pull_request'
-3
View File
@@ -185,9 +185,6 @@ cython_debug/
.repomix
repomix-output.txt
# Emacs backup
*~
# evaluation
evaluation/evaluation_outputs
evaluation/outputs
-1
View File
@@ -1 +0,0 @@
docs.all-hands.dev
+1 -1
View File
@@ -58,7 +58,7 @@ by implementing the [interface specified here](https://github.com/OpenHands/Open
#### Testing
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing test suites.
At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
At the moment, we have two kinds of tests: [`unit`](./tests/unit) and [`integration`](./evaluation/integration_tests). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
## Sending Pull Requests to OpenHands
+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/openhands/runtime:0.62-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.60-nikolaik`
## Develop inside Docker container
+4 -4
View File
@@ -51,7 +51,7 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for
## ☁️ OpenHands Cloud
The easiest way to get started with OpenHands is on [OpenHands Cloud](https://app.all-hands.dev),
which comes with $10 in free credits for new users.
which comes with $20 in free credits for new users.
## 💻 Running OpenHands Locally
@@ -82,17 +82,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.openhands.dev/openhands/runtime:0.62-nikolaik
docker pull docker.openhands.dev/openhands/runtime:0.60-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.62-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.60-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.openhands.dev/openhands/openhands:0.62
docker.openhands.dev/openhands/openhands:0.60
```
</details>
+1 -1
View File
@@ -1,7 +1,7 @@
# Develop in Docker
> [!WARNING]
> This way of running OpenHands is not officially supported. It is maintained by the community and may not work.
> This is not officially supported and may not work.
Install [Docker](https://docs.docker.com/engine/install/) on your host machine and run:
+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/openhands/runtime:0.62-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.60-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.openhands.dev/openhands/runtime:0.62-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.60-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:
@@ -1,71 +0,0 @@
"""add status and updated_at to callback
Revision ID: 080
Revises: 079
Create Date: 2025-11-05 00:00:00.000000
"""
from enum import Enum
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '080'
down_revision: Union[str, None] = '079'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
class EventCallbackStatus(Enum):
ACTIVE = 'ACTIVE'
DISABLED = 'DISABLED'
COMPLETED = 'COMPLETED'
ERROR = 'ERROR'
def upgrade() -> None:
"""Upgrade schema."""
status = sa.Enum(EventCallbackStatus, name='eventcallbackstatus')
status.create(op.get_bind(), checkfirst=True)
op.add_column(
'event_callback',
sa.Column('status', status, nullable=False, server_default='ACTIVE'),
)
op.add_column(
'event_callback',
sa.Column(
'updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()
),
)
op.drop_index('ix_event_callback_result_event_id')
op.drop_column('event_callback_result', 'event_id')
op.add_column(
'event_callback_result', sa.Column('event_id', sa.String, nullable=True)
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('event_callback', 'status')
op.drop_column('event_callback', 'updated_at')
op.drop_index('ix_event_callback_result_event_id')
op.drop_column('event_callback_result', 'event_id')
op.add_column(
'event_callback_result', sa.Column('event_id', sa.UUID, nullable=True)
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
op.execute('DROP TYPE eventcallbackstatus')
+48 -298
View File
@@ -201,20 +201,19 @@ files = [
[[package]]
name = "anthropic"
version = "0.72.0"
version = "0.65.0"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
{file = "anthropic-0.65.0-py3-none-any.whl", hash = "sha256:ba9d9f82678046c74ddf5698ca06d9f5b0f599cfac922ab0d5921638eb448d98"},
{file = "anthropic-0.65.0.tar.gz", hash = "sha256:6b6b6942574e54342050dfd42b8d856a8366b171daec147df3b80be4722733b9"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
docstring-parser = ">=0.15,<1"
google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""}
httpx = ">=0.25.0,<1"
jiter = ">=0.4.0,<1"
@@ -223,7 +222,7 @@ sniffio = "*"
typing-extensions = ">=4.10,<5"
[package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
vertex = ["google-auth[requests] (>=2,<3)"]
@@ -682,34 +681,31 @@ crt = ["awscrt (==0.27.6)"]
[[package]]
name = "browser-use"
version = "0.9.5"
version = "0.7.10"
description = "Make websites accessible for AI agents"
optional = false
python-versions = "<4.0,>=3.11"
groups = ["main"]
files = [
{file = "browser_use-0.9.5-py3-none-any.whl", hash = "sha256:4a2e92847204d1ded269026a99cb0cc0e60e38bd2751fa3f58aedd78f00b4e67"},
{file = "browser_use-0.9.5.tar.gz", hash = "sha256:f8285fe253b149d01769a7084883b4cf4db351e2f38e26302c157bcbf14a703f"},
{file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"},
{file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"},
]
[package.dependencies]
aiohttp = "3.12.15"
anthropic = ">=0.68.1,<1.0.0"
anthropic = ">=0.58.2,<1.0.0"
anyio = ">=4.9.0"
authlib = ">=1.6.0"
bubus = ">=1.5.6"
cdp-use = ">=1.4.0"
click = ">=8.1.8"
cloudpickle = ">=3.1.1"
google-api-core = ">=2.25.0"
google-api-python-client = ">=2.174.0"
google-auth = ">=2.40.3"
google-auth-oauthlib = ">=1.2.2"
google-genai = ">=1.29.0,<2.0.0"
groq = ">=0.30.0"
html2text = ">=2025.4.15"
httpx = ">=0.28.1"
inquirerpy = ">=0.3.4"
markdownify = ">=1.2.0"
mcp = ">=1.10.1"
ollama = ">=0.5.1"
openai = ">=1.99.2,<2.0.0"
@@ -724,20 +720,16 @@ pypdf = ">=5.7.0"
python-dotenv = ">=1.0.1"
reportlab = ">=4.0.0"
requests = ">=2.32.3"
rich = ">=14.0.0"
screeninfo = {version = ">=0.8.1", markers = "platform_system != \"darwin\""}
typing-extensions = ">=4.12.2"
uuid7 = ">=0.1.0"
[package.extras]
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "oci (>=2.126.4)", "textual (>=3.2.0)"]
all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
aws = ["boto3 (>=1.38.45)"]
cli = ["textual (>=3.2.0)"]
cli-oci = ["oci (>=2.126.4)", "textual (>=3.2.0)"]
code = ["matplotlib (>=3.9.0)", "numpy (>=2.3.2)", "pandas (>=2.2.0)", "tabulate (>=0.9.0)"]
eval = ["anyio (>=4.9.0)", "datamodel-code-generator (>=0.26.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
oci = ["oci (>=2.126.4)"]
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"]
examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
[[package]]
@@ -3533,25 +3525,6 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "inquirerpy"
version = "0.3.4"
description = "Python port of Inquirer.js (A collection of common interactive command-line user interfaces)"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4"},
{file = "InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e"},
]
[package.dependencies]
pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
version = "0.7.0"
@@ -4607,62 +4580,6 @@ files = [
{file = "llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4"},
]
[[package]]
name = "lmnr"
version = "0.7.20"
description = "Python SDK for Laminar"
optional = false
python-versions = "<4,>=3.10"
groups = ["main"]
files = [
{file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
{file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
]
[package.dependencies]
grpcio = ">=1"
httpx = ">=0.24.0"
opentelemetry-api = ">=1.33.0"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.0"
opentelemetry-exporter-otlp-proto-http = ">=1.33.0"
opentelemetry-instrumentation = ">=0.54b0"
opentelemetry-instrumentation-threading = ">=0.57b0"
opentelemetry-sdk = ">=1.33.0"
opentelemetry-semantic-conventions = ">=0.54b0"
opentelemetry-semantic-conventions-ai = ">=0.4.13"
orjson = ">=3.0.0"
packaging = ">=22.0"
pydantic = ">=2.0.3,<3.0.0"
python-dotenv = ">=1.0"
tenacity = ">=8.0"
tqdm = ">=4.0"
[package.extras]
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
milvus = ["opentelemetry-instrumentation-milvus (>=0.47.1)"]
mistralai = ["opentelemetry-instrumentation-mistralai (>=0.47.1)"]
ollama = ["opentelemetry-instrumentation-ollama (>=0.47.1)"]
pinecone = ["opentelemetry-instrumentation-pinecone (>=0.47.1)"]
qdrant = ["opentelemetry-instrumentation-qdrant (>=0.47.1)"]
replicate = ["opentelemetry-instrumentation-replicate (>=0.47.1)"]
sagemaker = ["opentelemetry-instrumentation-sagemaker (>=0.47.1)"]
together = ["opentelemetry-instrumentation-together (>=0.47.1)"]
transformers = ["opentelemetry-instrumentation-transformers (>=0.47.1)"]
vertexai = ["opentelemetry-instrumentation-vertexai (>=0.47.1)"]
watsonx = ["opentelemetry-instrumentation-watsonx (>=0.47.1)"]
weaviate = ["opentelemetry-instrumentation-weaviate (>=0.47.1)"]
[[package]]
name = "lxml"
version = "6.0.1"
@@ -5820,15 +5737,13 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.1.0"
version = "1.0.0a4"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
{file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
]
files = []
develop = false
[package.dependencies]
aiosqlite = ">=0.19"
@@ -5841,9 +5756,16 @@ uvicorn = ">=0.31.1"
websockets = ">=12"
wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-ai"
version = "0.0.0-post.5525+0b6631523"
version = "0.0.0-post.5456+15c207c40"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5879,14 +5801,13 @@ jupyter_kernel_gateway = "*"
kubernetes = "^33.1.0"
libtmux = ">=0.46.2"
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
lmnr = "^0.7.20"
memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = "1.1.0"
openhands-sdk = "1.1.0"
openhands-tools = "1.1.0"
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-tools"}
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5942,21 +5863,18 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.1.0"
version = "1.0.0a4"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
{file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
]
files = []
develop = false
[package.dependencies]
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
litellm = ">=1.77.7.dev9"
lmnr = ">=0.7.20"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
python-json-logger = ">=3.3.0"
@@ -5966,28 +5884,40 @@ websockets = ">=12"
[package.extras]
boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.1.0"
version = "1.0.0a4"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
{file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
]
files = []
develop = false
[package.dependencies]
bashlex = ">=0.18"
binaryornot = ">=0.4.4"
browser-use = ">=0.8.0"
browser-use = ">=0.7.7"
cachetools = "*"
func-timeout = ">=4.3.5"
libtmux = ">=0.46.2"
openhands-sdk = "*"
pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-tools"
[[package]]
name = "openpyxl"
version = "3.1.5"
@@ -6058,62 +5988,6 @@ opentelemetry-proto = "1.36.0"
opentelemetry-sdk = ">=1.36.0,<1.37.0"
typing-extensions = ">=4.6.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.36.0"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0-py3-none-any.whl", hash = "sha256:3d769f68e2267e7abe4527f70deb6f598f40be3ea34c6adc35789bea94a32902"},
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0.tar.gz", hash = "sha256:dd3637f72f774b9fc9608ab1ac479f8b44d09b6fb5b2f3df68a24ad1da7d356e"},
]
[package.dependencies]
googleapis-common-protos = ">=1.52,<2.0"
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.36.0"
opentelemetry-proto = "1.36.0"
opentelemetry-sdk = ">=1.36.0,<1.37.0"
requests = ">=2.7,<3.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-instrumentation"
version = "0.57b0"
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation-0.57b0-py3-none-any.whl", hash = "sha256:9109280f44882e07cec2850db28210b90600ae9110b42824d196de357cbddf7e"},
{file = "opentelemetry_instrumentation-0.57b0.tar.gz", hash = "sha256:f2a30135ba77cdea2b0e1df272f4163c154e978f57214795d72f40befd4fcf05"},
]
[package.dependencies]
opentelemetry-api = ">=1.4,<2.0"
opentelemetry-semantic-conventions = "0.57b0"
packaging = ">=18.0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-instrumentation-threading"
version = "0.57b0"
description = "Thread context propagation support for OpenTelemetry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_threading-0.57b0-py3-none-any.whl", hash = "sha256:adfd64857c8c78d6111cf80552311e1713bad64272dd81abdd61f07b892a161b"},
{file = "opentelemetry_instrumentation_threading-0.57b0.tar.gz", hash = "sha256:06fa4c98d6bfe4670e7532497670ac202db42afa647ff770aedce0e422421c6e"},
]
[package.dependencies]
opentelemetry-api = ">=1.12,<2.0"
opentelemetry-instrumentation = "0.57b0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-proto"
version = "1.36.0"
@@ -6162,115 +6036,6 @@ files = [
opentelemetry-api = "1.36.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-semantic-conventions-ai"
version = "0.4.13"
description = "OpenTelemetry Semantic Conventions Extension for Large Language Models"
optional = false
python-versions = "<4,>=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5"},
{file = "opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036"},
]
[[package]]
name = "orjson"
version = "3.11.4"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"},
{file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"},
{file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"},
{file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"},
{file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"},
{file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"},
{file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"},
{file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"},
{file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"},
{file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"},
{file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"},
{file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"},
{file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"},
{file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"},
{file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"},
{file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"},
{file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"},
{file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"},
{file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"},
{file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"},
{file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"},
{file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"},
{file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"},
{file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"},
{file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"},
{file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"},
{file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"},
]
[[package]]
name = "packaging"
version = "25.0"
@@ -6487,21 +6252,6 @@ files = [
[package.dependencies]
ptyprocess = ">=0.5"
[[package]]
name = "pfzy"
version = "0.3.4"
description = "Python port of the fzy fuzzy string matching algorithm"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96"},
{file = "pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1"},
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
version = "1.31.5"
+1 -1
View File
@@ -50,7 +50,7 @@ SUBSCRIPTION_PRICE_DATA = {
},
}
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '20'))
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', None)
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')
@@ -35,7 +35,6 @@ class SaasConversationStore(ConversationStore):
session.query(StoredConversationMetadata)
.filter(StoredConversationMetadata.user_id == self.user_id)
.filter(StoredConversationMetadata.conversation_id == conversation_id)
.filter(StoredConversationMetadata.conversation_version == 'V0')
)
def _to_external_model(self, conversation_metadata: StoredConversationMetadata):
@@ -124,7 +123,6 @@ class SaasConversationStore(ConversationStore):
conversations = (
session.query(StoredConversationMetadata)
.filter(StoredConversationMetadata.user_id == self.user_id)
.filter(StoredConversationMetadata.conversation_version == 'V0')
.order_by(StoredConversationMetadata.created_at.desc())
.offset(offset)
.limit(limit + 1)
@@ -243,7 +243,7 @@ async def test_update_settings_with_litellm_default(
# Check that the URL and most of the JSON payload match what we expect
assert call_args['json']['user_email'] == 'testy@tester.com'
assert call_args['json']['models'] == []
assert call_args['json']['max_budget'] == 10.0
assert call_args['json']['max_budget'] == 20.0
assert call_args['json']['user_id'] == 'user-id'
assert call_args['json']['teams'] == ['test_team']
assert call_args['json']['auto_create_key'] is True
@@ -15,7 +15,7 @@ python evaluation/benchmarks/multi_swe_bench/scripts/data/data_change.py
## Docker image download
Please download the multi-swe-bench docker images from [here](https://github.com/multi-swe-bench/multi-swe-bench?tab=readme-ov-file#run-evaluation).
Please download the multi-swe-bench dokcer images from [here](https://github.com/multi-swe-bench/multi-swe-bench?tab=readme-ov-file#run-evaluation).
## Generate patch
@@ -47,7 +47,7 @@ For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=t
The results will be generated in evaluation/evaluation_outputs/outputs/XXX/CodeActAgent/YYY/output.jsonl, you can refer to the [example](examples/output.jsonl).
## Running evaluation
## Runing evaluation
First, install [multi-swe-bench](https://github.com/multi-swe-bench/multi-swe-bench).
+69
View File
@@ -0,0 +1,69 @@
# Integration tests
This directory implements integration tests that [was running in CI](https://github.com/OpenHands/OpenHands/tree/23d3becf1d6f5d07e592f7345750c314a826b4e9/tests/integration).
[PR 3985](https://github.com/OpenHands/OpenHands/pull/3985) introduce LLM-based editing, which requires access to LLM to perform edit. Hence, we remove integration tests from CI and intend to run them as nightly evaluation to ensure the quality of OpenHands softwares.
## To add new tests
Each test is a file named like `tXX_testname.py` where `XX` is a number.
Make sure to name the file for each test to start with `t` and ends with `.py`.
Each test should be structured as a subclass of [`BaseIntegrationTest`](./tests/base.py), where you need to implement `initialize_runtime` that setup the runtime enviornment before test, and `verify_result` that takes in a `Runtime` and history of `Event` and return a `TestResult`. See [t01_fix_simple_typo.py](./tests/t01_fix_simple_typo.py) and [t05_simple_browsing.py](./tests/t05_simple_browsing.py) for two representative examples.
```python
class TestResult(BaseModel):
success: bool
reason: str | None = None
class BaseIntegrationTest(ABC):
"""Base class for integration tests."""
INSTRUCTION: str
@classmethod
@abstractmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
"""Initialize the runtime for the test to run."""
pass
@classmethod
@abstractmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
"""Verify the result of the test.
This method will be called after the agent performs the task on the runtime.
"""
pass
```
## Setup Environment and LLM Configuration
Please follow instruction [here](../README.md#setup) to setup your local
development environment and LLM.
## Start the evaluation
```bash
./evaluation/integration_tests/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [eval-num-workers] [eval_ids]
```
- `model_config`, e.g. `eval_gpt4_1106_preview`, is the config group name for
your LLM settings, as defined in your `config.toml`.
- `git-version`, e.g. `HEAD`, is the git commit hash of the OpenHands version
you would like to evaluate. It could also be a release tag like `0.9.0`.
- `agent`, e.g. `CodeActAgent`, is the name of the agent for benchmarks,
defaulting to `CodeActAgent`.
- `eval_limit`, e.g. `10`, limits the evaluation to the first `eval_limit`
instances. By default, the script evaluates the entire Exercism test set
(133 issues). Note: in order to use `eval_limit`, you must also set `agent`.
- `eval-num-workers`: the number of workers to use for evaluation. Default: `1`.
- `eval_ids`, e.g. `"1,3,10"`, limits the evaluation to instances with the
given IDs (comma separated).
Example:
```bash
./evaluation/integration_tests/scripts/run_infer.sh llm.claude-35-sonnet-eval HEAD CodeActAgent
```
+251
View File
@@ -0,0 +1,251 @@
import asyncio
import importlib.util
import os
import pandas as pd
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
update_llm_config_for_completions_logging,
)
from evaluation.utils.shared import (
codeact_user_response as fake_user_response,
)
from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
OpenHandsConfig,
get_evaluation_parser,
get_llm_config_arg,
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_async_from_sync
FAKE_RESPONSES = {
'CodeActAgent': fake_user_response,
'VisualBrowsingAgent': fake_user_response,
}
def get_config(
metadata: EvalMetadata,
instance_id: str,
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.platform = 'linux/amd64'
config = get_openhands_config_for_eval(
metadata=metadata,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
)
config.debug = True
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config, metadata.eval_output_dir, instance_id
)
)
agent_config = AgentConfig(
enable_jupyter=True,
enable_browsing=True,
enable_llm_editor=False,
)
config.set_agent_config(agent_config)
return config
def process_instance(
instance: pd.Series,
metadata: EvalMetadata,
reset_logger: bool = True,
) -> EvalOutput:
config = get_config(metadata, instance.instance_id)
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
reset_logger_for_multiprocessing(logger, str(instance.instance_id), log_dir)
else:
logger.info(
f'\nStarting evaluation for instance {str(instance.instance_id)}.\n'
)
# =============================================
# import test instance
# =============================================
instance_id = instance.instance_id
spec = importlib.util.spec_from_file_location(instance_id, instance.file_path)
test_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(test_module)
assert hasattr(test_module, 'Test'), (
f'Test module {instance_id} does not have a Test class'
)
test_class: type[BaseIntegrationTest] = test_module.Test
assert issubclass(test_class, BaseIntegrationTest), (
f'Test class {instance_id} does not inherit from BaseIntegrationTest'
)
instruction = test_class.INSTRUCTION
# =============================================
# create sandbox and run the agent
# =============================================
runtime: Runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
try:
test_class.initialize_runtime(runtime)
# Here's how you can run the agent (similar to the `main` function) and get the final task state
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=MessageAction(content=instruction),
runtime=runtime,
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
)
)
if state is None:
raise ValueError('State should not be None.')
# # =============================================
# # result evaluation
# # =============================================
histories = state.history
# some basic check
logger.info(f'Total events in history: {len(histories)}')
assert len(histories) > 0, 'History should not be empty'
test_result: TestResult = test_class.verify_result(runtime, histories)
metrics = get_metrics(state)
finally:
runtime.close()
# Save the output
output = EvalOutput(
instance_id=str(instance.instance_id),
instance=instance.to_dict(),
instruction=instruction,
metadata=metadata,
history=[event_to_dict(event) for event in histories],
metrics=metrics,
error=state.last_error if state and state.last_error else None,
test_result=test_result.model_dump(),
)
return output
def load_integration_tests() -> pd.DataFrame:
"""Load tests from python files under ./tests"""
cur_dir = os.path.dirname(os.path.abspath(__file__))
test_dir = os.path.join(cur_dir, 'tests')
test_files = [
os.path.join(test_dir, f)
for f in os.listdir(test_dir)
if f.startswith('t') and f.endswith('.py')
]
df = pd.DataFrame(test_files, columns=['file_path'])
df['instance_id'] = df['file_path'].apply(
lambda x: os.path.basename(x).rstrip('.py')
)
return df
if __name__ == '__main__':
parser = get_evaluation_parser()
args, _ = parser.parse_known_args()
integration_tests = load_integration_tests()
llm_config = None
if args.llm_config:
llm_config = get_llm_config_arg(args.llm_config)
if llm_config is None:
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
metadata = make_metadata(
llm_config,
'integration_tests',
args.agent_cls,
args.max_iterations,
args.eval_note,
args.eval_output_dir,
)
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
# Parse dataset IDs if provided
eval_ids = None
if args.eval_ids:
eval_ids = str(args.eval_ids).split(',')
logger.info(f'\nUsing specific dataset IDs: {eval_ids}\n')
instances = prepare_dataset(
integration_tests,
output_file,
args.eval_n_limit,
eval_ids=eval_ids,
)
run_evaluation(
instances,
metadata,
output_file,
args.eval_num_workers,
process_instance,
)
df = pd.read_json(output_file, lines=True, orient='records')
# record success and reason
df['success'] = df['test_result'].apply(lambda x: x['success'])
df['reason'] = df['test_result'].apply(lambda x: x['reason'])
logger.info('-' * 100)
logger.info(
f'Success rate: {df["success"].mean():.2%} ({df["success"].sum()}/{len(df)})'
)
logger.info(
'\nEvaluation Results:'
+ '\n'
+ df[['instance_id', 'success', 'reason']].to_string(index=False)
)
logger.info('-' * 100)
# record cost for each instance, with 3 decimal places
# we sum up all the "costs" from the metrics array
df['cost'] = df['metrics'].apply(
lambda m: round(sum(c['cost'] for c in m['costs']), 3)
if m and 'costs' in m
else 0.0
)
# capture the top-level error if present, per instance
df['error_message'] = df.get('error', None)
logger.info(f'Total cost: USD {df["cost"].sum():.2f}')
report_file = os.path.join(metadata.eval_output_dir, 'report.md')
with open(report_file, 'w') as f:
f.write(
f'Success rate: {df["success"].mean():.2%}'
f' ({df["success"].sum()}/{len(df)})\n'
)
f.write(f'\nTotal cost: USD {df["cost"].sum():.2f}\n')
f.write(
df[
['instance_id', 'success', 'reason', 'cost', 'error_message']
].to_markdown(index=False)
)
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
MODEL_CONFIG=$1
COMMIT_HASH=$2
AGENT=$3
EVAL_LIMIT=$4
MAX_ITERATIONS=$5
NUM_WORKERS=$6
EVAL_IDS=$7
if [ -z "$NUM_WORKERS" ]; then
NUM_WORKERS=1
echo "Number of workers not specified, use default $NUM_WORKERS"
fi
checkout_eval_branch
if [ -z "$AGENT" ]; then
echo "Agent not specified, use default CodeActAgent"
AGENT="CodeActAgent"
fi
get_openhands_version
echo "AGENT: $AGENT"
echo "OPENHANDS_VERSION: $OPENHANDS_VERSION"
echo "MODEL_CONFIG: $MODEL_CONFIG"
EVAL_NOTE=$OPENHANDS_VERSION
# Default to NOT use unit tests.
if [ -z "$USE_UNIT_TESTS" ]; then
export USE_UNIT_TESTS=false
fi
echo "USE_UNIT_TESTS: $USE_UNIT_TESTS"
# If use unit tests, set EVAL_NOTE to the commit hash
if [ "$USE_UNIT_TESTS" = true ]; then
EVAL_NOTE=$EVAL_NOTE-w-test
fi
# export PYTHONPATH=evaluation/integration_tests:\$PYTHONPATH
COMMAND="poetry run python evaluation/integration_tests/run_infer.py \
--agent-cls $AGENT \
--llm-config $MODEL_CONFIG \
--max-iterations ${MAX_ITERATIONS:-10} \
--eval-num-workers $NUM_WORKERS \
--eval-note $EVAL_NOTE"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
if [ -n "$EVAL_IDS" ]; then
echo "EVAL_IDS: $EVAL_IDS"
COMMAND="$COMMAND --eval-ids $EVAL_IDS"
fi
# Run the command
eval $COMMAND
@@ -0,0 +1,32 @@
from abc import ABC, abstractmethod
from pydantic import BaseModel
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class TestResult(BaseModel):
success: bool
reason: str | None = None
class BaseIntegrationTest(ABC):
"""Base class for integration tests."""
INSTRUCTION: str
@classmethod
@abstractmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
"""Initialize the runtime for the test to run."""
pass
@classmethod
@abstractmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
"""Verify the result of the test.
This method will be called after the agent performs the task on the runtime.
"""
pass
@@ -0,0 +1,39 @@
import os
import tempfile
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from openhands.events.action import CmdRunAction
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = 'Fix typos in bad.txt.'
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
# create a file with a typo in /workspace/bad.txt
with tempfile.TemporaryDirectory() as temp_dir:
temp_file_path = os.path.join(temp_dir, 'bad.txt')
with open(temp_file_path, 'w') as f:
f.write('This is a stupid typoo.\nReally?\nNo mor typos!\nEnjoy!')
# Copy the file to the desired location
runtime.copy_to(temp_file_path, '/workspace')
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
# check if the file /workspace/bad.txt has been fixed
action = CmdRunAction(command='cat /workspace/bad.txt')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False, reason=f'Failed to run command: {obs.content}'
)
# check if the file /workspace/bad.txt has been fixed
if (
obs.content.strip().replace('\r\n', '\n')
== 'This is a stupid typo.\nReally?\nNo more typos!\nEnjoy!'
):
return TestResult(success=True)
return TestResult(success=False, reason=f'File not fixed: {obs.content}')
@@ -0,0 +1,40 @@
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import assert_and_raise
from openhands.events.action import CmdRunAction
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = "Write a shell script '/workspace/hello.sh' that prints 'hello'."
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
action = CmdRunAction(command='mkdir -p /workspace')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
# check if the file /workspace/hello.sh exists
action = CmdRunAction(command='cat /workspace/hello.sh')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/hello.sh: {obs.content}.',
)
# execute the script
action = CmdRunAction(command='bash /workspace/hello.sh')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to execute /workspace/hello.sh: {obs.content}.',
)
if obs.content.strip() != 'hello':
return TestResult(
success=False, reason=f'Script did not print "hello": {obs.content}.'
)
return TestResult(success=True)
@@ -0,0 +1,43 @@
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import assert_and_raise
from openhands.events.action import CmdRunAction
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = "Use Jupyter IPython to write a text file containing 'hello world' to '/workspace/test.txt'."
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
action = CmdRunAction(command='mkdir -p /workspace')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
# check if the file /workspace/hello.sh exists
action = CmdRunAction(command='cat /workspace/test.txt')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/test.txt: {obs.content}.',
)
# execute the script
action = CmdRunAction(command='cat /workspace/test.txt')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/test.txt: {obs.content}.',
)
if 'hello world' not in obs.content.strip():
return TestResult(
success=False,
reason=f'File did not contain "hello world": {obs.content}.',
)
return TestResult(success=True)
@@ -0,0 +1,57 @@
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import assert_and_raise
from openhands.events.action import CmdRunAction
from openhands.events.event import Event
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = 'Write a git commit message for the current staging area and commit the changes.'
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
action = CmdRunAction(command='mkdir -p /workspace')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# git init
action = CmdRunAction(command='git init')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# create file
action = CmdRunAction(command='echo \'print("hello world")\' > hello.py')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# git add
cmd_str = 'git add hello.py'
action = CmdRunAction(command=cmd_str)
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
# check if the file /workspace/hello.py exists
action = CmdRunAction(command='cat /workspace/hello.py')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/hello.py: {obs.content}.',
)
# check if the staging area is empty
action = CmdRunAction(command='git status')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False, reason=f'Failed to git status: {obs.content}.'
)
if 'nothing to commit, working tree clean' in obs.content.strip():
return TestResult(success=True)
return TestResult(
success=False,
reason=f'Failed to check for "nothing to commit, working tree clean": {obs.content}.',
)
@@ -0,0 +1,145 @@
import os
import tempfile
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from evaluation.utils.shared import assert_and_raise
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
from openhands.events.event import Event
from openhands.events.observation import AgentDelegateObservation
from openhands.runtime.base import Runtime
HTML_FILE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Ultimate Answer</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(to right, #1e3c72, #2a5298);
color: #fff;
font-family: 'Arial', sans-serif;
text-align: center;
}
.container {
text-align: center;
padding: 20px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}
h1 {
font-size: 36px;
margin-bottom: 20px;
}
p {
font-size: 18px;
margin-bottom: 30px;
}
#showButton {
padding: 10px 20px;
font-size: 16px;
color: #1e3c72;
background: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.3s ease;
}
#showButton:hover {
background: #f0f0f0;
}
#result {
margin-top: 20px;
font-size: 24px;
}
</style>
</head>
<body>
<div class="container">
<h1>The Ultimate Answer</h1>
<p>Click the button to reveal the answer to life, the universe, and everything.</p>
<button id="showButton">Click me</button>
<div id="result"></div>
</div>
<script>
document.getElementById('showButton').addEventListener('click', function() {
document.getElementById('result').innerText = 'The answer is OpenHands is all you need!';
});
</script>
</body>
</html>
"""
class Test(BaseIntegrationTest):
INSTRUCTION = 'Browse localhost:8000, and tell me the ultimate answer to life.'
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
action = CmdRunAction(command='mkdir -p /workspace')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
action = CmdRunAction(command='mkdir -p /tmp/server')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# create a file with a typo in /workspace/bad.txt
with tempfile.TemporaryDirectory() as temp_dir:
temp_file_path = os.path.join(temp_dir, 'index.html')
with open(temp_file_path, 'w') as f:
f.write(HTML_FILE)
# Copy the file to the desired location
runtime.copy_to(temp_file_path, '/tmp/server')
# create README.md
action = CmdRunAction(
command='cd /tmp/server && nohup python3 -m http.server 8000 &'
)
obs = runtime.run_action(action)
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
from openhands.core.logger import openhands_logger as logger
# check if the "The answer is OpenHands is all you need!" is in any message
message_actions = [
event
for event in histories
if isinstance(
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
)
]
logger.debug(f'Total message-like events: {len(message_actions)}')
for event in message_actions:
try:
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
elif isinstance(event, MessageAction):
content = event.content
else:
logger.warning(f'Unexpected event type: {type(event)}')
continue
if 'OpenHands is all you need!' in content:
return TestResult(success=True)
except Exception as e:
logger.error(f'Error processing event: {e}')
logger.debug(
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
)
return TestResult(
success=False,
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
)
@@ -0,0 +1,58 @@
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from openhands.events.action import AgentFinishAction, MessageAction
from openhands.events.event import Event
from openhands.events.observation import AgentDelegateObservation
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = 'Look at https://github.com/OpenHands/OpenHands/pull/8, and tell me what is happening there and what did @asadm suggest.'
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
pass
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
from openhands.core.logger import openhands_logger as logger
# check if the license information is in any message
message_actions = [
event
for event in histories
if isinstance(
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
)
]
logger.info(f'Total message-like events: {len(message_actions)}')
for event in message_actions:
try:
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
if event.thought:
content += f'\n\n{event.thought}'
elif isinstance(event, MessageAction):
content = event.content
else:
logger.warning(f'Unexpected event type: {type(event)}')
continue
if (
'non-commercial' in content
or 'MIT' in content
or 'Apache 2.0' in content
):
return TestResult(success=True)
except Exception as e:
logger.error(f'Error processing event: {e}')
logger.debug(
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
)
return TestResult(
success=False,
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
)
@@ -0,0 +1,73 @@
import hashlib
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
from openhands.events.action import (
AgentFinishAction,
FileWriteAction,
MessageAction,
)
from openhands.events.event import Event
from openhands.events.observation import AgentDelegateObservation
from openhands.runtime.base import Runtime
class Test(BaseIntegrationTest):
INSTRUCTION = 'Execute the python script /workspace/python_script.py with input "John" and "25" and tell me the secret number.'
SECRET_NUMBER = int(hashlib.sha256(str(25).encode()).hexdigest()[:8], 16) % 1000
@classmethod
def initialize_runtime(cls, runtime: Runtime) -> None:
from openhands.core.logger import openhands_logger as logger
action = FileWriteAction(
path='/workspace/python_script.py',
content=(
'name = input("Enter your name: "); age = input("Enter your age: "); '
'import hashlib; secret = int(hashlib.sha256(str(age).encode()).hexdigest()[:8], 16) % 1000; '
'print(f"Hello {name}, you are {age} years old. Tell you a secret number: {secret}")'
),
)
logger.info(action, extra={'msg_type': 'ACTION'})
observation = runtime.run_action(action)
logger.info(observation, extra={'msg_type': 'OBSERVATION'})
@classmethod
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
from openhands.core.logger import openhands_logger as logger
# check if the license information is in any message
message_actions = [
event
for event in histories
if isinstance(
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
)
]
logger.info(f'Total message-like events: {len(message_actions)}')
for event in message_actions:
try:
if isinstance(event, AgentDelegateObservation):
content = event.content
elif isinstance(event, AgentFinishAction):
content = event.outputs.get('content', '')
if event.thought:
content += f'\n\n{event.thought}'
elif isinstance(event, MessageAction):
content = event.content
else:
logger.warning(f'Unexpected event type: {type(event)}')
continue
if str(cls.SECRET_NUMBER) in content:
return TestResult(success=True)
except Exception as e:
logger.error(f'Error processing event: {e}')
logger.debug(
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
)
return TestResult(
success=False,
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
)
@@ -33,24 +33,9 @@ describe("AccountSettingsContextMenu", () => {
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
});
it("should render Documentation link with correct attributes", () => {
renderWithRouter(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
/>,
);
const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a");
expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev");
expect(documentationLink).toHaveAttribute("target", "_blank");
expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should call onLogout when the logout option is clicked", async () => {
renderWithRouter(
<AccountSettingsContextMenu
@@ -8,13 +8,6 @@ vi.mock("#/hooks/use-auth-url", () => ({
useAuthUrl: () => "https://gitlab.com/oauth/authorize",
}));
// Mock the useTracking hook
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackLoginButtonClick: vi.fn(),
}),
}));
describe("AuthModal", () => {
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
@@ -13,6 +13,34 @@ vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the custom hooks
const mockStartConversationMutate = vi.fn();
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
useUnifiedStartConversation: () => ({
mutate: mockStartConversationMutate,
}),
}));
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedStopConversation: () => ({
mutate: mockStopConversationMutate,
}),
}));
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({
conversationId: "test-conversation-id",
}),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/query/use-task-polling", () => ({
useTaskPolling: () => ({
isTask: false,
@@ -38,12 +66,8 @@ vi.mock("react-i18next", async () => {
COMMON$SERVER_STOPPED: "Server Stopped",
COMMON$ERROR: "Error",
COMMON$STARTING: "Starting",
COMMON$STOPPING: "Stopping...",
COMMON$STOP_RUNTIME: "Stop Runtime",
COMMON$START_RUNTIME: "Start Runtime",
CONVERSATION$ERROR_STARTING_CONVERSATION:
"Error starting conversation",
CONVERSATION$READY: "Ready",
};
return translations[key] || key;
},
@@ -55,6 +79,10 @@ vi.mock("react-i18next", async () => {
});
describe("ServerStatus", () => {
// Mock functions for handlers
const mockHandleStop = vi.fn();
const mockHandleResumeAgent = vi.fn();
// Helper function to mock agent state with specific state
const mockAgentStore = (agentState: AgentState) => {
vi.mocked(useAgentState).mockReturnValue({
@@ -66,91 +94,248 @@ describe("ServerStatus", () => {
vi.clearAllMocks();
});
it("should render server status with RUNNING conversation status", () => {
it("should render server status with different conversation statuses", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
// Test RUNNING status
const { rerender } = renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
expect(screen.getByTestId("server-status")).toBeInTheDocument();
// Test STOPPED status
rerender(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
// Test STARTING status (shows "Running" due to agent state being RUNNING)
rerender(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test null status (shows "Running" due to agent state being RUNNING)
rerender(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should render server status with STOPPED conversation status", () => {
mockAgentStore(AgentState.RUNNING);
it("should show context menu when clicked with RUNNING status", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
});
it("should render STARTING status when agent state is LOADING", () => {
mockAgentStore(AgentState.LOADING);
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Starting")).toBeInTheDocument();
});
it("should render STARTING status when agent state is INIT", () => {
mockAgentStore(AgentState.INIT);
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Starting")).toBeInTheDocument();
});
it("should render ERROR status when agent state is ERROR", () => {
mockAgentStore(AgentState.ERROR);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Error")).toBeInTheDocument();
});
it("should render STOPPING status when isPausing is true", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus conversationStatus="RUNNING" isPausing={true} />,
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Stopping...")).toBeInTheDocument();
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should appear
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
});
it("should show context menu when clicked with STOPPED status", async () => {
const user = userEvent.setup();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should appear
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
});
it("should not show context menu when clicked with other statuses", async () => {
const user = userEvent.setup();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should not appear
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("should call stop conversation mutation when stop server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockHandleStop.mockClear();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should call start conversation mutation when start server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockHandleResumeAgent.mockClear();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
});
it("should close context menu after stop server action", async () => {
const user = userEvent.setup();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
// Context menu should be closed (handled by the component)
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should close context menu after start server action", async () => {
const user = userEvent.setup();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
// Context menu should be closed
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("should handle null conversation status", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus={null} />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should apply custom className", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus conversationStatus="RUNNING" className="custom-class" />,
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const container = screen.getByTestId("server-status");
expect(container).toHaveClass("custom-class");
const statusText = screen.getByText("Running");
expect(statusText).toBeInTheDocument();
});
});
describe("ServerStatusContextMenu", () => {
// Helper function to mock agent state with specific state
const mockAgentStore = (agentState: AgentState) => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
});
};
const defaultProps = {
onClose: vi.fn(),
conversationStatus: "RUNNING" as ConversationStatus,
@@ -161,8 +346,6 @@ describe("ServerStatusContextMenu", () => {
});
it("should render stop server button when status is RUNNING", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -171,14 +354,11 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
expect(screen.getByText("Stop Runtime")).toBeInTheDocument();
});
it("should render start server button when status is STOPPED", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -187,14 +367,11 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
expect(screen.getByText("Start Runtime")).toBeInTheDocument();
});
it("should not render stop server button when onStopServer is not provided", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -202,13 +379,10 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
});
it("should not render start server button when onStartServer is not provided", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -216,14 +390,12 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
});
it("should call onStopServer when stop button is clicked", async () => {
const user = userEvent.setup();
const onStopServer = vi.fn();
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
@@ -242,7 +414,6 @@ describe("ServerStatusContextMenu", () => {
it("should call onStartServer when start button is clicked", async () => {
const user = userEvent.setup();
const onStartServer = vi.fn();
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
@@ -259,8 +430,6 @@ describe("ServerStatusContextMenu", () => {
});
it("should render correct text content for stop server button", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -275,8 +444,6 @@ describe("ServerStatusContextMenu", () => {
});
it("should render correct text content for start server button", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -292,7 +459,6 @@ describe("ServerStatusContextMenu", () => {
it("should call onClose when context menu is closed", () => {
const onClose = vi.fn();
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
@@ -309,8 +475,6 @@ describe("ServerStatusContextMenu", () => {
});
it("should not render any buttons for other conversation statuses", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -318,7 +482,6 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
});
@@ -21,7 +21,6 @@ const mockUseConfig = vi.fn();
const mockUseRepositoryMicroagents = vi.fn();
const mockUseMicroagentManagementConversations = vi.fn();
const mockUseSearchRepositories = vi.fn();
const mockUseCreateConversationAndSubscribeMultiple = vi.fn();
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
@@ -48,17 +47,6 @@ vi.mock("#/hooks/query/use-search-repositories", () => ({
useSearchRepositories: () => mockUseSearchRepositories(),
}));
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackEvent: vi.fn(),
}),
}));
vi.mock("#/hooks/use-create-conversation-and-subscribe-multiple", () => ({
useCreateConversationAndSubscribeMultiple: () =>
mockUseCreateConversationAndSubscribeMultiple(),
}));
describe("MicroagentManagement", () => {
const RouterStub = createRoutesStub([
{
@@ -321,16 +309,6 @@ describe("MicroagentManagement", () => {
isError: false,
});
mockUseCreateConversationAndSubscribeMultiple.mockReturnValue({
createConversationAndSubscribe: vi.fn(({ onSuccessCallback }) => {
// Immediately call the success callback to close the modal
if (onSuccessCallback) {
onSuccessCallback();
}
}),
isPending: false,
});
// Mock the search repositories hook to return repositories with OpenHands suffixes
const mockSearchResults =
getRepositoriesWithOpenHandsSuffix(mockRepositories);
@@ -30,7 +30,7 @@ describe("ImagePreview", () => {
expect(onRemoveMock).toHaveBeenCalledOnce();
});
it("should not display the close button when onRemove is not provided", () => {
it("shoud not display the close button when onRemove is not provided", () => {
render(<ImagePreview src="https://example.com/image.jpg" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
@@ -268,7 +268,7 @@ describe("useWebSocket", () => {
});
// onError handler should have been called
expect(onErrorSpy).toHaveBeenCalled();
expect(onErrorSpy).toHaveBeenCalledOnce();
});
it("should provide sendMessage function to send messages to WebSocket", async () => {
+6 -12
View File
@@ -1,9 +1,10 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AcceptTOS from "#/routes/accept-tos";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { openHands } from "#/api/open-hands-axios";
// Mock the react-router hooks
@@ -43,13 +44,9 @@ const createWrapper = () => {
},
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return Wrapper;
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe("AcceptTOS", () => {
@@ -109,10 +106,7 @@ describe("AcceptTOS", () => {
// Wait for the mutation to complete
await new Promise(process.nextTick);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(
expect.anything(),
true,
);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
expect(openHands.post).toHaveBeenCalledWith("/api/accept_tos", {
redirect_url: "/dashboard",
});
@@ -46,21 +46,6 @@ describe("Content", () => {
});
});
it("should render analytics toggle as enabled when server returns null (opt-in by default)", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: null,
});
renderAppSettingsScreen();
await waitFor(() => {
const analytics = screen.getByTestId("enable-analytics-switch");
expect(analytics).toBeChecked();
});
});
it("should render the language options", async () => {
renderAppSettingsScreen();
@@ -178,10 +163,7 @@ describe("Form submission", () => {
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(
expect.anything(),
true,
),
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
);
});
@@ -206,10 +188,7 @@ describe("Form submission", () => {
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(
expect.anything(),
false,
),
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
);
});
@@ -32,7 +32,6 @@ describe("Error Handler", () => {
const error = {
message: "Test error",
source: "test",
posthog,
};
trackError(error);
@@ -53,7 +52,6 @@ describe("Error Handler", () => {
extra: "info",
details: { foo: "bar" },
},
posthog,
};
trackError(error);
@@ -75,7 +73,6 @@ describe("Error Handler", () => {
const error = {
message: "Toast error",
source: "toast-test",
posthog,
};
showErrorToast(error);
@@ -97,7 +94,6 @@ describe("Error Handler", () => {
message: "Toast error",
source: "toast-test",
metadata: { context: "testing" },
posthog,
};
showErrorToast(error);
@@ -117,7 +113,6 @@ describe("Error Handler", () => {
message: "Agent error",
source: "agent-status",
metadata: { id: "error.agent" },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -132,7 +127,6 @@ describe("Error Handler", () => {
message: "Server error",
source: "server",
metadata: { error_code: 500, details: "Internal error" },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -151,7 +145,6 @@ describe("Error Handler", () => {
message: error.message,
source: "feedback",
metadata: { conversationId: "123", error },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -171,7 +164,6 @@ describe("Error Handler", () => {
message: "Chat error",
source: "chat-test",
msgId: "123",
posthog,
};
showChatError(error);
@@ -13,14 +13,14 @@ describe("handleCaptureConsent", () => {
});
it("should opt out of of capturing", () => {
handleCaptureConsent(posthog, false);
handleCaptureConsent(false);
expect(optOutSpy).toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
});
it("should opt in to capturing if the user consents", () => {
handleCaptureConsent(posthog, true);
handleCaptureConsent(true);
expect(optInSpy).toHaveBeenCalled();
expect(optOutSpy).not.toHaveBeenCalled();
@@ -28,7 +28,7 @@ describe("handleCaptureConsent", () => {
it("should not opt in to capturing if the user is already opted in", () => {
hasOptedInSpy.mockReturnValueOnce(true);
handleCaptureConsent(posthog, true);
handleCaptureConsent(true);
expect(optInSpy).not.toHaveBeenCalled();
expect(optOutSpy).not.toHaveBeenCalled();
@@ -36,7 +36,7 @@ describe("handleCaptureConsent", () => {
it("should not opt out of capturing if the user is already opted out", () => {
hasOptedOutSpy.mockReturnValueOnce(true);
handleCaptureConsent(posthog, false);
handleCaptureConsent(false);
expect(optOutSpy).not.toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
+28 -32
View File
@@ -1,18 +1,17 @@
{
"name": "openhands-frontend",
"version": "0.62.0",
"version": "0.60.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.62.0",
"version": "0.60.0",
"dependencies": {
"@heroui/react": "^2.8.4",
"@heroui/use-infinite-scroll": "^2.2.11",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@posthog/react": "^1.4.0",
"@react-router/node": "^7.9.3",
"@react-router/serve": "^7.9.3",
"@react-types/shared": "^3.32.0",
@@ -39,7 +38,7 @@
"jose": "^6.1.0",
"lucide-react": "^0.544.0",
"monaco-editor": "^0.53.0",
"posthog-js": "^1.290.0",
"posthog-js": "^1.268.8",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -3512,29 +3511,9 @@
"license": "MIT"
},
"node_modules/@posthog/core": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.5.2.tgz",
"integrity": "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6"
}
},
"node_modules/@posthog/react": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.4.0.tgz",
"integrity": "sha512-xzPeZ753fQ0deZzdgY/0YavZvNpmdaxUzLYJYu5XjONNcZ8PwJnNLEK+7D/Cj8UM4Q8nWI7QC5mjum0uLWa4FA==",
"license": "MIT",
"peerDependencies": {
"@types/react": ">=16.8.0",
"posthog-js": ">=1.257.2",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.2.tgz",
"integrity": "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg=="
},
"node_modules/@react-aria/breadcrumbs": {
"version": "3.5.28",
@@ -8204,6 +8183,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -8218,6 +8198,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -11422,6 +11403,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -14091,6 +14073,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -14281,16 +14264,27 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.290.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.290.0.tgz",
"integrity": "sha512-zavBwZkf+3JeiSDVE7ZDXBfzva/iOljicdhdJH+cZoqp0LsxjKxjnNhGOd3KpAhw0wqdwjhd7Lp1aJuI7DXyaw==",
"license": "SEE LICENSE IN LICENSE",
"version": "1.268.8",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.268.8.tgz",
"integrity": "sha512-BJiKK4MlUvs7ybnQcy1KkwAz+SZkE/wRLotetIoank5kbqZs8FLbeyozFvmmgx4aoMmaVymYBSmYphYjYQeidw==",
"dependencies": {
"@posthog/core": "1.5.2",
"@posthog/core": "1.2.2",
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
}
},
"node_modules/posthog-js/node_modules/web-vitals": {
@@ -15553,6 +15547,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -15565,6 +15560,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.62.0",
"version": "0.60.0",
"private": true,
"type": "module",
"engines": {
@@ -11,7 +11,6 @@
"@heroui/use-infinite-scroll": "^2.2.11",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@posthog/react": "^1.4.0",
"@react-router/node": "^7.9.3",
"@react-router/serve": "^7.9.3",
"@react-types/shared": "^3.32.0",
@@ -38,7 +37,7 @@
"jose": "^6.1.0",
"lucide-react": "^0.544.0",
"monaco-editor": "^0.53.0",
"posthog-js": "^1.290.0",
"posthog-js": "^1.268.8",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -11,6 +11,7 @@ import type {
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
V1SandboxInfo,
} from "./v1-conversation-service.types";
class V1ConversationService {
@@ -212,6 +213,36 @@ class V1ConversationService {
return data;
}
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*
* @param sandboxId The sandbox ID to pause
* @returns Success response
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*
* @param sandboxId The sandbox ID to resume
* @returns Success response
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 app conversations by their IDs
* Returns null for any missing conversations
@@ -238,6 +269,32 @@ class V1ConversationService {
return data;
}
/**
* Batch get V1 sandboxes by their IDs
* Returns null for any missing sandboxes
*
* @param ids Array of sandbox IDs (max 100)
* @returns Array of sandboxes or null for missing ones
*/
static async batchGetSandboxes(
ids: string[],
): Promise<(V1SandboxInfo | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 sandboxes at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("id", id));
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
`/api/v1/sandboxes?${params.toString()}`,
);
return data;
}
/**
* Upload a single file to the V1 conversation workspace
* V1 API endpoint: POST /api/file/upload/{path}
@@ -288,6 +345,24 @@ class V1ConversationService {
const { data } = await openHands.get<{ runtime_id: string }>(url);
return data;
}
/**
* Get the count of events for a conversation
* Uses the V1 API endpoint: GET /api/v1/events/count
*
* @param conversationId The conversation ID to get event count for
* @returns The number of events in the conversation
*/
static async getEventCount(conversationId: string): Promise<number> {
const params = new URLSearchParams();
params.append("conversation_id__eq", conversationId);
const { data } = await openHands.get<number>(
`/api/v1/events/count?${params.toString()}`,
);
return data;
}
}
export default V1ConversationService;
@@ -1,6 +1,5 @@
import { ConversationTrigger } from "../open-hands.types";
import { Provider } from "#/types/settings";
import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types";
// V1 API Types for requests
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
@@ -65,7 +64,14 @@ export interface V1AppConversationStartTaskPage {
next_page_id: string | null;
}
export type V1ConversationExecutionStatus =
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
export type V1AgentExecutionStatus =
| "RUNNING"
| "AWAITING_USER_INPUT"
| "AWAITING_USER_CONFIRMATION"
@@ -88,7 +94,22 @@ export interface V1AppConversation {
created_at: string;
updated_at: string;
sandbox_status: V1SandboxStatus;
execution_status: V1ConversationExecutionStatus | null;
agent_status: V1AgentExecutionStatus | null;
conversation_url: string | null;
session_api_key: string | null;
}
export interface V1ExposedUrl {
name: string;
url: string;
}
export interface V1SandboxInfo {
id: string;
created_by_user_id: string | null;
sandbox_spec_id: string;
status: V1SandboxStatus;
session_api_key: string | null;
exposed_urls: V1ExposedUrl[] | null;
created_at: string;
}
@@ -5,7 +5,6 @@ import type {
ConfirmationResponseRequest,
ConfirmationResponseResponse,
} from "./event-service.types";
import { openHands } from "../open-hands-axios";
class EventService {
/**
@@ -37,14 +36,6 @@ class EventService {
return data;
}
static async getEventCount(conversationId: string): Promise<number> {
const params = new URLSearchParams();
params.append("conversation_id__eq", conversationId);
const { data } = await openHands.get<number>(
`/api/v1/events/count?${params.toString()}`,
);
return data;
}
}
export default EventService;
@@ -1,52 +0,0 @@
// sandbox-service.api.ts
// This file contains API methods for /api/v1/sandboxes endpoints.
import { openHands } from "../open-hands-axios";
import type { V1SandboxInfo } from "./sandbox-service.types";
export class SandboxService {
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 sandboxes by their IDs
* Returns null for any missing sandboxes
*/
static async batchGetSandboxes(
ids: string[],
): Promise<(V1SandboxInfo | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 sandboxes at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("id", id));
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
`/api/v1/sandboxes?${params.toString()}`,
);
return data;
}
}
@@ -1,24 +0,0 @@
// sandbox-service.types.ts
// This file contains types for Sandbox API.
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
export interface V1ExposedUrl {
name: string;
url: string;
}
export interface V1SandboxInfo {
id: string;
created_by_user_id: string | null;
sandbox_spec_id: string;
status: V1SandboxStatus;
session_api_key: string | null;
exposed_urls: V1ExposedUrl[] | null;
created_at: string;
}
@@ -1,5 +1,4 @@
import { useTranslation } from "react-i18next";
import { usePostHog } from "posthog-js/react";
import {
BaseModalTitle,
BaseModalDescription,
@@ -18,7 +17,6 @@ interface AnalyticsConsentFormModalProps {
export function AnalyticsConsentFormModal({
onClose,
}: AnalyticsConsentFormModalProps) {
const posthog = usePostHog();
const { t } = useTranslation();
const { mutate: saveUserSettings } = useSaveSettings();
@@ -31,7 +29,7 @@ export function AnalyticsConsentFormModal({
{ user_consents_to_analytics: analytics },
{
onSuccess: () => {
handleCaptureConsent(posthog, analytics);
handleCaptureConsent(analytics);
onClose();
},
},
@@ -1,109 +0,0 @@
import React, { useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import CodeTagIcon from "#/icons/code-tag.svg?react";
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { useConversationStore } from "#/state/conversation-store";
import { ChangeAgentContextMenu } from "./change-agent-context-menu";
import { cn } from "#/utils/utils";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
export function ChangeAgentButton() {
const { t } = useTranslation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const conversationMode = useConversationStore(
(state) => state.conversationMode,
);
const setConversationMode = useConversationStore(
(state) => state.setConversationMode,
);
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const { curAgentState } = useAgentState();
const isAgentRunning = curAgentState === AgentState.RUNNING;
// Close context menu when agent starts running
useEffect(() => {
if (isAgentRunning && contextMenuOpen) {
setContextMenuOpen(false);
}
}, [isAgentRunning, contextMenuOpen]);
const handleButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setContextMenuOpen(!contextMenuOpen);
};
const handleCodeClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setConversationMode("code");
};
const handlePlanClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setConversationMode("plan");
};
const isExecutionAgent = conversationMode === "code";
const buttonLabel = useMemo(() => {
if (isExecutionAgent) {
return t(I18nKey.COMMON$CODE);
}
return t(I18nKey.COMMON$PLAN);
}, [isExecutionAgent, t]);
const buttonIcon = useMemo(() => {
if (isExecutionAgent) {
return <CodeTagIcon width={18} height={18} color="#737373" />;
}
return <LessonPlanIcon width={18} height={18} color="#ffffff" />;
}, [isExecutionAgent]);
if (!shouldUsePlanningAgent) {
return null;
}
return (
<div className="relative">
<button
type="button"
onClick={handleButtonClick}
disabled={isAgentRunning}
className={cn(
"flex items-center border border-[#4B505F] rounded-[100px] transition-opacity",
!isExecutionAgent && "border-[#597FF4] bg-[#4A67BD]",
isAgentRunning
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:opacity-80",
)}
>
<div className="flex items-center gap-1 pl-1.5">
{buttonIcon}
<Typography.Text className="text-white text-2.75 not-italic font-normal leading-5">
{buttonLabel}
</Typography.Text>
</div>
<ChevronDownSmallIcon width={24} height={24} color="#ffffff" />
</button>
{contextMenuOpen && (
<ChangeAgentContextMenu
onClose={() => setContextMenuOpen(false)}
onCodeClick={handleCodeClick}
onPlanClick={handlePlanClick}
/>
)}
</div>
);
}
@@ -1,81 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import CodeTagIcon from "#/icons/code-tag.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { CONTEXT_MENU_ICON_TEXT_CLASSNAME } from "#/utils/constants";
const contextMenuListItemClassName = cn(
"cursor-pointer p-0 h-auto hover:bg-transparent",
CONTEXT_MENU_ICON_TEXT_CLASSNAME,
);
const contextMenuIconTextClassName =
"gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]";
interface ChangeAgentContextMenuProps {
onClose: () => void;
onCodeClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onPlanClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
export function ChangeAgentContextMenu({
onClose,
onCodeClick,
onPlanClick,
}: ChangeAgentContextMenuProps) {
const { t } = useTranslation();
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
const handleCodeClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onCodeClick?.(event);
onClose();
};
const handlePlanClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onPlanClick?.(event);
onClose();
};
return (
<ContextMenu
ref={menuRef}
testId="change-agent-context-menu"
position="top"
alignment="left"
className="min-h-fit min-w-[195px] mb-2"
>
<ContextMenuListItem
testId="code-option"
onClick={handleCodeClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
icon={CodeTagIcon}
text={t(I18nKey.COMMON$CODE)}
className={contextMenuIconTextClassName}
/>
</ContextMenuListItem>
<ContextMenuListItem
testId="plan-option"
onClick={handlePlanClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
icon={LessonPlanIcon}
text={t(I18nKey.COMMON$PLAN)}
className={contextMenuIconTextClassName}
/>
</ContextMenuListItem>
</ContextMenu>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
@@ -60,7 +60,6 @@ function getEntryPoint(
}
export function ChatInterface() {
const posthog = usePostHog();
const { setMessageToSend } = useConversationStore();
const { data: conversation } = useActiveConversation();
const { errorMessage } = useErrorMessageStore();
@@ -98,29 +97,24 @@ export function ChatInterface() {
const isV1Conversation = conversation?.conversation_version === "V1";
// Track when we should show V1 messages (after DOM has rendered)
const [showV1Messages, setShowV1Messages] = React.useState(false);
const prevV1LoadingRef = React.useRef(
// Instantly scroll to bottom when history loading completes
const prevLoadingHistoryRef = React.useRef(
conversationWebSocket?.isLoadingHistory,
);
// Wait for DOM to render before showing V1 messages
React.useEffect(() => {
const wasLoading = prevV1LoadingRef.current;
const wasLoading = prevLoadingHistoryRef.current;
const isLoading = conversationWebSocket?.isLoadingHistory;
if (wasLoading && !isLoading) {
// Loading just finished - wait for next frame to ensure DOM is ready
requestAnimationFrame(() => {
setShowV1Messages(true);
// When history loading transitions from true to false, instantly scroll to bottom
if (wasLoading && !isLoading && scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "instant",
});
} else if (isLoading) {
// Reset when loading starts
setShowV1Messages(false);
}
prevV1LoadingRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory]);
prevLoadingHistoryRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory, scrollRef]);
// Filter V0 events
const v0Events = storeEvents
@@ -258,7 +252,7 @@ export function ChatInterface() {
</div>
)}
{(conversationWebSocket?.isLoadingHistory || !showV1Messages) &&
{conversationWebSocket?.isLoadingHistory &&
isV1Conversation &&
!isTask && (
<div className="flex justify-center">
@@ -275,7 +269,7 @@ export function ChatInterface() {
/>
)}
{showV1Messages && v1UserEventsExist && (
{!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
<V1Messages messages={v1UiEvents} allEvents={v1FullEvents} />
)}
</div>
@@ -1,33 +1,45 @@
import { ConversationStatus } from "#/types/conversation-status";
import { ServerStatus } from "#/components/features/controls/server-status";
import { AgentStatus } from "#/components/features/controls/agent-status";
import { Tools } from "../../controls/tools";
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useSendMessage } from "#/hooks/use-send-message";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { AgentState } from "#/types/agent-state";
import { useV1PauseConversation } from "#/hooks/mutation/use-v1-pause-conversation";
import { useV1ResumeConversation } from "#/hooks/mutation/use-v1-resume-conversation";
import { ChangeAgentButton } from "../change-agent-button";
interface ChatInputActionsProps {
conversationStatus: ConversationStatus | null;
disabled: boolean;
handleResumeAgent: () => void;
}
export function ChatInputActions({
conversationStatus,
disabled,
handleResumeAgent,
}: ChatInputActionsProps) {
const { data: conversation } = useActiveConversation();
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
const resumeConversationSandboxMutation =
useUnifiedResumeConversationSandbox();
const v1PauseConversationMutation = useV1PauseConversation();
const v1ResumeConversationMutation = useV1ResumeConversation();
const { conversationId } = useConversationId();
const { providers } = useUserProviders();
const { send } = useSendMessage();
const isV1Conversation = conversation?.conversation_version === "V1";
const handleStopClick = () => {
pauseConversationSandboxMutation.mutate({ conversationId });
};
const handlePauseAgent = () => {
if (isV1Conversation) {
// V1: Pause the conversation (agent execution)
@@ -50,6 +62,10 @@ export function ChatInputActions({
handleResumeAgent();
};
const handleStartClick = () => {
resumeConversationSandboxMutation.mutate({ conversationId, providers });
};
const isPausing =
pauseConversationSandboxMutation.isPending ||
v1PauseConversationMutation.isPending;
@@ -57,10 +73,13 @@ export function ChatInputActions({
return (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<div className="flex items-center gap-4">
<Tools />
<ChangeAgentButton />
</div>
<Tools />
<ServerStatus
conversationStatus={conversationStatus}
isPausing={isPausing}
handleStop={handleStopClick}
handleResumeAgent={handleStartClick}
/>
</div>
<AgentStatus
className="ml-2 md:ml-3"
@@ -1,10 +1,9 @@
import React from "react";
import { ConversationStatus } from "#/types/conversation-status";
import { DragOver } from "../drag-over";
import { UploadedFiles } from "../uploaded-files";
import { ChatInputRow } from "./chat-input-row";
import { ChatInputActions } from "./chat-input-actions";
import { useConversationStore } from "#/state/conversation-store";
import { cn } from "#/utils/utils";
interface ChatInputContainerProps {
chatContainerRef: React.RefObject<HTMLDivElement | null>;
@@ -12,6 +11,7 @@ interface ChatInputContainerProps {
disabled: boolean;
showButton: boolean;
buttonClassName: string;
conversationStatus: ConversationStatus | null;
chatInputRef: React.RefObject<HTMLDivElement | null>;
handleFileIconClick: (isDisabled: boolean) => void;
handleSubmit: () => void;
@@ -32,6 +32,7 @@ export function ChatInputContainer({
disabled,
showButton,
buttonClassName,
conversationStatus,
chatInputRef,
handleFileIconClick,
handleSubmit,
@@ -45,17 +46,10 @@ export function ChatInputContainer({
onFocus,
onBlur,
}: ChatInputContainerProps) {
const conversationMode = useConversationStore(
(state) => state.conversationMode,
);
return (
<div
ref={chatContainerRef}
className={cn(
"bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full",
conversationMode === "plan" && "border border-[#597FF4]",
)}
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
onDragOver={(e) => onDragOver(e, disabled)}
onDragLeave={(e) => onDragLeave(e, disabled)}
onDrop={(e) => onDrop(e, disabled)}
@@ -80,6 +74,7 @@ export function ChatInputContainer({
/>
<ChatInputActions
conversationStatus={conversationStatus}
disabled={disabled}
handleResumeAgent={handleResumeAgent}
/>
@@ -1,7 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useConversationStore } from "#/state/conversation-store";
interface ChatInputFieldProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
@@ -22,12 +20,6 @@ export function ChatInputField({
}: ChatInputFieldProps) {
const { t } = useTranslation();
const conversationMode = useConversationStore(
(state) => state.conversationMode,
);
const isPlanMode = conversationMode === "plan";
return (
<div
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
@@ -38,11 +30,7 @@ export function ChatInputField({
ref={chatInputRef}
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
contentEditable
data-placeholder={
isPlanMode
? t(I18nKey.COMMON$LET_S_WORK_ON_A_PLAN)
: t(I18nKey.SUGGESTIONS$WHAT_TO_BUILD)
}
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
data-testid="chat-input"
onInput={onInput}
onPaste={onPaste}
@@ -137,6 +137,7 @@ export function CustomChatInput({
disabled={isDisabled}
showButton={showButton}
buttonClassName={buttonClassName}
conversationStatus={conversationStatus}
chatInputRef={chatInputRef}
handleFileIconClick={handleFileIconClick}
handleSubmit={handleSubmit}
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import PRIcon from "#/icons/u-pr.svg?react";
import { cn, getCreatePRPrompt } from "#/utils/utils";
import { useUserProviders } from "#/hooks/use-user-providers";
import { I18nKey } from "#/i18n/declaration";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
interface GitControlBarPrButtonProps {
onSuggestionsClick: (value: string) => void;
@@ -20,7 +20,6 @@ export function GitControlBarPrButton({
isConversationReady = true,
}: GitControlBarPrButtonProps) {
const { t } = useTranslation();
const { trackCreatePrButtonClick } = useTracking();
const { providers } = useUserProviders();
@@ -29,7 +28,7 @@ export function GitControlBarPrButton({
providersAreSet && hasRepository && isConversationReady;
const handlePrClick = () => {
trackCreatePrButtonClick();
posthog.capture("create_pr_button_clicked");
onSuggestionsClick(getCreatePRPrompt(currentGitProvider));
};
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import ArrowDownIcon from "#/icons/u-arrow-down.svg?react";
import { cn, getGitPullPrompt } from "#/utils/utils";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { I18nKey } from "#/i18n/declaration";
import { useTracking } from "#/hooks/use-tracking";
interface GitControlBarPullButtonProps {
onSuggestionsClick: (value: string) => void;
@@ -16,7 +16,6 @@ export function GitControlBarPullButton({
isConversationReady = true,
}: GitControlBarPullButtonProps) {
const { t } = useTranslation();
const { trackPullButtonClick } = useTracking();
const { data: conversation } = useActiveConversation();
const { providers } = useUserProviders();
@@ -27,7 +26,7 @@ export function GitControlBarPullButton({
providersAreSet && hasRepository && isConversationReady;
const handlePullClick = () => {
trackPullButtonClick();
posthog.capture("pull_button_clicked");
onSuggestionsClick(getGitPullPrompt());
};
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import ArrowUpIcon from "#/icons/u-arrow-up.svg?react";
import { cn, getGitPushPrompt } from "#/utils/utils";
import { useUserProviders } from "#/hooks/use-user-providers";
import { I18nKey } from "#/i18n/declaration";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
interface GitControlBarPushButtonProps {
onSuggestionsClick: (value: string) => void;
@@ -20,7 +20,6 @@ export function GitControlBarPushButton({
isConversationReady = true,
}: GitControlBarPushButtonProps) {
const { t } = useTranslation();
const { trackPushButtonClick } = useTracking();
const { providers } = useUserProviders();
@@ -29,7 +28,7 @@ export function GitControlBarPushButton({
providersAreSet && hasRepository && isConversationReady;
const handlePushClick = () => {
trackPushButtonClick();
posthog.capture("push_button_clicked");
onSuggestionsClick(getGitPushPrompt(currentGitProvider));
};
@@ -1,82 +0,0 @@
import { useTranslation } from "react-i18next";
import { ArrowUpRight } from "lucide-react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
interface PlanPreviewProps {
title?: string;
description?: string;
onViewClick?: () => void;
onBuildClick?: () => void;
}
// TODO: Remove the hardcoded values and use the plan content from the conversation store
/* eslint-disable i18next/no-literal-string */
export function PlanPreview({
title = "Improve Developer Onboarding and Examples",
description = "Based on the analysis of Browser-Use's current documentation and examples, this plan addresses gaps in developer onboarding by creating a progressive learning path, troubleshooting resources, and practical examples that address real-world scenarios (like the LM Studio/local LLM integration issues encountered...",
onViewClick,
onBuildClick,
}: PlanPreviewProps) {
const { t } = useTranslation();
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
if (!shouldUsePlanningAgent) {
return null;
}
return (
<div className="bg-[#25272d] border border-[#597FF4] rounded-[12px] w-full mb-4 mt-2">
{/* Header */}
<div className="border-b border-[#525252] flex h-[41px] items-center px-2 gap-1">
<LessonPlanIcon width={18} height={18} color="#9299aa" />
<Typography.Text className="font-medium text-[11px] text-white tracking-[0.11px] leading-4">
{t(I18nKey.COMMON$PLAN_MD)}
</Typography.Text>
<div className="flex-1" />
<button
type="button"
onClick={onViewClick}
className="flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<Typography.Text className="font-medium text-[11px] text-white tracking-[0.11px] leading-4">
{t(I18nKey.COMMON$VIEW)}
</Typography.Text>
<ArrowUpRight className="text-white" size={18} />
</button>
</div>
{/* Content */}
<div className="flex flex-col gap-[10px] p-4">
<h3 className="font-bold text-[19px] text-white leading-[29px]">
{title}
</h3>
<p className="text-[15px] text-white leading-[29px]">
{description}
<Typography.Text className="text-[#4a67bd] cursor-pointer hover:underline ml-1">
{t(I18nKey.COMMON$READ_MORE)}
</Typography.Text>
</p>
</div>
{/* Footer */}
<div className="border-t border-[#525252] flex h-[54px] items-center justify-start px-4">
<button
type="button"
onClick={onBuildClick}
className="bg-white flex items-center justify-center h-[26px] px-2 rounded-[4px] w-[93px] hover:opacity-90 transition-opacity cursor-pointer"
>
<Typography.Text className="font-medium text-[14px] text-black leading-5">
{t(I18nKey.COMMON$BUILD)}{" "}
<Typography.Text className="font-medium text-black">
</Typography.Text>
</Typography.Text>
</button>
</div>
</div>
);
}
@@ -8,7 +8,6 @@ import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import LogOutIcon from "#/icons/log-out.svg?react";
import DocumentIcon from "#/icons/document.svg?react";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
interface AccountSettingsContextMenuProps {
@@ -59,21 +58,6 @@ export function AccountSettingsContextMenu({
<Divider />
<a
href="https://docs.openhands.dev"
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none"
>
<ContextMenuListItem
onClick={onClose}
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
>
<DocumentIcon width={16} height={16} />
<span className="text-white text-sm">{t(I18nKey.SIDEBAR$DOCS)}</span>
</ContextMenuListItem>
</a>
<ContextMenuListItem
onClick={onLogout}
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
@@ -13,7 +13,6 @@ import { useConversationStore } from "#/state/conversation-store";
import CircleErrorIcon from "#/icons/circle-error.svg?react";
import { useAgentState } from "#/hooks/use-agent-state";
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
export interface AgentStatusProps {
className?: string;
@@ -36,7 +35,6 @@ export function AgentStatus({
const { curStatusMessage } = useStatusStore();
const webSocketStatus = useUnifiedWebSocketStatus();
const { data: conversation } = useActiveConversation();
const { taskStatus } = useTaskPolling();
const statusCode = getStatusCode(
curStatusMessage,
@@ -44,33 +42,25 @@ export function AgentStatus({
conversation?.status || null,
conversation?.runtime_status || null,
curAgentState,
taskStatus,
);
const isTaskLoading =
taskStatus && taskStatus !== "ERROR" && taskStatus !== "READY";
const shouldShownAgentLoading =
isPausing ||
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING ||
(webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") ||
isTaskLoading;
webSocketStatus === "CONNECTING";
const shouldShownAgentError =
curAgentState === AgentState.ERROR ||
curAgentState === AgentState.RATE_LIMITED ||
webSocketStatus === "DISCONNECTED" ||
taskStatus === "ERROR";
curAgentState === AgentState.RATE_LIMITED;
const shouldShownAgentStop = curAgentState === AgentState.RUNNING;
const shouldShownAgentResume =
curAgentState === AgentState.STOPPED || curAgentState === AgentState.PAUSED;
const shouldShownAgentResume = curAgentState === AgentState.STOPPED;
// Update global state when agent loading condition changes
useEffect(() => {
setShouldShownAgentLoading(!!shouldShownAgentLoading);
setShouldShownAgentLoading(shouldShownAgentLoading);
}, [shouldShownAgentLoading, setShouldShownAgentLoading]);
return (
@@ -13,7 +13,7 @@ export function ServerStatusContextMenuIconText({
}: ServerStatusContextMenuIconTextProps) {
return (
<button
className="flex items-center justify-between p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
onClick={onClick}
data-testid={testId}
type="button"
@@ -6,9 +6,6 @@ import { ConversationStatus } from "#/types/conversation-status";
import StopCircleIcon from "#/icons/stop-circle.svg?react";
import PlayCircleIcon from "#/icons/play-circle.svg?react";
import { ServerStatusContextMenuIconText } from "./server-status-context-menu-icon-text";
import { ServerStatus } from "./server-status";
import { Divider } from "#/ui/divider";
import { cn } from "#/utils/utils";
interface ServerStatusContextMenuProps {
onClose: () => void;
@@ -16,8 +13,6 @@ interface ServerStatusContextMenuProps {
onStartServer?: (event: React.MouseEvent<HTMLButtonElement>) => void;
conversationStatus: ConversationStatus | null;
position?: "top" | "bottom";
className?: string;
isPausing?: boolean;
}
export function ServerStatusContextMenu({
@@ -26,15 +21,10 @@ export function ServerStatusContextMenu({
onStartServer,
conversationStatus,
position = "top",
className = "",
isPausing = false,
}: ServerStatusContextMenuProps) {
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const shouldActionShown =
conversationStatus === "RUNNING" || conversationStatus === "STOPPED";
return (
<ContextMenu
ref={ref}
@@ -42,36 +32,24 @@ export function ServerStatusContextMenu({
position={position}
alignment="left"
size="default"
className={cn("left-2 w-fit min-w-42", className)}
className="left-2 w-fit min-w-max"
>
<ServerStatus
conversationStatus={conversationStatus}
isPausing={isPausing}
className="py-1"
/>
{conversationStatus === "RUNNING" && onStopServer && (
<ServerStatusContextMenuIconText
icon={<StopCircleIcon width={18} height={18} />}
text={t(I18nKey.COMMON$STOP_RUNTIME)}
onClick={onStopServer}
testId="stop-server-button"
/>
)}
{shouldActionShown && (
<>
<Divider />
{conversationStatus === "RUNNING" && onStopServer && (
<ServerStatusContextMenuIconText
icon={<StopCircleIcon width={18} height={18} />}
text={t(I18nKey.COMMON$STOP_RUNTIME)}
onClick={onStopServer}
testId="stop-server-button"
/>
)}
{conversationStatus === "STOPPED" && onStartServer && (
<ServerStatusContextMenuIconText
icon={<PlayCircleIcon width={18} height={18} />}
text={t(I18nKey.COMMON$START_RUNTIME)}
onClick={onStartServer}
testId="start-server-button"
/>
)}
</>
{conversationStatus === "STOPPED" && onStartServer && (
<ServerStatusContextMenuIconText
icon={<PlayCircleIcon width={18} height={18} />}
text={t(I18nKey.COMMON$START_RUNTIME)}
onClick={onStartServer}
testId="start-server-button"
/>
)}
</ContextMenu>
);
@@ -1,23 +1,30 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { ServerStatusContextMenu } from "./server-status-context-menu";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { getStatusColor } from "#/utils/utils";
export interface ServerStatusProps {
className?: string;
conversationStatus: ConversationStatus | null;
isPausing?: boolean;
handleStop: () => void;
handleResumeAgent: () => void;
}
export function ServerStatus({
className = "",
conversationStatus,
isPausing = false,
handleStop,
handleResumeAgent,
}: ServerStatusProps) {
const [showContextMenu, setShowContextMenu] = useState(false);
const { curAgentState } = useAgentState();
const { t } = useTranslation();
const { isTask, taskStatus, taskDetail } = useTaskPolling();
@@ -27,15 +34,34 @@ export function ServerStatus({
const isStopStatus = conversationStatus === "STOPPED";
const statusColor = getStatusColor({
isPausing,
isTask,
taskStatus,
isStartingStatus,
isStopStatus,
curAgentState,
});
// Get the appropriate color based on agent status
const getStatusColor = (): string => {
// Show pausing status
if (isPausing) {
return "#FFD600";
}
// Show task status if we're polling a task
if (isTask && taskStatus) {
if (taskStatus === "ERROR") {
return "#FF684E";
}
return "#FFD600";
}
if (isStartingStatus) {
return "#FFD600";
}
if (isStopStatus) {
return "#ffffff";
}
if (curAgentState === AgentState.ERROR) {
return "#FF684E";
}
return "#BCFF8C";
};
// Get the appropriate status text based on agent status
const getStatusText = (): string => {
// Show pausing status
if (isPausing) {
@@ -74,14 +100,49 @@ export function ServerStatus({
return t(I18nKey.COMMON$RUNNING);
};
const handleClick = () => {
if (conversationStatus === "RUNNING" || conversationStatus === "STOPPED") {
setShowContextMenu(true);
}
};
const handleCloseContextMenu = () => {
setShowContextMenu(false);
};
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
handleStop();
setShowContextMenu(false);
};
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
handleResumeAgent();
setShowContextMenu(false);
};
const statusColor = getStatusColor();
const statusText = getStatusText();
return (
<div className={className} data-testid="server-status">
<div className="flex items-center">
<div className={`relative ${className}`}>
<div className="flex items-center cursor-pointer" onClick={handleClick}>
<DebugStackframeDot className="w-6 h-6" color={statusColor} />
<span className="text-[13px] text-white font-normal">{statusText}</span>
<span className="text-[11px] text-white font-normal leading-5">
{statusText}
</span>
</div>
{showContextMenu && (
<ServerStatusContextMenu
onClose={handleCloseContextMenu}
onStopServer={handleStopServer}
onStartServer={handleStartServer}
conversationStatus={conversationStatus}
position="top"
/>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import posthog from "posthog-js";
import { cn } from "#/utils/utils";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import ConversationService from "#/api/conversation-service/conversation-service.api";
@@ -44,7 +44,6 @@ export function ConversationCard({
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const posthog = usePostHog();
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const onTitleSave = (newTitle: string) => {
@@ -1,79 +0,0 @@
import React from "react";
import { useParams } from "react-router";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { getStatusColor } from "#/utils/utils";
import { AgentState } from "#/types/agent-state";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
import { ServerStatusContextMenu } from "../controls/server-status-context-menu";
import { ConversationName } from "./conversation-name";
export function ConversationNameWithStatus() {
const { conversationId } = useParams<{ conversationId: string }>();
const { data: conversation } = useActiveConversation();
const { curAgentState } = useAgentState();
const { isTask, taskStatus } = useTaskPolling();
const { mutate: pauseConversationSandbox } =
useUnifiedPauseConversationSandbox();
const { mutate: resumeConversationSandbox } =
useUnifiedResumeConversationSandbox();
const { providers } = useUserProviders();
const isStartingStatus =
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
const isStopStatus = conversation?.status === "STOPPED";
const statusColor = getStatusColor({
isPausing: false,
isTask,
taskStatus,
isStartingStatus,
isStopStatus,
curAgentState,
});
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (conversationId) {
pauseConversationSandbox({ conversationId });
}
};
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (conversationId) {
resumeConversationSandbox({ conversationId, providers });
}
};
return (
<div className="flex items-center">
<div className="group relative">
<DebugStackframeDot
className="ml-[3.5px] w-6 h-6 cursor-pointer"
color={statusColor}
/>
<ServerStatusContextMenu
onClose={() => {}}
onStopServer={
conversation?.status === "RUNNING" ? handleStopServer : undefined
}
onStartServer={
conversation?.status === "STOPPED" ? handleStartServer : undefined
}
conversationStatus={conversation?.status ?? null}
position="bottom"
className="opacity-0 invisible pointer-events-none group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto bottom-full left-0 mt-0 min-h-fit"
isPausing={false}
/>
</div>
<ConversationName />
</div>
);
}
@@ -124,7 +124,7 @@ export function ConversationName() {
return (
<>
<div
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-1"
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-3.5"
data-testid="conversation-name"
>
{titleMode === "edit" ? (
@@ -15,7 +15,6 @@ const EditorTab = lazy(() => import("#/routes/changes-tab"));
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
const ServedTab = lazy(() => import("#/routes/served-tab"));
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
const PlannerTab = lazy(() => import("#/routes/planner-tab"));
export function ConversationTabContent() {
const { selectedTab, shouldShownAgentLoading } = useConversationStore();
@@ -29,7 +28,6 @@ export function ConversationTabContent() {
const isServedActive = selectedTab === "served";
const isVSCodeActive = selectedTab === "vscode";
const isTerminalActive = selectedTab === "terminal";
const isPlannerActive = selectedTab === "planner";
// Define tab configurations
const tabs = [
@@ -46,11 +44,6 @@ export function ConversationTabContent() {
component: Terminal,
isActive: isTerminalActive,
},
{
key: "planner",
component: PlannerTab,
isActive: isPlannerActive,
},
];
const conversationTabTitle = useMemo(() => {
@@ -69,9 +62,6 @@ export function ConversationTabContent() {
if (isTerminalActive) {
return t(I18nKey.COMMON$TERMINAL);
}
if (isPlannerActive) {
return t(I18nKey.COMMON$PLANNER);
}
return "";
}, [
isEditorActive,
@@ -79,7 +69,6 @@ export function ConversationTabContent() {
isServedActive,
isVSCodeActive,
isTerminalActive,
isPlannerActive,
]);
if (shouldShownAgentLoading) {
@@ -5,16 +5,12 @@ type ConversationTabNavProps = {
icon: ComponentType<{ className: string }>;
onClick(): void;
isActive?: boolean;
label?: string;
className?: string;
};
export function ConversationTabNav({
icon: Icon,
onClick,
isActive,
label,
className,
}: ConversationTabNavProps) {
return (
<button
@@ -23,21 +19,18 @@ export function ConversationTabNav({
onClick();
}}
className={cn(
"flex items-center gap-2 rounded-md cursor-pointer",
"pl-1.5 pr-2 py-1",
"p-1 rounded-md cursor-pointer",
"text-[#9299AA] bg-[#0D0F11]",
isActive && "bg-[#25272D] text-white",
isActive
? "hover:text-white hover:bg-tertiary"
: "hover:text-white hover:bg-[#0D0F11]",
isActive ? "focus-within:text-white" : "focus-within:text-[#9299AA]",
className,
isActive
? "focus-within:text-white focus-within:bg-tertiary"
: "focus-within:text-[#9299AA] focus-within:bg-[#0D0F11]",
)}
>
<Icon className={cn("w-5 h-5 text-inherit flex-shrink-0")} />
{isActive && label && (
<span className="text-sm font-medium whitespace-nowrap">{label}</span>
)}
<Icon className={cn("w-5 h-5 text-inherit")} />
</button>
);
}
@@ -1,116 +0,0 @@
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "@uidotdev/usehooks";
import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../../context-menu/context-menu-list-item";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { I18nKey } from "#/i18n/declaration";
import TerminalIcon from "#/icons/terminal.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
import ServerIcon from "#/icons/server.svg?react";
import GitChanges from "#/icons/git_changes.svg?react";
import VSCodeIcon from "#/icons/vscode.svg?react";
import PillIcon from "#/icons/pill.svg?react";
import PillFillIcon from "#/icons/pill-fill.svg?react";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
interface ConversationTabsContextMenuProps {
isOpen: boolean;
onClose: () => void;
}
export function ConversationTabsContextMenu({
isOpen,
onClose,
}: ConversationTabsContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
const [unpinnedTabs, setUnpinnedTabs] = useLocalStorage<string[]>(
"conversation-unpinned-tabs",
[],
);
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const tabConfig = [
{
tab: "editor",
icon: GitChanges,
i18nKey: I18nKey.COMMON$CHANGES,
},
{
tab: "vscode",
icon: VSCodeIcon,
i18nKey: I18nKey.COMMON$CODE,
},
{
tab: "terminal",
icon: TerminalIcon,
i18nKey: I18nKey.COMMON$TERMINAL,
},
{
tab: "served",
icon: ServerIcon,
i18nKey: I18nKey.COMMON$APP,
},
{
tab: "browser",
icon: GlobeIcon,
i18nKey: I18nKey.COMMON$BROWSER,
},
];
if (shouldUsePlanningAgent) {
tabConfig.unshift({
tab: "planner",
icon: LessonPlanIcon,
i18nKey: I18nKey.COMMON$PLANNER,
});
}
if (!isOpen) return null;
const handleTabClick = (tab: string) => {
const tabString = tab;
if (unpinnedTabs.includes(tabString)) {
// Tab is unpinned, pin it (remove from unpinned list)
setUnpinnedTabs(
unpinnedTabs.filter((unpinnedTab) => unpinnedTab !== tabString),
);
} else {
// Tab is pinned, unpin it (add to unpinned list)
setUnpinnedTabs([...unpinnedTabs, tabString]);
}
};
const isTabPinned = (tab: string) => !unpinnedTabs.includes(tab as string);
return (
<ContextMenu
testId="conversation-tabs-context-menu"
ref={ref}
alignment="right"
position="bottom"
className="mt-2 w-fit z-[9999]"
>
{tabConfig.map(({ tab, icon: Icon, i18nKey }) => {
const pinned = isTabPinned(tab);
return (
<ContextMenuListItem
key={tab}
onClick={() => handleTabClick(tab)}
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
>
<Icon className="w-4 h-4" />
<span className="text-white text-sm">{t(i18nKey)}</span>
{pinned ? (
<PillFillIcon className="w-7 h-7 ml-auto flex-shrink-0 text-white -mr-[5px]" />
) : (
<PillIcon className="w-4.5 h-4.5 ml-auto flex-shrink-0 text-white" />
)}
</ContextMenuListItem>
);
})}
</ContextMenu>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "@uidotdev/usehooks";
import TerminalIcon from "#/icons/terminal.svg?react";
@@ -6,8 +6,6 @@ import GlobeIcon from "#/icons/globe.svg?react";
import ServerIcon from "#/icons/server.svg?react";
import GitChanges from "#/icons/git_changes.svg?react";
import VSCodeIcon from "#/icons/vscode.svg?react";
import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { cn } from "#/utils/utils";
import { ConversationTabNav } from "./conversation-tab-nav";
import { ChatActionTooltip } from "../../chat/chat-action-tooltip";
@@ -17,8 +15,6 @@ import {
useConversationStore,
type ConversationTab,
} from "#/state/conversation-store";
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
export function ConversationTabs() {
const {
@@ -28,8 +24,6 @@ export function ConversationTabs() {
setSelectedTab,
} = useConversationStore();
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Persist selectedTab and isRightPanelShown in localStorage
const [persistedSelectedTab, setPersistedSelectedTab] =
useLocalStorage<ConversationTab | null>(
@@ -40,13 +34,6 @@ export function ConversationTabs() {
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
useLocalStorage<boolean>("conversation-right-panel-shown", true);
const [persistedUnpinnedTabs] = useLocalStorage<string[]>(
"conversation-unpinned-tabs",
[],
);
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const onTabChange = (value: ConversationTab | null) => {
setSelectedTab(value);
// Persist the selected tab to localStorage
@@ -100,70 +87,42 @@ export function ConversationTabs() {
const tabs = [
{
tabValue: "editor",
isActive: isTabActive("editor"),
icon: GitChanges,
onClick: () => onTabSelected("editor"),
tooltipContent: t(I18nKey.COMMON$CHANGES),
tooltipAriaLabel: t(I18nKey.COMMON$CHANGES),
label: t(I18nKey.COMMON$CHANGES),
},
{
tabValue: "vscode",
isActive: isTabActive("vscode"),
icon: VSCodeIcon,
onClick: () => onTabSelected("vscode"),
tooltipContent: <VSCodeTooltipContent />,
tooltipAriaLabel: t(I18nKey.COMMON$CODE),
label: t(I18nKey.COMMON$CODE),
},
{
tabValue: "terminal",
isActive: isTabActive("terminal"),
icon: TerminalIcon,
onClick: () => onTabSelected("terminal"),
tooltipContent: t(I18nKey.COMMON$TERMINAL),
tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL),
label: t(I18nKey.COMMON$TERMINAL),
className: "pl-2",
},
{
tabValue: "served",
isActive: isTabActive("served"),
icon: ServerIcon,
onClick: () => onTabSelected("served"),
tooltipContent: t(I18nKey.COMMON$APP),
tooltipAriaLabel: t(I18nKey.COMMON$APP),
label: t(I18nKey.COMMON$APP),
},
{
tabValue: "browser",
isActive: isTabActive("browser"),
icon: GlobeIcon,
onClick: () => onTabSelected("browser"),
tooltipContent: t(I18nKey.COMMON$BROWSER),
tooltipAriaLabel: t(I18nKey.COMMON$BROWSER),
label: t(I18nKey.COMMON$BROWSER),
},
];
if (shouldUsePlanningAgent) {
tabs.unshift({
tabValue: "planner",
isActive: isTabActive("planner"),
icon: LessonPlanIcon,
onClick: () => onTabSelected("planner"),
tooltipContent: t(I18nKey.COMMON$PLANNER),
tooltipAriaLabel: t(I18nKey.COMMON$PLANNER),
label: t(I18nKey.COMMON$PLANNER),
});
}
// Filter out unpinned tabs
const visibleTabs = tabs.filter(
(tab) => !persistedUnpinnedTabs.includes(tab.tabValue),
);
return (
<div
className={cn(
@@ -171,17 +130,9 @@ export function ConversationTabs() {
"flex flex-row justify-start lg:justify-end items-center gap-4.5",
)}
>
{visibleTabs.map(
{tabs.map(
(
{
icon,
onClick,
isActive,
tooltipContent,
tooltipAriaLabel,
label,
className,
},
{ icon, onClick, isActive, tooltipContent, tooltipAriaLabel },
index,
) => (
<ChatActionTooltip
@@ -193,29 +144,10 @@ export function ConversationTabs() {
icon={icon}
onClick={onClick}
isActive={isActive}
label={label}
className={className}
/>
</ChatActionTooltip>
),
)}
<div className="relative">
<button
type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={cn(
"p-1 pl-0 rounded-md cursor-pointer",
"text-[#9299AA] bg-[#0D0F11]",
)}
aria-label={t(I18nKey.COMMON$MORE_OPTIONS)}
>
<ThreeDotsVerticalIcon className={cn("w-5 h-5 text-inherit")} />
</button>
<ConversationTabsContextMenu
isOpen={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
/>
</div>
</div>
);
}
@@ -131,7 +131,7 @@ export function RepositorySelectionForm({
onBranchSelect={handleBranchSelection}
defaultBranch={defaultBranch}
placeholder="Select branch..."
className="max-w-full"
className="max-w-[500px]"
disabled={!selectedRepository || isLoadingSettings}
/>
);
@@ -1,80 +0,0 @@
import React from "react";
import { ExtraProps } from "react-markdown";
// Custom component to render <h1> in markdown
export function h1({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h1 className="text-[32px] text-white font-bold leading-8 mb-4 mt-6 first:mt-0">
{children}
</h1>
);
}
// Custom component to render <h2> in markdown
export function h2({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h2 className="text-xl font-semibold leading-6 -tracking-[0.02em] text-white mb-3 mt-5 first:mt-0">
{children}
</h2>
);
}
// Custom component to render <h3> in markdown
export function h3({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h3 className="text-lg font-semibold text-white mb-2 mt-4 first:mt-0">
{children}
</h3>
);
}
// Custom component to render <h4> in markdown
export function h4({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h4 className="text-base font-semibold text-white mb-2 mt-4 first:mt-0">
{children}
</h4>
);
}
// Custom component to render <h5> in markdown
export function h5({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h5 className="text-sm font-semibold text-white mb-2 mt-3 first:mt-0">
{children}
</h5>
);
}
// Custom component to render <h6> in markdown
export function h6({
children,
}: React.ClassAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLHeadingElement> &
ExtraProps) {
return (
<h6 className="text-sm font-medium text-gray-300 mb-2 mt-3 first:mt-0">
{children}
</h6>
);
}
@@ -11,7 +11,6 @@ import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
import { useAuthUrl } from "#/hooks/use-auth-url";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
interface AuthModalProps {
githubAuthUrl: string | null;
@@ -27,7 +26,6 @@ export function AuthModal({
providersConfigured,
}: AuthModalProps) {
const { t } = useTranslation();
const { trackLoginButtonClick } = useTracking();
const gitlabAuthUrl = useAuthUrl({
appMode: appMode || null,
@@ -49,7 +47,6 @@ export function AuthModal({
const handleGitHubAuth = () => {
if (githubAuthUrl) {
trackLoginButtonClick({ provider: "github" });
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = githubAuthUrl;
}
@@ -57,7 +54,6 @@ export function AuthModal({
const handleGitLabAuth = () => {
if (gitlabAuthUrl) {
trackLoginButtonClick({ provider: "gitlab" });
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = gitlabAuthUrl;
}
@@ -65,7 +61,6 @@ export function AuthModal({
const handleBitbucketAuth = () => {
if (bitbucketAuthUrl) {
trackLoginButtonClick({ provider: "bitbucket" });
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = bitbucketAuthUrl;
}
@@ -73,7 +68,6 @@ export function AuthModal({
const handleEnterpriseSsoAuth = () => {
if (enterpriseSsoUrl) {
trackLoginButtonClick({ provider: "enterprise_sso" });
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = enterpriseSsoUrl;
}
@@ -1,7 +1,7 @@
import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import React from "react";
import { usePostHog } from "posthog-js/react";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { DangerModal } from "../confirmation-modals/danger-modal";
@@ -22,7 +22,6 @@ interface SettingsFormProps {
}
export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
const posthog = usePostHog();
const { mutate: saveUserSettings } = useSaveSettings();
const location = useLocation();
@@ -49,10 +49,6 @@ const getExecuteBashObservationContent = (
let { output } = observation;
if (!output) {
output = "";
}
if (output.length > MAX_CONTENT_LENGTH) {
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
}
@@ -140,7 +136,6 @@ const getTaskTrackerObservationContent = (
if (
"content" in observation &&
observation.content &&
typeof observation.content === "string" &&
observation.content.trim()
) {
content += `\n\n**Result:** ${observation.content.trim()}`;
+3 -11
View File
@@ -1,7 +1,6 @@
import React from "react";
import { io, Socket } from "socket.io-client";
import { useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import EventLogger from "#/utils/event-logger";
import { handleAssistantMessage } from "#/services/actions";
import { showChatError, trackError } from "#/utils/error-handler";
@@ -101,10 +100,7 @@ interface ErrorArgData {
msg_id: string;
}
export function updateStatusWhenErrorMessagePresent(
data: ErrorArg | unknown,
posthog?: ReturnType<typeof usePostHog>,
) {
export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
const isObject = (val: unknown): val is object =>
!!val && typeof val === "object";
const isString = (val: unknown): val is string => typeof val === "string";
@@ -127,7 +123,6 @@ export function updateStatusWhenErrorMessagePresent(
source: "websocket",
metadata,
msgId,
posthog,
});
}
}
@@ -136,7 +131,6 @@ export function WsClientProvider({
conversationId,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const posthog = usePostHog();
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
const { addEvent, clearEvents } = useEventStore();
@@ -184,7 +178,6 @@ export function WsClientProvider({
message: errorMessage,
source: "chat",
metadata: { msgId: event.id },
posthog,
});
setErrorMessage(errorMessage);
@@ -200,7 +193,6 @@ export function WsClientProvider({
message: event.message,
source: "chat",
metadata: { msgId: event.id },
posthog,
});
} else {
removeErrorMessage();
@@ -268,14 +260,14 @@ export function WsClientProvider({
sio.io.opts.query = sio.io.opts.query || {};
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data, posthog);
updateStatusWhenErrorMessagePresent(data);
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
}
function handleError(data: unknown) {
// set status
setWebSocketStatus("DISCONNECTED");
updateStatusWhenErrorMessagePresent(data, posthog);
updateStatusWhenErrorMessagePresent(data);
setErrorMessage(
hasValidMessageProperty(data)
@@ -28,7 +28,7 @@ import {
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
import { buildWebSocketUrl } from "#/utils/websocket-url";
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
import EventService from "#/api/event-service/event-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
// eslint-disable-next-line @typescript-eslint/naming-convention
export type V1_WebSocketConnectionState =
@@ -67,7 +67,7 @@ export function ConversationWebSocketProvider({
const { addEvent } = useEventStore();
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
const { setExecutionStatus } = useV1ConversationStateStore();
const { setAgentStatus } = useV1ConversationStateStore();
const { appendInput, appendOutput } = useCommandStore();
// History loading state
@@ -154,10 +154,10 @@ export function ConversationWebSocketProvider({
// TODO: Tests
if (isConversationStateUpdateEvent(event)) {
if (isFullStateConversationStateUpdateEvent(event)) {
setExecutionStatus(event.value.execution_status);
setAgentStatus(event.value.agent_status);
}
if (isAgentStatusConversationStateUpdateEvent(event)) {
setExecutionStatus(event.value);
setAgentStatus(event.value);
}
}
@@ -184,7 +184,7 @@ export function ConversationWebSocketProvider({
removeOptimisticUserMessage,
queryClient,
conversationId,
setExecutionStatus,
setAgentStatus,
appendInput,
appendOutput,
],
@@ -211,7 +211,8 @@ export function ConversationWebSocketProvider({
// Fetch expected event count for history loading detection
if (conversationId) {
try {
const count = await EventService.getEventCount(conversationId);
const count =
await V1ConversationService.getEventCount(conversationId);
setExpectedEventCount(count);
// If no events expected, mark as loaded immediately
+13 -21
View File
@@ -8,18 +8,17 @@
import { HydratedRouter } from "react-router/dom";
import React, { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { PostHogProvider } from "posthog-js/react";
import posthog from "posthog-js";
import "./i18n";
import { QueryClientProvider } from "@tanstack/react-query";
import OptionService from "./api/option-service/option-service.api";
import { displayErrorToast } from "./utils/custom-toast-handlers";
import { queryClient } from "./query-client-config";
function PostHogWrapper({ children }: { children: React.ReactNode }) {
function PosthogInit() {
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
null,
);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
(async () => {
@@ -28,27 +27,20 @@ function PostHogWrapper({ children }: { children: React.ReactNode }) {
setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
} catch {
displayErrorToast("Error fetching PostHog client key");
} finally {
setIsLoading(false);
}
})();
}, []);
if (isLoading || !posthogClientKey) {
return children;
}
return (
<PostHogProvider
apiKey={posthogClientKey}
options={{
React.useEffect(() => {
if (posthogClientKey) {
posthog.init(posthogClientKey, {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only",
}}
>
{children}
</PostHogProvider>
);
});
}
}, [posthogClientKey]);
return null;
}
async function prepareApp() {
@@ -70,10 +62,10 @@ prepareApp().then(() =>
document,
<StrictMode>
<QueryClientProvider client={queryClient}>
<PostHogWrapper>
<HydratedRouter />
</PostHogWrapper>
<HydratedRouter />
<PosthogInit />
</QueryClientProvider>
<div id="modal-portal-exit" />
</StrictMode>,
);
}),
@@ -2,7 +2,6 @@ import { QueryClient } from "@tanstack/react-query";
import { Provider } from "#/types/settings";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
/**
* Gets the conversation version from the cache
@@ -49,7 +48,7 @@ const fetchV1ConversationData = async (
*/
export const pauseV1ConversationSandbox = async (conversationId: string) => {
const { sandboxId } = await fetchV1ConversationData(conversationId);
return SandboxService.pauseSandbox(sandboxId);
return V1ConversationService.pauseSandbox(sandboxId);
};
/**
@@ -76,7 +75,7 @@ export const stopV0Conversation = async (conversationId: string) =>
*/
export const resumeV1ConversationSandbox = async (conversationId: string) => {
const { sandboxId } = await fetchV1ConversationData(conversationId);
return SandboxService.resumeSandbox(sandboxId);
return V1ConversationService.resumeSandbox(sandboxId);
};
/**
@@ -1,11 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { Provider, ProviderToken } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
export const useAddGitProviders = () => {
const queryClient = useQueryClient();
const { trackGitProviderConnected } = useTracking();
return useMutation({
mutationFn: ({
@@ -13,18 +11,7 @@ export const useAddGitProviders = () => {
}: {
providers: Record<Provider, ProviderToken>;
}) => SecretsService.addGitProvider(providers),
onSuccess: async (_, { providers }) => {
// Track which providers were connected (filter out empty tokens)
const connectedProviders = Object.entries(providers)
.filter(([, value]) => value.token && value.token.trim() !== "")
.map(([key]) => key);
if (connectedProviders.length > 0) {
trackGitProviderConnected({
providers: connectedProviders,
});
}
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
},
meta: {
@@ -1,11 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { SuggestedTask } from "#/utils/types";
import { Provider } from "#/types/settings";
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
import { useTracking } from "#/hooks/use-tracking";
interface CreateConversationVariables {
query?: string;
@@ -31,7 +31,6 @@ interface CreateConversationResponse extends Partial<Conversation> {
export const useCreateConversation = () => {
const queryClient = useQueryClient();
const { trackConversationCreated } = useTracking();
return useMutation({
mutationKey: ["create-conversation"],
@@ -87,11 +86,12 @@ export const useCreateConversation = () => {
is_v1: false,
};
},
onSuccess: async (_, { repository }) => {
trackConversationCreated({
hasRepository: !!repository,
onSuccess: async (_, { query, repository }) => {
posthog.capture("initial_query_submitted", {
entry_point: "task_form",
query_character_length: query?.length,
has_repository: !!repository,
});
queryClient.removeQueries({
queryKey: ["user", "conversations"],
});
+1 -2
View File
@@ -1,11 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import posthog from "posthog-js";
import AuthService from "#/api/auth-service/auth-service.api";
import { useConfig } from "../query/use-config";
import { clearLoginData } from "#/utils/local-storage";
export const useLogout = () => {
const posthog = usePostHog();
const queryClient = useQueryClient();
const { data: config } = useConfig();
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import posthog from "posthog-js";
import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/settings-service/settings-service.api";
import { PostSettings } from "#/types/settings";
@@ -41,7 +41,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
};
export const useSaveSettings = () => {
const posthog = usePostHog();
const queryClient = useQueryClient();
const { data: currentSettings } = useSettings();
@@ -4,7 +4,6 @@ import ConversationService from "#/api/conversation-service/conversation-service
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
export interface BatchFeedbackData {
exists: boolean;
@@ -26,20 +25,13 @@ export const getFeedbackExistsQueryKey = (
export const useBatchFeedback = () => {
const { conversationId } = useConversationId();
const { data: config } = useConfig();
const { data: conversation } = useActiveConversation();
const queryClient = useQueryClient();
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
const query = useQuery({
queryKey: getFeedbackQueryKey(conversationId),
queryFn: () => ConversationService.getBatchFeedback(conversationId!),
enabled:
runtimeIsReady &&
!!conversationId &&
config?.APP_MODE === "saas" &&
!isV1Conversation,
enabled: runtimeIsReady && !!conversationId && config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
export const useBatchSandboxes = (ids: string[]) =>
useQuery({
queryKey: ["sandboxes", "batch", ids],
queryFn: () => SandboxService.batchGetSandboxes(ids),
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
enabled: ids.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
@@ -1,7 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { BatchFeedbackData, getFeedbackQueryKey } from "./use-batch-feedback";
export type FeedbackData = BatchFeedbackData;
@@ -10,9 +9,6 @@ export const useFeedbackExists = (eventId?: number) => {
const queryClient = useQueryClient();
const { conversationId } = useConversationId();
const { data: config } = useConfig();
const { data: conversation } = useActiveConversation();
const isV1Conversation = conversation?.conversation_version === "V1";
return useQuery<FeedbackData>({
queryKey: [...getFeedbackQueryKey(conversationId), eventId],
@@ -26,11 +22,7 @@ export const useFeedbackExists = (eventId?: number) => {
return batchData?.[eventId.toString()] ?? { exists: false };
},
enabled:
!!eventId &&
!!conversationId &&
config?.APP_MODE === "saas" &&
!isV1Conversation,
enabled: !!eventId && !!conversationId && config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
+1 -2
View File
@@ -1,12 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { usePostHog } from "posthog-js/react";
import posthog from "posthog-js";
import { useConfig } from "./use-config";
import UserService from "#/api/user-service/user-service.api";
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
export const useGitUser = () => {
const posthog = usePostHog();
const { data: config } = useConfig();
// Use the shared hook to determine if we should fetch user data
+8
View File
@@ -1,4 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import SettingsService from "#/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
@@ -59,6 +61,12 @@ export const useSettings = () => {
},
});
React.useEffect(() => {
if (query.isFetched && query.data?.LLM_API_KEY_SET) {
posthog.capture("user_activated");
}
}, [query.data?.LLM_API_KEY_SET, query.isFetched]);
// We want to return the defaults if the settings aren't found so the user can still see the
// options to make their initial save. We don't set the defaults in `initialData` above because
// that would prepopulate the data to the cache and mess with expectations. Read more:
+10 -12
View File
@@ -3,30 +3,30 @@ import { useAgentStore } from "#/stores/agent-store";
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { AgentState } from "#/types/agent-state";
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
import { V1AgentStatus } from "#/types/v1/core/base/common";
/**
* Maps V1 agent status to V0 AgentState
*/
function mapV1StatusToV0State(status: V1ExecutionStatus | null): AgentState {
function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
if (!status) {
return AgentState.LOADING;
}
switch (status) {
case V1ExecutionStatus.IDLE:
case V1AgentStatus.IDLE:
return AgentState.AWAITING_USER_INPUT;
case V1ExecutionStatus.RUNNING:
case V1AgentStatus.RUNNING:
return AgentState.RUNNING;
case V1ExecutionStatus.PAUSED:
case V1AgentStatus.PAUSED:
return AgentState.PAUSED;
case V1ExecutionStatus.WAITING_FOR_CONFIRMATION:
case V1AgentStatus.WAITING_FOR_CONFIRMATION:
return AgentState.AWAITING_USER_CONFIRMATION;
case V1ExecutionStatus.FINISHED:
case V1AgentStatus.FINISHED:
return AgentState.FINISHED;
case V1ExecutionStatus.ERROR:
case V1AgentStatus.ERROR:
return AgentState.ERROR;
case V1ExecutionStatus.STUCK:
case V1AgentStatus.STUCK:
return AgentState.ERROR; // Map STUCK to ERROR for now
default:
return AgentState.LOADING;
@@ -41,9 +41,7 @@ function mapV1StatusToV0State(status: V1ExecutionStatus | null): AgentState {
export function useAgentState() {
const { data: conversation } = useActiveConversation();
const v0State = useAgentStore((state) => state.curAgentState);
const v1Status = useV1ConversationStateStore(
(state) => state.execution_status,
);
const v1Status = useV1ConversationStateStore((state) => state.agent_status);
const isV1Conversation = conversation?.conversation_version === "V1";
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import React from "react";
import { usePostHog } from "posthog-js/react";
import posthog from "posthog-js";
import { useParams, useNavigate } from "react-router";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import useMetricsStore from "#/stores/metrics-store";
@@ -29,7 +29,6 @@ export function useConversationNameContextMenu({
showOptions = false,
onContextMenuToggle,
}: UseConversationNameContextMenuProps) {
const posthog = usePostHog();
const { t } = useTranslation();
const { conversationId: currentConversationId } = useParams();
const navigate = useNavigate();
@@ -1,10 +1,8 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { useSaveSettings } from "./mutation/use-save-settings";
export const useMigrateUserConsent = () => {
const posthog = usePostHog();
const { mutate: saveUserSettings } = useSaveSettings();
/**
@@ -17,11 +15,11 @@ export const useMigrateUserConsent = () => {
if (userAnalyticsConsent) {
args?.handleAnalyticsWasPresentInLocalStorage();
saveUserSettings(
await saveUserSettings(
{ user_consents_to_analytics: userAnalyticsConsent === "true" },
{
onSuccess: () => {
handleCaptureConsent(posthog, userAnalyticsConsent === "true");
handleCaptureConsent(userAnalyticsConsent === "true");
},
},
);
@@ -29,7 +27,7 @@ export const useMigrateUserConsent = () => {
localStorage.removeItem("analytics-consent");
}
},
[posthog, saveUserSettings],
[],
);
return { migrateUserConsent };
+8 -14
View File
@@ -1,10 +1,4 @@
import {
RefObject,
useState,
useCallback,
useRef,
useLayoutEffect,
} from "react";
import { RefObject, useEffect, useState, useCallback, useRef } from "react";
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
// Track whether we should auto-scroll to the bottom when content changes
@@ -71,20 +65,20 @@ export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
}, [scrollRef]);
// Auto-scroll effect that runs when content changes
// Use useLayoutEffect to scroll after DOM updates but before paint
useLayoutEffect(() => {
useEffect(() => {
// Only auto-scroll if autoscroll is enabled
if (autoscroll) {
const dom = scrollRef.current;
if (dom) {
// Scroll to bottom - this will trigger on any DOM change
dom.scrollTo({
top: dom.scrollHeight,
behavior: "smooth",
requestAnimationFrame(() => {
dom.scrollTo({
top: dom.scrollHeight,
behavior: "smooth",
});
});
}
}
}); // No dependency array - runs after every render to follow new content
});
return {
scrollRef,
+1 -1
View File
@@ -22,7 +22,7 @@ const renderCommand = (
return;
}
const trimmedContent = (content || "").replaceAll("\n", "\r\n").trim();
const trimmedContent = content.replaceAll("\n", "\r\n").trim();
// Only write if there's actual content to avoid empty newlines
if (trimmedContent) {
terminal.writeln(parseTerminalOutput(trimmedContent));

Some files were not shown because too many files have changed in this diff Show More