Compare commits

..

45 Commits

Author SHA1 Message Date
enyst e754edb798 Merge main into openhands/llm-extra-headers: resolve conflict in LLM to support both completion_kwargs and env-driven extra_headers (env only fills if not provided).\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-11-16 01:07:36 +00:00
Hiep Le d6fab190bf feat(frontend): integrate with the API to create a sub-conversation for the planning agent (#11730) 2025-11-15 09:43:21 +07:00
Hiep Le 833aae1833 feat(backend): exclude sub-conversations when searching for conversations (#11733) 2025-11-15 00:21:27 +07:00
Tim O'Farrell 2841e35f24 Do not get live status updates when they are not required (#11727)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-14 07:55:43 -07:00
Tim O'Farrell 8115d82f96 feat: add created_at__gte filter to search_app_conversation_start_tasks (#11740)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-14 07:08:34 -07:00
Hiep Le 7263657937 feat(backend): include sub-conversation ids when fetching conversation details (#11734) 2025-11-14 11:34:30 +07:00
jpelletier1 34fcc50350 Update to include llms.txt (#11737) 2025-11-13 21:42:50 +00:00
jpelletier1 24a9758434 Adding an Agent Builder Skill/Microagent (#11720) 2025-11-13 16:10:00 -05:00
Tim O'Farrell f24d2a61e6 Fix for wrong column name (#11735) 2025-11-13 17:55:23 +00:00
Hiep Le e3d0380c2e feat(frontend): add support for the shift + tab shortcut to cycle through conversation modes (#11731) 2025-11-14 00:10:25 +07:00
Hiep Le 8c3f93ddc4 feat(frontend): set descriptive text for all options in the change agent button (#11732) 2025-11-14 00:10:15 +07:00
Hiep Le bc86796a67 feat(backend): enable sub-conversation creation using a different agent (#11715) 2025-11-13 23:06:44 +07:00
sp.wack d5b2d2ebc5 fix(frontend): Sync client PostHog opt-in status with server setting (#11728) 2025-11-13 13:22:05 +00:00
Rohit Malhotra b605c96796 Hotfix: rm max condenser size override (#11713) 2025-11-12 20:13:16 -05:00
sp.wack 8192184d3e chore(backend): Add better PostHog tracking (#11655)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-12 16:47:21 +00:00
Hiep Le 8e75f25108 feat(frontend): implement new task tracker interface (#11692) 2025-11-12 22:59:45 +07:00
Neha Prasad 73fe865c7e feat: queue chat messages during runtime connection (#11687)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-11-12 13:20:09 +00:00
Rohit Malhotra 95a44f4248 CLI release 1.0.7 (#11712) 2025-11-11 16:46:30 -05:00
Rohit Malhotra 0a6b76ca2d CLI: bump agent-sdk (#11710)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-11 20:29:18 +00:00
Tim O'Farrell 8b6521de62 Fix for issue where conversation does not start (#11695) 2025-11-11 20:23:18 +00:00
mamoodi 11636edf15 Release 0.62.0 (#11706) 2025-11-11 14:57:13 -05:00
Hiep Le 915c180ba7 feat(frontend): disable change agent button while agent is running (#11691) 2025-11-12 00:46:12 +07:00
sp.wack cdd8aace86 refactor(frontend): migrate from direct posthog imports to usePostHog hook (#11703) 2025-11-11 15:48:56 +00:00
Hiep Le a2c312d108 feat(frontend): add plan preview component (#11676) 2025-11-11 21:59:23 +07:00
sp.wack 5ad3572810 chore(frontend): Remove user_activated PostHog capture event (#11704) 2025-11-11 14:35:04 +00:00
John Eismeier 967e9e1891 Propose fix some typos and ignore emacs backup files (#11701)
Signed-off-by: John E <jeis4wpi@outlook.com>
2025-11-11 09:20:42 -05:00
sp.wack f8a41d3ffe fix(frontend): Properly reflect default user analytics setting (#11702) 2025-11-11 18:19:37 +04:00
John-Mason P. Shackelford 6e9e7547e5 Add Documentation link to profile context menu (#11583)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-11 09:16:32 -05:00
Hiep Le 9b4f1c365b feat(frontend): add change agent button (#11675) 2025-11-11 20:28:48 +07:00
Engel Nyst f4dcc136d0 tests: remove Windows-only tests and clean up Windows conditionals (#11697) 2025-11-10 21:34:55 +01:00
Rohit Malhotra 36a8cbbfe4 Add GitHub CI workflow to check package versions (#11637)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-10 19:39:49 +00:00
Engel Nyst 83a3c2c5bf Add invisible AI-only guidance to Checklist: humans must fill (#11688) 2025-11-10 18:13:18 +00:00
Engel Nyst 63c9e6403f ci: remove flaky Windows Python tests workflow (#11694) 2025-11-10 12:43:48 -05:00
Hiep Le bff734070c feat(frontend): update data-placeholder when switching to plan mode (#11674) 2025-11-10 21:30:29 +04:00
mamoodi 5db6bffaf6 Add some notes to the README for things that are not officially suppo… (#11663) 2025-11-10 20:16:41 +04:00
Engel Nyst 14807ed273 ci: remove outdated integration runner (#11653) 2025-11-10 15:51:40 +01:00
Rohit Malhotra e0d26c1f4e CLI: custom visualizer (#11677) 2025-11-07 19:45:01 +00:00
Rohit Malhotra 27c8c330f4 CLI release 1.0.6 (#11672) 2025-11-07 14:10:04 -05:00
sp.wack 0c927b19d2 fix(frontend): agent loading condition update logic (#11673) 2025-11-07 18:04:27 +00:00
Hiep Le a660321d55 feat(frontend): display plan content within the planner tab (#11658) 2025-11-08 00:54:15 +07:00
Tim O'Farrell 0e94833d5b Now removing V1 sandboxes in the V0 endpoint (#11671) 2025-11-07 10:51:46 -07:00
Engel Nyst c4ff3d6483 Merge branch 'main' into openhands/llm-extra-headers 2025-10-15 08:23:21 +02:00
enyst bace2ef8a1 refactor(llm): remove partial_kwargs and factor extra headers parsing into helper
- Merge extra headers directly into kwargs across sync, async, streaming paths
- Add LLM._get_extra_headers() to de-duplicate env parsing
- Replace inline parsing in AsyncLLM and StreamingLLM
- Run pre-commit and unit tests

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-14 07:34:13 +00:00
enyst 4e7846928b refactor(llm): simplify extra_headers plumbing\n\n- Remove partial_kwargs/_partial_kwargs indirection\n- Pass extra_headers directly via kwargs to partial() calls\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-10-14 07:02:25 +00:00
enyst 62dbb20846 feat(llm): support extra headers via env var LLM_EXTRA_HEADERS for LiteLLM completion and acompletion (async/streaming); add unit tests\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-10-14 03:58:42 +00:00
141 changed files with 4587 additions and 2408 deletions
+1
View File
@@ -0,0 +1 @@
This way of running OpenHands is not officially supported. It is maintained by the community.
+1
View File
@@ -13,6 +13,7 @@
- [ ] 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.
@@ -0,0 +1,65 @@
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
-199
View File
@@ -1,199 +0,0 @@
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 }})
+1 -31
View File
@@ -70,37 +70,7 @@ 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/runtime//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
+3
View File
@@ -185,6 +185,9 @@ cython_debug/
.repomix
repomix-output.txt
# Emacs backup
*~
# evaluation
evaluation/evaluation_outputs
evaluation/outputs
+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 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.
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.
## 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.61-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -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.61-nikolaik
docker pull docker.openhands.dev/openhands/runtime:0.62-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.62-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.61
docker.openhands.dev/openhands/openhands:0.62
```
</details>
+1 -1
View File
@@ -1,7 +1,7 @@
# Develop in Docker
> [!WARNING]
> This is not officially supported and may not work.
> This way of running OpenHands is not officially supported. It is maintained by the community 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.61-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-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.61-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-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 -22
View File
@@ -5,12 +5,8 @@ from experiments.constants import (
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
)
from experiments.experiment_versions import (
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
@@ -31,10 +27,6 @@ class SaaSExperimentManager(ExperimentManager):
)
return agent
agent = handle_condenser_max_step_experiment__v1(
user_id, conversation_id, agent
)
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
@@ -60,20 +52,7 @@ class SaaSExperimentManager(ExperimentManager):
"""
logger.debug(
'experiment_manager:run_conversation_variant_test:started',
extra={'user_id': user_id},
)
# Skip all experiment processing if the experiment manager is disabled
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_conversation_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return conversation_settings
# Apply conversation-scoped experiments
conversation_settings = handle_condenser_max_step_experiment(
user_id, conversation_id, conversation_settings
extra={'user_id': user_id, 'conversation_id': conversation_id},
)
return conversation_settings
@@ -0,0 +1,41 @@
"""add parent_conversation_id to conversation_metadata
Revision ID: 081
Revises: 080
Create Date: 2025-11-06 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '081'
down_revision: Union[str, None] = '080'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
'conversation_metadata',
sa.Column('parent_conversation_id', sa.String(), nullable=True),
)
op.create_index(
op.f('ix_conversation_metadata_parent_conversation_id'),
'conversation_metadata',
['parent_conversation_id'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(
op.f('ix_conversation_metadata_parent_conversation_id'),
table_name='conversation_metadata',
)
op.drop_column('conversation_metadata', 'parent_conversation_id')
+19 -34
View File
@@ -5820,13 +5820,15 @@ 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.0.0a5"
version = "1.1.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = []
develop = false
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"},
]
[package.dependencies]
aiosqlite = ">=0.19"
@@ -5839,16 +5841,9 @@ uvicorn = ">=0.31.1"
websockets = ">=12"
wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-ai"
version = "0.0.0-post.5514+7c9e66194"
version = "0.0.0-post.5525+0b6631523"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5889,9 +5884,9 @@ memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-tools"}
openhands-agent-server = "1.1.0"
openhands-sdk = "1.1.0"
openhands-tools = "1.1.0"
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5947,13 +5942,15 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.0.0a5"
version = "1.1.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = []
develop = false
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"},
]
[package.dependencies]
fastmcp = ">=2.11.3"
@@ -5969,22 +5966,17 @@ websockets = ">=12"
[package.extras]
boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.0.0a5"
version = "1.1.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = []
develop = false
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"},
]
[package.dependencies]
bashlex = ">=0.18"
@@ -5996,13 +5988,6 @@ libtmux = ">=0.46.2"
openhands-sdk = "*"
pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-tools"
[[package]]
name = "openpyxl"
version = "3.1.5"
+7
View File
@@ -30,6 +30,7 @@ from openhands.server.services.conversation_service import create_provider_token
from openhands.server.shared import config
from openhands.server.user_auth import get_access_token
from openhands.server.user_auth.user_auth import get_user_auth
from openhands.utils.posthog_tracker import track_user_signup_completed
with warnings.catch_warnings():
warnings.simplefilter('ignore')
@@ -362,6 +363,12 @@ async def accept_tos(request: Request):
logger.info(f'User {user_id} accepted TOS')
# Track user signup completion in PostHog
track_user_signup_completed(
user_id=user_id,
signup_timestamp=user_settings.accepted_tos.isoformat(),
)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
)
+15
View File
@@ -28,6 +28,7 @@ from storage.subscription_access import SubscriptionAccess
from openhands.server.user_auth import get_user_id
from openhands.utils.http_session import httpx_verify_option
from openhands.utils.posthog_tracker import track_credits_purchased
stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing')
@@ -457,6 +458,20 @@ async def success_callback(session_id: str, request: Request):
)
session.commit()
# Track credits purchased in PostHog
try:
track_credits_purchased(
user_id=billing_session.user_id,
amount_usd=amount_subtotal / 100, # Convert cents to dollars
credits_added=add_credits,
stripe_session_id=session_id,
)
except Exception as e:
logger.warning(
f'Failed to track credits purchase: {e}',
extra={'user_id': billing_session.user_id, 'error': str(e)},
)
return RedirectResponse(
f'{request.base_url}settings/billing?checkout=success', status_code=302
)
@@ -60,6 +60,7 @@ class SaasConversationStore(ConversationStore):
kwargs.pop('reasoning_tokens', None)
kwargs.pop('context_window', None)
kwargs.pop('per_turn_token', None)
kwargs.pop('parent_conversation_id', None)
return ConversationMetadata(**kwargs)
@@ -92,11 +92,8 @@ def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
assert getattr(result, 'condenser', None) is None
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
mock_handle_condenser,
):
def test_run_agent_variant_tests_v1_noop_when_manager_disabled():
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
agent = make_agent()
conv_id = uuid4()
@@ -109,8 +106,6 @@ def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
# Same object returned (no copy)
assert result is agent
# Handler should not have been called
mock_handle_condenser.assert_not_called()
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
@@ -131,7 +126,3 @@ def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeyp
# Should be a different instance than the original (copied after handler runs)
assert result is not agent
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
# The condenser returned by the handler must be preserved after the system-prompt override copy
assert isinstance(result.condenser, LLMSummarizingCondenser)
assert result.condenser.max_size == 80
@@ -15,7 +15,7 @@ python evaluation/benchmarks/multi_swe_bench/scripts/data/data_change.py
## Docker image download
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).
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).
## 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).
## Runing evaluation
## Running evaluation
First, install [multi-swe-bench](https://github.com/multi-swe-bench/multi-swe-bench).
-69
View File
@@ -1,69 +0,0 @@
# 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
@@ -1,251 +0,0 @@
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)
)
@@ -1,62 +0,0 @@
#!/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
@@ -1,32 +0,0 @@
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
@@ -1,39 +0,0 @@
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}')
@@ -1,40 +0,0 @@
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)
@@ -1,43 +0,0 @@
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)
@@ -1,57 +0,0 @@
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}.',
)
@@ -1,145 +0,0 @@
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)}.',
)
@@ -1,58 +0,0 @@
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)}.',
)
@@ -1,73 +0,0 @@
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,9 +33,24 @@ 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,10 +8,11 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"TASK_TRACKING_OBSERVATION$TASK_LIST": "Task List",
"TASK_TRACKING_OBSERVATION$TASK_ID": "ID",
"TASK_TRACKING_OBSERVATION$TASK_NOTES": "Notes",
"TASK_TRACKING_OBSERVATION$RESULT": "Result",
TASK_TRACKING_OBSERVATION$TASK_LIST: "Task List",
TASK_TRACKING_OBSERVATION$TASK_ID: "ID",
TASK_TRACKING_OBSERVATION$TASK_NOTES: "Notes",
TASK_TRACKING_OBSERVATION$RESULT: "Result",
COMMON$TASKS: "Tasks",
};
return translations[key] || key;
},
@@ -61,19 +62,26 @@ describe("TaskTrackingObservationContent", () => {
it("renders task list when command is 'plan' and tasks exist", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
expect(screen.getByText("Task List (3 items)")).toBeInTheDocument();
expect(screen.getByText("Tasks")).toBeInTheDocument();
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
});
it("displays correct status icons and badges", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
const { container } = render(
<TaskTrackingObservationContent event={mockEvent} />,
);
// Check for status text (the icons are emojis)
expect(screen.getByText("todo")).toBeInTheDocument();
expect(screen.getByText("in progress")).toBeInTheDocument();
expect(screen.getByText("done")).toBeInTheDocument();
// Status is represented by icons, not text. Verify task items are rendered with their titles
// which indicates the status icons are present (status affects icon rendering)
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
// Verify task items are present (they contain the status icons)
const taskItems = container.querySelectorAll('[data-name="item"]');
expect(taskItems).toHaveLength(3);
});
it("displays task IDs and notes", () => {
@@ -84,14 +92,9 @@ describe("TaskTrackingObservationContent", () => {
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
expect(screen.getByText("Notes: Completed successfully")).toBeInTheDocument();
});
it("renders result section when content exists", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
expect(screen.getByText("Result")).toBeInTheDocument();
expect(screen.getByText("Task tracking operation completed successfully")).toBeInTheDocument();
expect(
screen.getByText("Notes: Completed successfully"),
).toBeInTheDocument();
});
it("does not render task list when command is not 'plan'", () => {
@@ -105,7 +108,7 @@ describe("TaskTrackingObservationContent", () => {
render(<TaskTrackingObservationContent event={eventWithoutPlan} />);
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
});
it("does not render task list when task list is empty", () => {
@@ -119,17 +122,6 @@ describe("TaskTrackingObservationContent", () => {
render(<TaskTrackingObservationContent event={eventWithEmptyTasks} />);
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
});
it("does not render result section when content is empty", () => {
const eventWithoutContent = {
...mockEvent,
content: "",
};
render(<TaskTrackingObservationContent event={eventWithoutContent} />);
expect(screen.queryByText("Result")).not.toBeInTheDocument();
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
});
});
@@ -30,7 +30,7 @@ describe("ImagePreview", () => {
expect(onRemoveMock).toHaveBeenCalledOnce();
});
it("shoud not display the close button when onRemove is not provided", () => {
it("should not display the close button when onRemove is not provided", () => {
render(<ImagePreview src="https://example.com/image.jpg" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
+12 -6
View File
@@ -1,10 +1,9 @@
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
@@ -44,9 +43,13 @@ const createWrapper = () => {
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return Wrapper;
};
describe("AcceptTOS", () => {
@@ -106,7 +109,10 @@ describe("AcceptTOS", () => {
// Wait for the mutation to complete
await new Promise(process.nextTick);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(
expect.anything(),
true,
);
expect(openHands.post).toHaveBeenCalledWith("/api/accept_tos", {
redirect_url: "/dashboard",
});
@@ -46,6 +46,21 @@ 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();
@@ -163,7 +178,10 @@ describe("Form submission", () => {
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(
expect.anything(),
true,
),
);
});
@@ -188,7 +206,10 @@ describe("Form submission", () => {
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(
expect.anything(),
false,
),
);
});
@@ -32,6 +32,7 @@ describe("Error Handler", () => {
const error = {
message: "Test error",
source: "test",
posthog,
};
trackError(error);
@@ -52,6 +53,7 @@ describe("Error Handler", () => {
extra: "info",
details: { foo: "bar" },
},
posthog,
};
trackError(error);
@@ -73,6 +75,7 @@ describe("Error Handler", () => {
const error = {
message: "Toast error",
source: "toast-test",
posthog,
};
showErrorToast(error);
@@ -94,6 +97,7 @@ describe("Error Handler", () => {
message: "Toast error",
source: "toast-test",
metadata: { context: "testing" },
posthog,
};
showErrorToast(error);
@@ -113,6 +117,7 @@ describe("Error Handler", () => {
message: "Agent error",
source: "agent-status",
metadata: { id: "error.agent" },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -127,6 +132,7 @@ describe("Error Handler", () => {
message: "Server error",
source: "server",
metadata: { error_code: 500, details: "Internal error" },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -145,6 +151,7 @@ describe("Error Handler", () => {
message: error.message,
source: "feedback",
metadata: { conversationId: "123", error },
posthog,
});
expect(posthog.captureException).toHaveBeenCalledWith(
@@ -164,6 +171,7 @@ 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(false);
handleCaptureConsent(posthog, false);
expect(optOutSpy).toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
});
it("should opt in to capturing if the user consents", () => {
handleCaptureConsent(true);
handleCaptureConsent(posthog, 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(true);
handleCaptureConsent(posthog, 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(false);
handleCaptureConsent(posthog, false);
expect(optOutSpy).not.toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
+32 -28
View File
@@ -1,17 +1,18 @@
{
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.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",
@@ -38,7 +39,7 @@
"jose": "^6.1.0",
"lucide-react": "^0.544.0",
"monaco-editor": "^0.53.0",
"posthog-js": "^1.268.8",
"posthog-js": "^1.290.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -3511,9 +3512,29 @@
"license": "MIT"
},
"node_modules/@posthog/core": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.2.tgz",
"integrity": "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg=="
"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
}
}
},
"node_modules/@react-aria/breadcrumbs": {
"version": "3.5.28",
@@ -8183,7 +8204,6 @@
"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",
@@ -8198,7 +8218,6 @@
"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"
@@ -11403,7 +11422,6 @@
"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": {
@@ -14073,7 +14091,6 @@
"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"
@@ -14264,27 +14281,16 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.268.8",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.268.8.tgz",
"integrity": "sha512-BJiKK4MlUvs7ybnQcy1KkwAz+SZkE/wRLotetIoank5kbqZs8FLbeyozFvmmgx4aoMmaVymYBSmYphYjYQeidw==",
"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",
"dependencies": {
"@posthog/core": "1.2.2",
"@posthog/core": "1.5.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": {
@@ -15547,7 +15553,6 @@
"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"
@@ -15560,7 +15565,6 @@
"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"
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.62.0",
"private": true,
"type": "module",
"engines": {
@@ -11,6 +11,7 @@
"@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",
@@ -37,7 +38,7 @@
"jose": "^6.1.0",
"lucide-react": "^0.544.0",
"monaco-editor": "^0.53.0",
"posthog-js": "^1.268.8",
"posthog-js": "^1.290.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -60,6 +60,8 @@ class V1ConversationService {
selected_branch?: string,
conversationInstructions?: string,
trigger?: ConversationTrigger,
parent_conversation_id?: string,
agent_type?: "default" | "plan",
): Promise<V1AppConversationStartTask> {
const body: V1AppConversationStartRequest = {
selected_repository: selectedRepository,
@@ -67,6 +69,8 @@ class V1ConversationService {
selected_branch,
title: conversationInstructions,
trigger,
parent_conversation_id: parent_conversation_id || null,
agent_type,
};
// Add initial message if provided
@@ -111,11 +115,11 @@ class V1ConversationService {
* Search for start tasks (ongoing tasks that haven't completed yet)
* Use this to find tasks that were started but the user navigated away
*
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
* Note: Backend supports filtering by limit and created_at__gte. To filter by repository/trigger,
* filter the results client-side after fetching.
*
* @param limit Maximum number of tasks to return (max 100)
* @returns Array of start tasks
* @returns Array of start tasks from the last 20 minutes
*/
static async searchStartTasks(
limit: number = 100,
@@ -123,6 +127,10 @@ class V1ConversationService {
const params = new URLSearchParams();
params.append("limit", limit.toString());
// Only get tasks from the last 20 minutes
const twentyMinutesAgo = new Date(Date.now() - 20 * 60 * 1000);
params.append("created_at__gte", twentyMinutesAgo.toISOString());
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
);
@@ -30,6 +30,8 @@ export interface V1AppConversationStartRequest {
title?: string | null;
trigger?: ConversationTrigger | null;
pr_number?: number[];
parent_conversation_id?: string | null;
agent_type?: "default" | "plan";
}
export type V1AppConversationStartTaskStatus =
+1
View File
@@ -77,6 +77,7 @@ export interface Conversation {
session_api_key: string | null;
pr_number?: number[] | null;
conversation_version?: "V0" | "V1";
sub_conversation_ids?: string[];
}
export interface ResultSet<T> {
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { usePostHog } from "posthog-js/react";
import {
BaseModalTitle,
BaseModalDescription,
@@ -17,6 +18,7 @@ interface AnalyticsConsentFormModalProps {
export function AnalyticsConsentFormModal({
onClose,
}: AnalyticsConsentFormModalProps) {
const posthog = usePostHog();
const { t } = useTranslation();
const { mutate: saveUserSettings } = useSaveSettings();
@@ -29,7 +31,7 @@ export function AnalyticsConsentFormModal({
{ user_consents_to_analytics: analytics },
{
onSuccess: () => {
handleCaptureConsent(analytics);
handleCaptureConsent(posthog, analytics);
onClose();
},
},
@@ -0,0 +1,182 @@
import React, { useMemo, useEffect, useState } 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";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
export function ChangeAgentButton() {
const [contextMenuOpen, setContextMenuOpen] = useState<boolean>(false);
const conversationMode = useConversationStore(
(state) => state.conversationMode,
);
const setConversationMode = useConversationStore(
(state) => state.setConversationMode,
);
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const { curAgentState } = useAgentState();
const { t } = useTranslation();
const isAgentRunning = curAgentState === AgentState.RUNNING;
const { data: conversation } = useActiveConversation();
const { mutate: createConversation, isPending: isCreatingConversation } =
useCreateConversation();
// Close context menu when agent starts running
useEffect(() => {
if (isAgentRunning && contextMenuOpen) {
setContextMenuOpen(false);
}
}, [isAgentRunning, contextMenuOpen]);
const handlePlanClick = (
event: React.MouseEvent<HTMLButtonElement> | KeyboardEvent,
) => {
event.preventDefault();
event.stopPropagation();
// Set conversation mode to "plan" immediately
setConversationMode("plan");
// Check if sub_conversation_ids is not empty
if (
(conversation?.sub_conversation_ids &&
conversation.sub_conversation_ids.length > 0) ||
!conversation?.conversation_id
) {
// Do nothing if both conditions are true
return;
}
// Create a new sub-conversation if we have a current conversation ID
createConversation(
{
parentConversationId: conversation.conversation_id,
agentType: "plan",
},
{
onSuccess: () =>
displaySuccessToast(
t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED),
),
},
);
};
// Handle Shift + Tab keyboard shortcut to cycle through modes
useEffect(() => {
if (!shouldUsePlanningAgent || isAgentRunning) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Shift + Tab combination
if (event.shiftKey && event.key === "Tab") {
// Prevent default tab navigation behavior
event.preventDefault();
event.stopPropagation();
// Cycle between modes: code -> plan -> code
const nextMode = conversationMode === "code" ? "plan" : "code";
if (nextMode === "plan") {
handlePlanClick(event);
} else {
setConversationMode(nextMode);
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
shouldUsePlanningAgent,
isAgentRunning,
conversationMode,
setConversationMode,
]);
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 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]);
const isButtonDisabled = isAgentRunning || isCreatingConversation;
if (!shouldUsePlanningAgent) {
return null;
}
return (
<div className="relative">
<button
type="button"
onClick={handleButtonClick}
disabled={isButtonDisabled}
className={cn(
"flex items-center border border-[#4B505F] rounded-[100px] transition-opacity",
!isExecutionAgent && "border-[#597FF4] bg-[#4A67BD]",
isButtonDisabled
? "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>
);
}
@@ -0,0 +1,76 @@
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 { ContextMenuIconTextWithDescription } from "../context-menu/context-menu-icon-text-with-description";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
const contextMenuListItemClassName = cn(
"cursor-pointer p-0 h-auto hover:bg-transparent",
);
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 mb-2 min-w-[195px] max-w-[195px] gap-0"
>
<ContextMenuListItem
testId="code-option"
onClick={handleCodeClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconTextWithDescription
icon={CodeTagIcon}
title={t(I18nKey.COMMON$CODE)}
description={t(I18nKey.COMMON$CODE_AGENT_DESCRIPTION)}
/>
</ContextMenuListItem>
<ContextMenuListItem
testId="plan-option"
onClick={handlePlanClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconTextWithDescription
icon={LessonPlanIcon}
title={t(I18nKey.COMMON$PLAN)}
description={t(I18nKey.COMMON$PLAN_AGENT_DESCRIPTION)}
/>
</ContextMenuListItem>
</ContextMenu>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
@@ -60,6 +60,7 @@ function getEntryPoint(
}
export function ChatInterface() {
const posthog = usePostHog();
const { setMessageToSend } = useConversationStore();
const { data: conversation } = useActiveConversation();
const { errorMessage } = useErrorMessageStore();
@@ -8,6 +8,7 @@ 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 {
disabled: boolean;
@@ -56,7 +57,10 @@ export function ChatInputActions({
return (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<div className="flex items-center gap-4">
<Tools />
<ChangeAgentButton />
</div>
</div>
<AgentStatus
className="ml-2 md:ml-3"
@@ -1,5 +1,7 @@
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>;
@@ -20,6 +22,12 @@ 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"
@@ -30,7 +38,11 @@ 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={t("SUGGESTIONS$WHAT_TO_BUILD")}
data-placeholder={
isPlanMode
? t(I18nKey.COMMON$LET_S_WORK_ON_A_PLAN)
: t(I18nKey.SUGGESTIONS$WHAT_TO_BUILD)
}
data-testid="chat-input"
onInput={onInput}
onPaste={onPaste}
@@ -1,11 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { OpenHandsObservation } from "#/types/core/observations";
import { isTaskTrackingObservation } from "#/types/core/guards";
import { GenericEventMessage } from "../generic-event-message";
import { TaskTrackingObservationContent } from "../task-tracking-observation-content";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { getObservationResult } from "../event-content-helpers/get-observation-result";
interface TaskTrackingEventMessageProps {
event: OpenHandsObservation;
@@ -16,34 +12,13 @@ export function TaskTrackingEventMessage({
event,
shouldShowConfirmationButtons,
}: TaskTrackingEventMessageProps) {
const { t } = useTranslation();
if (!isTaskTrackingObservation(event)) {
return null;
}
const { command } = event.extras;
let title: React.ReactNode;
let initiallyExpanded = false;
// Determine title and expansion state based on command
if (command === "plan") {
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_PLAN");
initiallyExpanded = true;
} else {
// command === "view"
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_VIEW");
initiallyExpanded = false;
}
return (
<div>
<GenericEventMessage
title={title}
details={<TaskTrackingObservationContent event={event} />}
success={getObservationResult(event)}
initiallyExpanded={initiallyExpanded}
/>
<TaskTrackingObservationContent event={event} />
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);
@@ -0,0 +1,82 @@
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>
);
}
@@ -1,6 +1,5 @@
import { TaskTrackingObservation } from "#/types/core/observations";
import { TaskListSection } from "./task-tracking/task-list-section";
import { ResultSection } from "./task-tracking/result-section";
interface TaskTrackingObservationContentProps {
event: TaskTrackingObservation;
@@ -16,11 +15,6 @@ export function TaskTrackingObservationContent({
<div className="flex flex-col gap-4">
{/* Task List section - only show for 'plan' command */}
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
{/* Result message - only show if there's meaningful content */}
{event.content && event.content.trim() && (
<ResultSection content={event.content} />
)}
</div>
);
}
@@ -1,21 +0,0 @@
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
interface ResultSectionProps {
content: string;
}
export function ResultSection({ content }: ResultSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography.H3>{t("TASK_TRACKING_OBSERVATION$RESULT")}</Typography.H3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
<pre className="whitespace-pre-wrap text-sm">{content.trim()}</pre>
</div>
</div>
);
}
@@ -1,7 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import CircleIcon from "#/icons/u-circle.svg?react";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import LoadingIcon from "#/icons/loading.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { StatusIcon } from "./status-icon";
import { StatusBadge } from "./status-badge";
interface TaskItemProps {
task: {
@@ -10,33 +14,47 @@ interface TaskItemProps {
status: "todo" | "in_progress" | "done";
notes?: string;
};
index: number;
}
export function TaskItem({ task, index }: TaskItemProps) {
export function TaskItem({ task }: TaskItemProps) {
const { t } = useTranslation();
const icon = useMemo(() => {
switch (task.status) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return <LoadingIcon className="w-4 h-4 text-[#ffffff]" />;
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
}
}, [task.status]);
const isDoneStatus = task.status === "done";
return (
<div className="border-l-2 border-gray-600 pl-3">
<div className="flex items-start gap-2">
<StatusIcon status={task.status} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Typography.Text className="text-sm text-gray-400">
{index + 1}.
</Typography.Text>
<StatusBadge status={task.status} />
</div>
<h4 className="font-medium text-white mb-1">{task.title}</h4>
<Typography.Text className="text-xs text-gray-400 mb-1">
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
</Typography.Text>
{task.notes && (
<Typography.Text className="text-sm text-gray-300 italic">
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}: {task.notes}
</Typography.Text>
<div
className="flex gap-[14px] items-center px-4 py-2 w-full"
data-name="item"
>
<div className="shrink-0">{icon}</div>
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
<Typography.Text
className={cn(
"text-[12px] text-white",
isDoneStatus && "text-[#A3A3A3]",
)}
</div>
>
{task.title}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3] font-normal">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3]">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
</Typography.Text>
</div>
</div>
);
@@ -1,5 +1,7 @@
import { useTranslation } from "react-i18next";
import { TaskItem } from "./task-item";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface TaskListSectionProps {
@@ -15,19 +17,20 @@ export function TaskListSection({ taskList }: TaskListSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography.H3>
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
{taskList.length === 1 ? "item" : "items"})
</Typography.H3>
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
{/* Header Tabs */}
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
{t(I18nKey.COMMON$TASKS)}
</Typography.Text>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<div className="space-y-3">
{taskList.map((task, index) => (
<TaskItem key={task.id} task={task} index={index} />
))}
</div>
{/* Task Items */}
<div>
{taskList.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
</div>
);
@@ -8,6 +8,7 @@ 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 {
@@ -58,6 +59,21 @@ 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]"
@@ -0,0 +1,39 @@
import React from "react";
import { ContextMenuIconText } from "./context-menu-icon-text";
import { Typography } from "#/ui/typography";
import { cn } from "#/utils/utils";
interface ContextMenuIconTextWithDescriptionProps {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
className?: string;
iconClassName?: string;
}
export function ContextMenuIconTextWithDescription({
icon,
title,
description,
className,
iconClassName,
}: ContextMenuIconTextWithDescriptionProps) {
return (
<div
className={cn(
"flex flex-col gap-1 justify-center hover:bg-[#5C5D62] rounded p-2",
className,
)}
>
<ContextMenuIconText
icon={icon}
text={title}
className="px-0"
iconClassName={iconClassName}
/>
<Typography.Text className="text-[#A3A3A3] text-[10px] font-normal whitespace-pre-wrap break-words">
{description}
</Typography.Text>
</div>
);
}
@@ -70,8 +70,7 @@ export function AgentStatus({
// Update global state when agent loading condition changes
useEffect(() => {
if (shouldShownAgentLoading)
setShouldShownAgentLoading(shouldShownAgentLoading);
setShouldShownAgentLoading(!!shouldShownAgentLoading);
}, [shouldShownAgentLoading, setShouldShownAgentLoading]);
return (
@@ -1,5 +1,5 @@
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { cn } from "#/utils/utils";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import ConversationService from "#/api/conversation-service/conversation-service.api";
@@ -44,6 +44,7 @@ export function ConversationCard({
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const posthog = usePostHog();
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const onTitleSave = (newTitle: string) => {
@@ -0,0 +1,80 @@
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>
);
}
@@ -1,7 +1,7 @@
import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { I18nKey } from "#/i18n/declaration";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { DangerModal } from "../confirmation-modals/danger-modal";
@@ -22,6 +22,7 @@ interface SettingsFormProps {
}
export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
const posthog = usePostHog();
const { mutate: saveUserSettings } = useSaveSettings();
const location = useLocation();
@@ -1,10 +1,13 @@
import { Trans } from "react-i18next";
import { OpenHandsEvent } from "#/types/v1/core";
import React from "react";
import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core";
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component";
import { getActionContent } from "./get-action-content";
import { getObservationContent } from "./get-observation-content";
import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content";
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import i18n from "#/i18n";
const trimText = (text: string, maxLength: number): string => {
@@ -158,14 +161,24 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
export const getEventContent = (event: OpenHandsEvent) => {
let title: React.ReactNode = "";
let details: string = "";
let details: string | React.ReactNode = "";
if (isActionEvent(event)) {
title = getActionEventTitle(event);
details = getActionContent(event);
} else if (isObservationEvent(event)) {
title = getObservationEventTitle(event);
details = getObservationContent(event);
// For TaskTrackerObservation, use React component instead of markdown
if (event.observation.kind === "TaskTrackerObservation") {
details = (
<TaskTrackingObservationContent
event={event as ObservationEvent<TaskTrackerObservation>}
/>
);
} else {
details = getObservationContent(event);
}
}
return {
@@ -49,6 +49,10 @@ const getExecuteBashObservationContent = (
let { output } = observation;
if (!output) {
output = "";
}
if (output.length > MAX_CONTENT_LENGTH) {
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
}
@@ -136,6 +140,7 @@ const getTaskTrackerObservationContent = (
if (
"content" in observation &&
observation.content &&
typeof observation.content === "string" &&
observation.content.trim()
) {
content += `\n\n**Result:** ${observation.content.trim()}`;
@@ -27,13 +27,16 @@ export function FinishEventMessage({
microagentPRUrl,
actions,
}: FinishEventMessageProps) {
const eventContent = getEventContent(event);
// For FinishAction, details is always a string (getActionContent returns string)
const message =
typeof eventContent.details === "string"
? eventContent.details
: String(eventContent.details);
return (
<>
<ChatMessage
type="agent"
message={getEventContent(event).details}
actions={actions}
/>
<ChatMessage type="agent" message={message} actions={actions} />
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
@@ -16,6 +16,13 @@ export function GenericEventMessageWrapper({
}: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event);
if (
isObservationEvent(event) &&
event.observation.kind === "TaskTrackerObservation"
) {
return <div>{details}</div>;
}
return (
<div>
<GenericEventMessage
@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
import CircleIcon from "#/icons/u-circle.svg?react";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import LoadingIcon from "#/icons/loading.svg?react";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
interface TaskItemProps {
task: TaskItemType;
}
export function TaskItem({ task }: TaskItemProps) {
const { t } = useTranslation();
const icon = useMemo(() => {
switch (task.status) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return (
<LoadingIcon className="w-4 h-4 text-[#ffffff]" strokeWidth={0.5} />
);
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
}
}, [task.status]);
const isDoneStatus = task.status === "done";
return (
<div
className="flex gap-[14px] items-center px-4 py-2 w-full"
data-name="item"
>
<div className="shrink-0">{icon}</div>
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
<Typography.Text
className={cn(
"text-[12px] text-white",
isDoneStatus && "text-[#A3A3A3]",
)}
>
{task.title}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3]">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
</Typography.Text>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { TaskItem } from "./task-item";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface TaskListSectionProps {
taskList: TaskItemType[];
}
export function TaskListSection({ taskList }: TaskListSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
{/* Header Tabs */}
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
{t(I18nKey.COMMON$TASKS)}
</Typography.Text>
</div>
{/* Task Items */}
<div>
{taskList.map((task, index) => (
<TaskItem key={`task-${index}`} task={task} />
))}
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import React from "react";
import { ObservationEvent } from "#/types/v1/core";
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import { TaskListSection } from "./task-list-section";
interface TaskTrackingObservationContentProps {
event: ObservationEvent<TaskTrackerObservation>;
}
export function TaskTrackingObservationContent({
event,
}: TaskTrackingObservationContentProps): React.ReactNode {
const { observation } = event;
const { command, task_list: taskList } = observation;
const shouldShowTaskList = command === "plan" && taskList.length > 0;
return (
<div className="flex flex-col gap-4">
{/* Task List section - only show for 'plan' command */}
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
</div>
);
}
+48 -7
View File
@@ -1,6 +1,7 @@
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";
@@ -100,7 +101,10 @@ interface ErrorArgData {
msg_id: string;
}
export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
export function updateStatusWhenErrorMessagePresent(
data: ErrorArg | unknown,
posthog?: ReturnType<typeof usePostHog>,
) {
const isObject = (val: unknown): val is object =>
!!val && typeof val === "object";
const isString = (val: unknown): val is string => typeof val === "string";
@@ -123,6 +127,7 @@ export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
source: "websocket",
metadata,
msgId,
posthog,
});
}
}
@@ -131,11 +136,13 @@ export function WsClientProvider({
conversationId,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const posthog = usePostHog();
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
const { addEvent, clearEvents } = useEventStore();
const queryClient = useQueryClient();
const sioRef = React.useRef<Socket | null>(null);
const pendingEventsRef = React.useRef<Record<string, unknown>[]>([]);
const [webSocketStatus, setWebSocketStatus] =
React.useState<V0_WebSocketStatus>("DISCONNECTED");
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
@@ -145,17 +152,37 @@ export function WsClientProvider({
const { data: conversation, refetch: refetchConversation } =
useActiveConversation();
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
EventLogger.error("WebSocket is not connected.");
function flushPendingEvents(socket: Socket | null = sioRef.current) {
if (!socket || pendingEventsRef.current.length === 0) {
return;
}
sioRef.current.emit("oh_user_action", event);
pendingEventsRef.current.forEach((queuedEvent) => {
socket.emit("oh_user_action", queuedEvent);
});
pendingEventsRef.current = [];
}
function send(event: Record<string, unknown>) {
const socket = sioRef.current;
if (!socket) {
EventLogger.error("WebSocket is not connected, queuing message...");
pendingEventsRef.current.push(event);
return;
}
if (pendingEventsRef.current.length > 0) {
flushPendingEvents(socket);
}
socket.emit("oh_user_action", event);
}
function handleConnect() {
setWebSocketStatus("CONNECTED");
removeErrorMessage();
flushPendingEvents();
}
function handleMessage(event: Record<string, unknown>) {
@@ -178,6 +205,7 @@ export function WsClientProvider({
message: errorMessage,
source: "chat",
metadata: { msgId: event.id },
posthog,
});
setErrorMessage(errorMessage);
@@ -193,6 +221,7 @@ export function WsClientProvider({
message: event.message,
source: "chat",
metadata: { msgId: event.id },
posthog,
});
} else {
removeErrorMessage();
@@ -260,14 +289,14 @@ export function WsClientProvider({
sio.io.opts.query = sio.io.opts.query || {};
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data);
updateStatusWhenErrorMessagePresent(data, posthog);
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
}
function handleError(data: unknown) {
// set status
setWebSocketStatus("DISCONNECTED");
updateStatusWhenErrorMessagePresent(data);
updateStatusWhenErrorMessagePresent(data, posthog);
setErrorMessage(
hasValidMessageProperty(data)
@@ -284,6 +313,7 @@ export function WsClientProvider({
clearEvents();
setWebSocketStatus("CONNECTING");
pendingEventsRef.current = [];
}, [conversationId]);
React.useEffect(() => {
@@ -293,6 +323,12 @@ export function WsClientProvider({
// Clear error messages when conversation is intentionally stopped
if (conversation && conversation.status === "STOPPED") {
const existingSocket = sioRef.current;
if (existingSocket) {
existingSocket.disconnect();
}
sioRef.current = null;
pendingEventsRef.current = [];
removeErrorMessage();
setWebSocketStatus("DISCONNECTED");
return () => undefined; // conversation intentionally stopped
@@ -312,6 +348,10 @@ export function WsClientProvider({
!conversation.runtime_status ||
conversation.runtime_status === "STATUS$STOPPED"
) {
if (sioRef.current) {
sioRef.current.disconnect();
}
sioRef.current = null;
return () => undefined; // conversation not ready for WebSocket connection
}
@@ -360,6 +400,7 @@ export function WsClientProvider({
sio.on("disconnect", handleDisconnect);
sioRef.current = sio;
flushPendingEvents(sio);
return () => {
sio.off("connect", handleConnect);
+21 -13
View File
@@ -8,17 +8,18 @@
import { HydratedRouter } from "react-router/dom";
import React, { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
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 PosthogInit() {
function PostHogWrapper({ children }: { children: React.ReactNode }) {
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
null,
);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
(async () => {
@@ -27,20 +28,27 @@ function PosthogInit() {
setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
} catch {
displayErrorToast("Error fetching PostHog client key");
} finally {
setIsLoading(false);
}
})();
}, []);
React.useEffect(() => {
if (posthogClientKey) {
posthog.init(posthogClientKey, {
if (isLoading || !posthogClientKey) {
return children;
}
return (
<PostHogProvider
apiKey={posthogClientKey}
options={{
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only",
});
}
}, [posthogClientKey]);
return null;
}}
>
{children}
</PostHogProvider>
);
}
async function prepareApp() {
@@ -62,10 +70,10 @@ prepareApp().then(() =>
document,
<StrictMode>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
<PostHogWrapper>
<HydratedRouter />
</PostHogWrapper>
</QueryClientProvider>
<div id="modal-portal-exit" />
</StrictMode>,
);
}),
@@ -17,6 +17,8 @@ interface CreateConversationVariables {
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
createMicroagent?: CreateMicroagent;
parentConversationId?: string;
agentType?: "default" | "plan";
}
// Response type that combines both V1 and legacy responses
@@ -44,6 +46,8 @@ export const useCreateConversation = () => {
suggestedTask,
conversationInstructions,
createMicroagent,
parentConversationId,
agentType,
} = variables;
const useV1 = USE_V1_CONVERSATION_API() && !createMicroagent;
@@ -57,6 +61,8 @@ export const useCreateConversation = () => {
repository?.branch,
conversationInstructions,
undefined, // trigger - will be set by backend
parentConversationId,
agentType,
);
// Return a special task ID that the frontend will recognize
+2 -1
View File
@@ -1,10 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
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 posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/settings-service/settings-service.api";
import { PostSettings } from "#/types/settings";
@@ -41,6 +41,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
};
export const useSaveSettings = () => {
const posthog = usePostHog();
const queryClient = useQueryClient();
const { data: currentSettings } = useSettings();
+2 -1
View File
@@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
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,6 +1,4 @@
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";
@@ -61,12 +59,6 @@ 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:
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import React from "react";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useParams, useNavigate } from "react-router";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import useMetricsStore from "#/stores/metrics-store";
@@ -29,6 +29,7 @@ export function useConversationNameContextMenu({
showOptions = false,
onContextMenuToggle,
}: UseConversationNameContextMenuProps) {
const posthog = usePostHog();
const { t } = useTranslation();
const { conversationId: currentConversationId } = useParams();
const navigate = useNavigate();
@@ -1,8 +1,10 @@
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();
/**
@@ -15,11 +17,11 @@ export const useMigrateUserConsent = () => {
if (userAnalyticsConsent) {
args?.handleAnalyticsWasPresentInLocalStorage();
await saveUserSettings(
saveUserSettings(
{ user_consents_to_analytics: userAnalyticsConsent === "true" },
{
onSuccess: () => {
handleCaptureConsent(userAnalyticsConsent === "true");
handleCaptureConsent(posthog, userAnalyticsConsent === "true");
},
},
);
@@ -27,7 +29,7 @@ export const useMigrateUserConsent = () => {
localStorage.removeItem("analytics-consent");
}
},
[],
[posthog, saveUserSettings],
);
return { migrateUserConsent };
@@ -0,0 +1,41 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { useSettings } from "./query/use-settings";
/**
* Hook to sync PostHog opt-in/out state with backend setting on mount.
* This ensures that if the backend setting changes (e.g., via API or different client),
* the PostHog instance reflects the current user preference.
*/
export const useSyncPostHogConsent = () => {
const posthog = usePostHog();
const { data: settings } = useSettings();
const hasSyncedRef = React.useRef(false);
React.useEffect(() => {
// Only run once when both PostHog and settings are available
if (!posthog || settings === undefined || hasSyncedRef.current) {
return;
}
const backendConsent = settings.USER_CONSENTS_TO_ANALYTICS;
// Only sync if there's a backend preference set
if (backendConsent !== null) {
const posthogHasOptedIn = posthog.has_opted_in_capturing();
const posthogHasOptedOut = posthog.has_opted_out_capturing();
// Check if PostHog state is out of sync with backend
const needsSync =
(backendConsent === true && !posthogHasOptedIn) ||
(backendConsent === false && !posthogHasOptedOut);
if (needsSync) {
handleCaptureConsent(posthog, backendConsent);
}
hasSyncedRef.current = true;
}
}, [posthog, settings]);
};
+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));
+2 -1
View File
@@ -1,4 +1,4 @@
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useConfig } from "./query/use-config";
import { useSettings } from "./query/use-settings";
import { Provider } from "#/types/settings";
@@ -8,6 +8,7 @@ import { Provider } from "#/types/settings";
* from available hooks (config, settings, etc.)
*/
export const useTracking = () => {
const posthog = usePostHog();
const { data: config } = useConfig();
const { data: settings } = useSettings();
+11 -1
View File
@@ -471,12 +471,12 @@ export enum I18nKey {
PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB = "PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB",
PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED = "PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED",
PROJECT_MENU_DETAILS$AGO_LABEL = "PROJECT_MENU_DETAILS$AGO_LABEL",
STATUS$ERROR = "STATUS$ERROR",
STATUS$ERROR_LLM_AUTHENTICATION = "STATUS$ERROR_LLM_AUTHENTICATION",
STATUS$ERROR_LLM_SERVICE_UNAVAILABLE = "STATUS$ERROR_LLM_SERVICE_UNAVAILABLE",
STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR = "STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR",
STATUS$ERROR_LLM_OUT_OF_CREDITS = "STATUS$ERROR_LLM_OUT_OF_CREDITS",
STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION = "STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION",
STATUS$ERROR = "STATUS$ERROR",
STATUS$ERROR_RUNTIME_DISCONNECTED = "STATUS$ERROR_RUNTIME_DISCONNECTED",
STATUS$ERROR_MEMORY = "STATUS$ERROR_MEMORY",
STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR = "STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR",
@@ -937,4 +937,14 @@ export enum I18nKey {
AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION",
COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS",
COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
COMMON$TASKS = "COMMON$TASKS",
COMMON$PLAN_MD = "COMMON$PLAN_MD",
COMMON$READ_MORE = "COMMON$READ_MORE",
COMMON$BUILD = "COMMON$BUILD",
COMMON$ASK = "COMMON$ASK",
COMMON$PLAN = "COMMON$PLAN",
COMMON$LET_S_WORK_ON_A_PLAN = "COMMON$LET_S_WORK_ON_A_PLAN",
COMMON$CODE_AGENT_DESCRIPTION = "COMMON$CODE_AGENT_DESCRIPTION",
COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION",
PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED",
}
+160
View File
@@ -14990,5 +14990,165 @@
"tr": "Bir plan oluştur",
"de": "Einen Plan erstellen",
"uk": "Створити план"
},
"COMMON$TASKS": {
"en": "Tasks",
"ja": "タスク",
"zh-CN": "任务",
"zh-TW": "任務",
"ko-KR": "작업",
"no": "Oppgaver",
"it": "Attività",
"pt": "Tarefas",
"es": "Tareas",
"ar": "مهام",
"fr": "Tâches",
"tr": "Görevler",
"de": "Aufgaben",
"uk": "Завдання"
},
"COMMON$PLAN_MD": {
"en": "Plan.md",
"ja": "Plan.md",
"zh-CN": "Plan.md",
"zh-TW": "Plan.md",
"ko-KR": "Plan.md",
"no": "Plan.md",
"it": "Plan.md",
"pt": "Plan.md",
"es": "Plan.md",
"ar": "Plan.md",
"fr": "Plan.md",
"tr": "Plan.md",
"de": "Plan.md",
"uk": "Plan.md"
},
"COMMON$READ_MORE": {
"en": "Read more",
"ja": "続きを読む",
"zh-CN": "阅读更多",
"zh-TW": "閱讀更多",
"ko-KR": "더 읽기",
"no": "Les mer",
"it": "Leggi di più",
"pt": "Leia mais",
"es": "Leer más",
"ar": "اقرأ المزيد",
"fr": "En savoir plus",
"tr": "Devamını oku",
"de": "Mehr lesen",
"uk": "Читати далі"
},
"COMMON$BUILD": {
"en": "Build",
"ja": "ビルド",
"zh-CN": "构建",
"zh-TW": "建構",
"ko-KR": "빌드",
"no": "Bygg",
"it": "Compila",
"pt": "Construir",
"es": "Compilar",
"ar": "بناء",
"fr": "Construire",
"tr": "Derle",
"de": "Erstellen",
"uk": "Зібрати"
},
"COMMON$ASK": {
"en": "Ask",
"ja": "質問する",
"zh-CN": "提问",
"zh-TW": "詢問",
"ko-KR": "질문",
"no": "Spør",
"it": "Chiedi",
"pt": "Perguntar",
"es": "Preguntar",
"ar": "اسأل",
"fr": "Demander",
"tr": "Sor",
"de": "Fragen",
"uk": "Запитати"
},
"COMMON$PLAN": {
"en": "Plan",
"ja": "計画",
"zh-CN": "计划",
"zh-TW": "計劃",
"ko-KR": "계획",
"no": "Plan",
"it": "Piano",
"pt": "Plano",
"es": "Plan",
"ar": "خطة",
"fr": "Planifier",
"tr": "Plan",
"de": "Plan",
"uk": "План"
},
"COMMON$LET_S_WORK_ON_A_PLAN": {
"en": "Lets work on a plan",
"ja": "プランに取り組みましょう",
"zh-CN": "让我们制定一个计划吧",
"zh-TW": "讓我們來制定計劃吧",
"ko-KR": "계획을 세워봅시다",
"no": "La oss lage en plan",
"it": "Lavoriamo su un piano",
"pt": "Vamos trabalhar em um plano",
"es": "Trabajemos en un plan",
"ar": "لنضع خطة معًا",
"fr": "Travaillons sur un plan",
"tr": "Bir plan üzerinde çalışalım",
"de": "Lassen Sie uns an einem Plan arbeiten",
"uk": "Давайте розробимо план"
},
"COMMON$CODE_AGENT_DESCRIPTION": {
"en": "Write, edit, and debug with AI assistance in real time.",
"ja": "AIの支援をリアルタイムで受けながら、コードの作成、編集、デバッグを行いましょう。",
"zh-CN": "实时在 AI 协助下编写、编辑和调试。",
"zh-TW": "即時在 AI 協助下編寫、編輯和除錯。",
"ko-KR": "AI의 지원을 받아 실시간으로 작성, 편집 및 디버깅하세요.",
"no": "Skriv, rediger og feilsøk med AI-assistanse i sanntid.",
"it": "Scrivi, modifica e esegui il debug con assistenza AI in tempo reale.",
"pt": "Escreva, edite e depure com assistência de IA em tempo real.",
"es": "Escribe, edita y depura con ayuda de IA en tiempo real.",
"ar": "اكتب وعدّل وصحّح الأخطاء بمساعدة الذكاء الاصطناعي في الوقت الفعلي.",
"fr": "Rédigez, modifiez et déboguez avec laide de lIA en temps réel.",
"tr": "AI desteğiyle gerçek zamanlı olarak yazın, düzenleyin ve hata ayıklayın.",
"de": "Schreiben, bearbeiten und debuggen Sie mit KI-Unterstützung in Echtzeit.",
"uk": "Пишіть, редагуйте та налагоджуйте з підтримкою ШІ у реальному часі."
},
"COMMON$PLAN_AGENT_DESCRIPTION": {
"en": "Outline goals, structure tasks, and map your next steps.",
"ja": "目標を明確にし、タスクを構造化し、次のステップを計画しましょう。",
"zh-CN": "概述目标、结构化任务,并规划下一步。",
"zh-TW": "概述目標、結構化任務,並規劃下一步。",
"ko-KR": "목표를 개요하고, 작업을 구조화하며, 다음 단계를 구상하세요.",
"no": "Skisser mål, strukturer oppgaver og planlegg dine neste steg.",
"it": "Definisci gli obiettivi, struttura le attività e pianifica i prossimi passi.",
"pt": "Esboce objetivos, estruture tarefas e trace seus próximos passos.",
"es": "Define objetivos, estructura tareas y planifica tus próximos pasos.",
"ar": "حدد الأهداف، نظم المهام، وارسم خطواتك التالية.",
"fr": "Dressez des objectifs, structurez vos tâches et planifiez vos prochaines étapes.",
"tr": "Hedefleri belirtin, görevleri yapılandırın ve sonraki adımlarınızı belirleyin.",
"de": "Umreißen Sie Ziele, strukturieren Sie Aufgaben und planen Sie Ihre nächsten Schritte.",
"uk": "Окресліть цілі, структуруйте завдання та сплануйте наступні кроки."
},
"PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED": {
"en": "Planning agent initialized",
"ja": "プランニングエージェントが初期化されました",
"zh-CN": "规划代理已初始化",
"zh-TW": "規劃代理已初始化",
"ko-KR": "계획 에이전트가 초기화되었습니다",
"no": "Planleggingsagent er initialisert",
"it": "Agente di pianificazione inizializzato",
"pt": "Agente de planejamento inicializado",
"es": "Agente de planificación inicializado",
"ar": "تم تهيئة وكيل التخطيط",
"fr": "Agent de planification initialisé",
"tr": "Planlama ajanı başlatıldı",
"de": "Planungsagent wurde initialisiert",
"uk": "Агент планування ініціалізовано"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M7.062 8.367L3.0915 12.336L7.062 16.305L6 17.367L1.5 12.867V11.805L6 7.305L7.062 8.367ZM17.562 7.305L16.5 8.367L20.4705 12.336L16.5 16.305L17.562 17.367L22.062 12.867V11.805L17.562 7.305ZM7.362 19.5L8.703 20.172L16.203 5.172L14.862 4.5L7.362 19.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="7" viewBox="0 0 16 7" fill="none">
<path d="M7.50684 0.25C9.24918 0.25 10.9332 0.87774 12.251 2.01758C13.5688 3.15746 14.4327 4.73379 14.6836 6.45801L14.7256 6.74316H13.2129L13.1777 6.53516C12.9499 5.19635 12.2554 3.98161 11.2178 3.10547C10.1799 2.22925 8.86511 1.74805 7.50684 1.74805C6.14866 1.74811 4.83466 2.22931 3.79688 3.10547C2.75913 3.98161 2.06476 5.19628 1.83691 6.53516L1.80078 6.74316H0.289063L0.331055 6.45801C0.581982 4.73389 1.44504 3.15745 2.7627 2.01758C4.08041 0.877757 5.76455 0.250069 7.50684 0.25Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M14.72 8.79L10.43 13.09L8.78 11.44C8.69036 11.3353 8.58004 11.2503 8.45597 11.1903C8.33191 11.1303 8.19678 11.0965 8.05906 11.0912C7.92134 11.0859 7.78401 11.1091 7.65568 11.1594C7.52736 11.2096 7.41081 11.2859 7.31335 11.3833C7.2159 11.4808 7.13964 11.5974 7.08937 11.7257C7.03909 11.854 7.01589 11.9913 7.02121 12.1291C7.02653 12.2668 7.06026 12.4019 7.12028 12.526C7.1803 12.65 7.26532 12.7604 7.37 12.85L9.72 15.21C9.81344 15.3027 9.92426 15.376 10.0461 15.4258C10.1679 15.4755 10.2984 15.5008 10.43 15.5C10.6923 15.4989 10.9437 15.3947 11.13 15.21L16.13 10.21C16.2237 10.117 16.2981 10.0064 16.3489 9.88458C16.3997 9.76272 16.4258 9.63201 16.4258 9.5C16.4258 9.36799 16.3997 9.23728 16.3489 9.11542C16.2981 8.99356 16.2237 8.88296 16.13 8.79C15.9426 8.60375 15.6892 8.49921 15.425 8.49921C15.1608 8.49921 14.9074 8.60375 14.72 8.79ZM12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -25,6 +25,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<ScrollRestoration />
<Scripts />
<Toaster />
<div id="modal-portal-exit" />
</body>
</html>
);
+3 -1
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
import { useMutation } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { I18nKey } from "#/i18n/declaration";
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
import { TOSCheckbox } from "#/components/features/waitlist/tos-checkbox";
@@ -11,6 +12,7 @@ import { openHands } from "#/api/open-hands-axios";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
export default function AcceptTOS() {
const posthog = usePostHog();
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -23,7 +25,7 @@ export default function AcceptTOS() {
const { mutate: acceptTOS, isPending: isSubmitting } = useMutation({
mutationFn: async () => {
// Set consent for analytics
handleCaptureConsent(true);
handleCaptureConsent(posthog, true);
// Call the API to record TOS acceptance in the database
return openHands.post("/api/accept_tos", {
+6 -3
View File
@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { usePostHog } from "posthog-js/react";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSettings } from "#/hooks/query/use-settings";
import { AvailableLanguages } from "#/i18n";
@@ -20,6 +21,7 @@ import { useConfig } from "#/hooks/query/use-config";
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
function AppSettingsScreen() {
const posthog = usePostHog();
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
@@ -93,7 +95,7 @@ function AppSettingsScreen() {
},
{
onSuccess: () => {
handleCaptureConsent(enableAnalytics);
handleCaptureConsent(posthog, enableAnalytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
@@ -125,7 +127,8 @@ function AppSettingsScreen() {
};
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
const currentAnalytics = !!settings?.USER_CONSENTS_TO_ANALYTICS;
// Treat null as true since analytics is opt-in by default
const currentAnalytics = settings?.USER_CONSENTS_TO_ANALYTICS ?? true;
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
};
@@ -197,7 +200,7 @@ function AppSettingsScreen() {
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
defaultIsToggled={settings.USER_CONSENTS_TO_ANALYTICS ?? true}
onToggle={checkIfAnalyticsSwitchHasChanged}
>
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
+42 -3
View File
@@ -1,13 +1,52 @@
import { useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { I18nKey } from "#/i18n/declaration";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { useConversationStore } from "#/state/conversation-store";
import { code } from "#/components/features/markdown/code";
import { ul, ol } from "#/components/features/markdown/list";
import { paragraph } from "#/components/features/markdown/paragraph";
import { anchor } from "#/components/features/markdown/anchor";
import {
h1,
h2,
h3,
h4,
h5,
h6,
} from "#/components/features/markdown/headings";
function PlannerTab() {
const { t } = useTranslation();
const setConversationMode = useConversationStore(
(state) => state.setConversationMode,
);
const { planContent, setConversationMode } = useConversationStore();
if (planContent) {
return (
<div className="flex flex-col w-full h-full p-4 overflow-auto">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
h1,
h2,
h3,
h4,
h5,
h6,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{planContent}
</Markdown>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center w-full h-full p-10">
+4
View File
@@ -25,6 +25,7 @@ import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { useReoTracking } from "#/hooks/use-reo-tracking";
import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
@@ -100,6 +101,9 @@ export default function MainApp() {
// Initialize Reo.dev tracking in SaaS mode
useReoTracking();
// Sync PostHog opt-in/out state with backend setting on mount
useSyncPostHogConsent();
React.useEffect(() => {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.LANGUAGE) {
+1
View File
@@ -72,6 +72,7 @@ export function handleStatusMessage(message: StatusMessage) {
message: message.message,
source: "chat",
metadata: { msgId: message.id },
posthog: undefined, // Service file - can't use hooks
});
}
}
+86
View File
@@ -28,6 +28,7 @@ interface ConversationState {
submittedMessage: string | null;
shouldHideSuggestions: boolean; // New state to hide suggestions when input expands
hasRightPanelToggled: boolean;
planContent: string | null;
conversationMode: ConversationMode;
}
@@ -78,6 +79,91 @@ export const useConversationStore = create<ConversationStore>()(
submittedMessage: null,
shouldHideSuggestions: false,
hasRightPanelToggled: true,
planContent: `
# Improve Developer Onboarding and Examples
## Overview
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).
## Current State Analysis
**Strengths:**
- Good quickstart documentation in \`docs/quickstart.mdx\`
- Extensive examples across multiple categories (60+ example files)
- Well-structured docs with multiple LLM provider examples
- Active community support via Discord
**Gaps Identified:**
- No progressive tutorial series that builds complexity gradually
- Limited troubleshooting documentation for common issues
- Sparse comments in example files explaining what's happening
- Local LLM setup (Ollama/LM Studio) not prominently featured
- No "first 10 minutes" success path
- Missing visual/conceptual architecture guides for beginners
- Error messages don't always point to solutions
## Proposed Improvements
### 1. Create Interactive Tutorial Series (\`examples/tutorials/\`)
**New folder structure:**
\`\`\`
examples/tutorials/
├── README.md # Tutorial overview and prerequisites
├── 00_hello_world.py # Absolute minimal example
├── 01_your_first_search.py # Basic search with detailed comments
├── 02_understanding_actions.py # How actions work
├── 03_data_extraction_basics.py # Extract data step-by-step
├── 04_error_handling.py # Common errors and solutions
├── 05_custom_tools_intro.py # First custom tool
├── 06_local_llm_setup.py # Ollama/LM Studio complete guide
└── 07_debugging_tips.py # Debugging strategies
\`\`\`
**Key Features:**
- Each file 5080 lines max
- Extensive inline comments explaining every concept
- Clear learning objectives at the top of each file
- "What you'll learn" and "Prerequisites" sections
- Common pitfalls highlighted
- Expected output shown in comments
### 2. Troubleshooting Guide (\`docs/troubleshooting.mdx\`)
**Sections:**
- Installation issues (Chromium, dependencies, virtual environments)
- LLM provider connection errors (API keys, timeouts, rate limits)
- Local LLM setup (Ollama vs LM Studio, model compatibility)
- Browser automation issues (element not found, timeout errors)
- Common error messages with solutions
- Performance optimization tips
- When to ask for help (Discord/GitHub)
**Format:**
**Error: "LLM call timed out after 60 seconds"**
**What it means:**
The model took too long to respond
**Common causes:**
1. Model is too slow for the task
2. LM Studio/Ollama not responding properly
3. Complex page overwhelming the model
**Solutions:**
- Use flash_mode for faster execution
- Try a faster model (Gemini Flash, GPT-4 Turbo Mini)
- Simplify the task
- Check model server logs`,
conversationMode: "code",
// Actions
+14 -4
View File
@@ -1,4 +1,4 @@
import posthog from "posthog-js";
import type { PostHog } from "posthog-js";
import { handleStatusMessage } from "#/services/actions";
import { displayErrorToast } from "./custom-toast-handlers";
@@ -7,9 +7,17 @@ interface ErrorDetails {
source?: string;
metadata?: Record<string, unknown>;
msgId?: string;
posthog?: PostHog;
}
export function trackError({ message, source, metadata = {} }: ErrorDetails) {
export function trackError({
message,
source,
metadata = {},
posthog,
}: ErrorDetails) {
if (!posthog) return;
const error = new Error(message);
posthog.captureException(error, {
error_source: source || "unknown",
@@ -21,8 +29,9 @@ export function showErrorToast({
message,
source,
metadata = {},
posthog,
}: ErrorDetails) {
trackError({ message, source, metadata });
trackError({ message, source, metadata, posthog });
displayErrorToast(message);
}
@@ -31,8 +40,9 @@ export function showChatError({
source,
metadata = {},
msgId,
posthog,
}: ErrorDetails) {
trackError({ message, source, metadata });
trackError({ message, source, metadata, posthog });
handleStatusMessage({
type: "error",
message,
+8 -2
View File
@@ -1,10 +1,16 @@
import posthog from "posthog-js";
import type { PostHog } from "posthog-js";
/**
* Handle user consent for tracking
* @param posthog PostHog instance (from usePostHog hook)
* @param consent Whether the user consents to tracking
*/
export const handleCaptureConsent = (consent: boolean) => {
export const handleCaptureConsent = (
posthog: PostHog | undefined,
consent: boolean,
) => {
if (!posthog) return;
if (consent && !posthog.has_opted_in_capturing()) {
posthog.opt_in_capturing();
}
+39
View File
@@ -0,0 +1,39 @@
---
name: agent_sdk_builder
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /agent-builder
inputs:
- name: INITIAL_PROMPT
description: "Initial SDK requirements"
---
# Agent Builder and Interviewer Role
You are an expert requirements gatherer and agent builder. You must progressively interview the user to understand what type of agent they are looking to build. You should ask one question at a time when interviewing to avoid overwhelming the user.
Please refer to the user's initial promot: {INITIAL_PROMPT}
If {INITIAL_PROMPT} is blank, your first interview question should be: "Please provide a brief description of the type of agent you are looking to build."
# Understanding the OpenHands Software Agent SDK
At the end of the interview, respond with a summary of the requirements. Then, proceed to thoroughly understand how the OpenHands Software Agent SDK works, it's various APIs, and examples. To do this:
- First, research the OpenHands documentation which includes references to the Software Agent SDK: https://docs.openhands.dev/llms.txt
- Then, clone the examples into a temporary workspace folder (under "temp/"): https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk
- Then, clone the SDK docs into the same temporary workspace folder: https://github.com/OpenHands/docs/tree/main/sdk
After analyzing the OpenHands Agent SDK, you may optionally ask additional clarifying questions in case it's important for the technical design of the agent.
# Generating the SDK Plan
You can then proceed to build a technical implementation plan based on the user requirements and your understanding of how the OpenHands Agent SDK works.
- The plan should be stored in "plan/SDK_PLAN.md" from the root of the workspace.
- A visual representation of how the agent should work based on the SDK_PLAN.md. This should look like a flow diagram with nodes and edges. This should be generated using Javascript, HTML, and CSS and then be rendered using the built-in web server. Store this in the plan/ directory.
# Implementing the Plan
After the plan is generated, please ask the user if they are ready to generate the SDK implementation. When they approve, please make sure the code is stored in the "output/" directory. Make sure the code provides logging that a user can see in the terminal. Ideally, the SDK is a single python file.
Additional guidelines:
- Users can configure their LLM API Key using an environment variable named "LLM_API_KEY"
- Unless otherwise specified, default to this model: openhands/claude-sonnet-4-20250514. This is configurable through the LLM_BASE_MODEL environment variable.
+10 -7
View File
@@ -1,5 +1,7 @@
import uuid
from openhands.sdk.conversation import visualizer
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace
@@ -9,7 +11,7 @@ from openhands.sdk.security.confirmation_policy import (
AlwaysConfirm,
)
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from openhands_cli.tui.visualizer import CLIVisualizer
# register tools
from openhands.tools.terminal import TerminalTool
@@ -73,11 +75,7 @@ def setup_conversation(
agent = load_agent_specs(str(conversation_id))
if not include_security_analyzer:
# Remove security analyzer from agent spec
agent = agent.model_copy(
update={"security_analyzer": None}
)
# Create conversation - agent context is now set in AgentStore.load()
conversation: BaseConversation = Conversation(
@@ -86,9 +84,14 @@ def setup_conversation(
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id,
visualizer=CLIVisualizer
)
if include_security_analyzer:
# Security analyzer is set though conversation API now
if not include_security_analyzer:
conversation.set_security_analyzer(None)
else:
conversation.set_security_analyzer(LLMSecurityAnalyzer())
conversation.set_confirmation_policy(AlwaysConfirm())
print_formatted_text(
@@ -38,6 +38,16 @@ class AgentStore:
str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
agent = Agent.model_validate_json(str_spec)
# Temporary to remove security analyzer from agent specs
# Security analyzer is set via conversation API now
# Doing this so that deprecation warning is thrown only the first time running CLI
if agent.security_analyzer:
agent = agent.model_copy(
update={"security_analyzer": None}
)
self.save(agent)
# Update tools with most recent working directory
updated_tools = get_default_tools(enable_browser=False)

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