mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
195 Commits
ctrl-c
...
fix/git-ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b0eae9e8 | ||
|
|
7a168b9b5f | ||
|
|
556ec9ab1a | ||
|
|
d567d22748 | ||
|
|
e045b757fa | ||
|
|
38ffc85470 | ||
|
|
58ea7b5248 | ||
|
|
f62ed911d2 | ||
|
|
d13e32bcec | ||
|
|
b978b71c47 | ||
|
|
dc2f5cd1b0 | ||
|
|
07041e057d | ||
|
|
6e91d19f80 | ||
|
|
936510e219 | ||
|
|
7af35ab827 | ||
|
|
a7245f2de2 | ||
|
|
6d7ab8a022 | ||
|
|
bbfa37fd97 | ||
|
|
d0cf12e474 | ||
|
|
78306b1ee7 | ||
|
|
f6d99234f1 | ||
|
|
19ca52f954 | ||
|
|
df75116184 | ||
|
|
acfb01d743 | ||
|
|
0daaf21607 | ||
|
|
95bda09cd9 | ||
|
|
1243612694 | ||
|
|
aff8aba5e6 | ||
|
|
aea37e52f7 | ||
|
|
f5674d7c76 | ||
|
|
9c68146b04 | ||
|
|
ee14f1ea41 | ||
|
|
b96301061d | ||
|
|
1281f2d6c2 | ||
|
|
dc41e0e90c | ||
|
|
793786130a | ||
|
|
59f03122c7 | ||
|
|
67edc66da7 | ||
|
|
cb910e6863 | ||
|
|
4c39e92351 | ||
|
|
e65e0a98f0 | ||
|
|
eecc00fa4a | ||
|
|
5654e251a8 | ||
|
|
d9694aabcd | ||
|
|
bc8ef37192 | ||
|
|
5f141f7712 | ||
|
|
30e3011cb0 | ||
|
|
3475d8021b | ||
|
|
32cd50db2f | ||
|
|
f0a6db936c | ||
|
|
11c37d8d70 | ||
|
|
7e1367057a | ||
|
|
3bbb0c6279 | ||
|
|
eed71c21bd | ||
|
|
4f46826de9 | ||
|
|
ea50fe4e3c | ||
|
|
b057af8d63 | ||
|
|
fba2218760 | ||
|
|
6147cbdc18 | ||
|
|
802acb3c7e | ||
|
|
376dc21e34 | ||
|
|
387318385c | ||
|
|
553f0a0918 | ||
|
|
0d1e21ae45 | ||
|
|
a885e9e4d2 | ||
|
|
4c10848e8d | ||
|
|
1d95b01514 | ||
|
|
cd32b5508c | ||
|
|
9a3bf0f2aa | ||
|
|
1d04a83e08 | ||
|
|
17e9b0fd6a | ||
|
|
54986c9841 | ||
|
|
c419277326 | ||
|
|
35b945b9d1 | ||
|
|
5c3619bc48 | ||
|
|
641d0a0bcb | ||
|
|
fbadea9a6f | ||
|
|
6e25d4bbb6 | ||
|
|
127220dc39 | ||
|
|
9a291e385b | ||
|
|
95ccec82d9 | ||
|
|
4aaa2ccd39 | ||
|
|
bfe0aa08b6 | ||
|
|
7fb47761c6 | ||
|
|
415931b4dc | ||
|
|
6d57eeb3ed | ||
|
|
c03d390772 | ||
|
|
a266d4274a | ||
|
|
a19cd193d9 | ||
|
|
4f3e648379 | ||
|
|
b99150c616 | ||
|
|
8937b3fbfc | ||
|
|
fb5a39a150 | ||
|
|
fc11c15b75 | ||
|
|
50a8741d50 | ||
|
|
9388fef0ef | ||
|
|
050e80cc34 | ||
|
|
5cc47ee592 | ||
|
|
a09346672f | ||
|
|
9e72b69cf8 | ||
|
|
da1f3a5a7b | ||
|
|
5c27a452ac | ||
|
|
8cb1c738ff | ||
|
|
cf276b2e96 | ||
|
|
1f416f616c | ||
|
|
52775acd4d | ||
|
|
be0596abd6 | ||
|
|
e77957aa92 | ||
|
|
d04c4c493e | ||
|
|
5cb534217a | ||
|
|
9331f5e8a7 | ||
|
|
8d16567428 | ||
|
|
acc69b74c5 | ||
|
|
28d174a7ce | ||
|
|
cff5697456 | ||
|
|
794eedf503 | ||
|
|
a6ffb2f799 | ||
|
|
3be3779f68 | ||
|
|
222f5fdd51 | ||
|
|
2066f90654 | ||
|
|
9ee2f976a1 | ||
|
|
be62df5277 | ||
|
|
4baf2a64c1 | ||
|
|
2a833325e1 | ||
|
|
aa2cacab44 | ||
|
|
ea07570f62 | ||
|
|
3f5a5005a2 | ||
|
|
7acee9e5da | ||
|
|
37cbeb735f | ||
|
|
c6c6c202f6 | ||
|
|
517a72fd0d | ||
|
|
7cfecb6e52 | ||
|
|
8fe2e006ee | ||
|
|
6d62c341eb | ||
|
|
229f35093d | ||
|
|
21a5e3eed5 | ||
|
|
97e3310dd5 | ||
|
|
2053e72474 | ||
|
|
300f20368e | ||
|
|
0bed046fcc | ||
|
|
0bf0dc9316 | ||
|
|
0e8d9a8bb4 | ||
|
|
9280bc34ad | ||
|
|
b132348d22 | ||
|
|
1be77faf94 | ||
|
|
a6301075ec | ||
|
|
b98615bc1c | ||
|
|
29fdc701a3 | ||
|
|
8bc9207c24 | ||
|
|
96008736a4 | ||
|
|
38d5db0547 | ||
|
|
8af1f1cac9 | ||
|
|
ef502ccba8 | ||
|
|
ece556c047 | ||
|
|
55a09785ce | ||
|
|
2990c21d97 | ||
|
|
14c8ea93c9 | ||
|
|
764077ef3d | ||
|
|
63ead2a638 | ||
|
|
be0049c76e | ||
|
|
bafd1596dd | ||
|
|
ce58ccab8a | ||
|
|
b3c8b7c089 | ||
|
|
ac2947b7ff | ||
|
|
91cd647f20 | ||
|
|
c521fb7a8f | ||
|
|
f049411631 | ||
|
|
606ec59b33 | ||
|
|
d2fc5679ad | ||
|
|
7bfa05d38a | ||
|
|
12a95fb548 | ||
|
|
ae03c4eb80 | ||
|
|
8e486dfd6b | ||
|
|
48ee5659c9 | ||
|
|
b7613d7529 | ||
|
|
e05e627957 | ||
|
|
6da7e051be | ||
|
|
002e12a049 | ||
|
|
ed58858e03 | ||
|
|
11ae4f96c2 | ||
|
|
c2acf4e07e | ||
|
|
e9bdf761b7 | ||
|
|
04b93069b4 | ||
|
|
ec03ce1ca0 | ||
|
|
46157a85d8 | ||
|
|
a691e3148a | ||
|
|
4674e0b77a | ||
|
|
d7d0329d25 | ||
|
|
17853cd5bd | ||
|
|
c992b6d2a0 | ||
|
|
34bf645d64 | ||
|
|
1ae1c16b26 | ||
|
|
5099413729 | ||
|
|
b06a3bdb7c | ||
|
|
a7b234d1f6 |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -3,6 +3,7 @@
|
||||
|
||||
# Frontend code owners
|
||||
/frontend/ @rbren @amanape
|
||||
/openhands-ui/ @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
8
.github/workflows/fe-unit-tests.yml
vendored
8
.github/workflows/fe-unit-tests.yml
vendored
@@ -9,8 +9,8 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/fe-unit-tests.yml'
|
||||
- "frontend/**"
|
||||
- ".github/workflows/fe-unit-tests.yml"
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
node-version: 22
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Run TypeScript compilation
|
||||
working-directory: ./frontend
|
||||
run: npm run make-i18n && tsc
|
||||
run: npm run build
|
||||
- name: Run tests and collect coverage
|
||||
working-directory: ./frontend
|
||||
run: npm run test:coverage
|
||||
|
||||
130
.github/workflows/ghcr-build.yml
vendored
130
.github/workflows/ghcr-build.yml
vendored
@@ -40,7 +40,8 @@ jobs:
|
||||
# Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu.
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
]')
|
||||
else
|
||||
json=$(jq -n -c '[
|
||||
@@ -54,12 +55,10 @@ jobs:
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
# Since this job uses outputs it cannot use matrix
|
||||
hash_from_app_image: ${{ steps.get_hash_in_app_image.outputs.hash_from_app_image }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -85,24 +84,12 @@ jobs:
|
||||
if: "!github.event.pull_request.head.repo.fork"
|
||||
run: |
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
|
||||
- name: Build app image
|
||||
if: "github.event.pull_request.head.repo.fork"
|
||||
run: |
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --load
|
||||
- name: Get hash in App Image
|
||||
id: get_hash_in_app_image
|
||||
run: |
|
||||
# Run the build script in the app image
|
||||
docker run -e SANDBOX_USER_ID=0 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/${{ env.REPO_OWNER }}/openhands:${{ env.RELEVANT_SHA }} /bin/bash -c "mkdir -p containers/runtime; python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild" 2>&1 | tee docker-outputs.txt
|
||||
# Get the hash from the build script
|
||||
hash_from_app_image=$(cat docker-outputs.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
|
||||
echo "hash_from_app_image=$hash_from_app_image" >> $GITHUB_OUTPUT
|
||||
echo "Hash from app image: $hash_from_app_image"
|
||||
|
||||
# Builds the runtime Docker images
|
||||
ghcr_build_runtime:
|
||||
name: Build Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -128,22 +115,13 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Cache Poetry dependencies
|
||||
uses: useblacksmith/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pypoetry
|
||||
~/.virtualenvs
|
||||
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
|
||||
# This is the one that saves the cache, the others set 'lookup-only: true'
|
||||
restore-keys: |
|
||||
${{ runner.os }}-poetry-
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
|
||||
- name: Create source distribution and Dockerfile
|
||||
@@ -188,61 +166,6 @@ jobs:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
|
||||
verify_hash_equivalence_in_runtime_and_app:
|
||||
name: Verify Hash Equivalence in Runtime and Docker images
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [ghcr_build_runtime, ghcr_build_app]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
base_image: ['nikolaik']
|
||||
env:
|
||||
BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST: nikolaik/python-nodejs:python3.12-nodejs22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Cache Poetry dependencies
|
||||
uses: useblacksmith/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pypoetry
|
||||
~/.virtualenvs
|
||||
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
|
||||
lookup-only: true
|
||||
restore-keys: |
|
||||
${{ runner.os }}-poetry-
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
|
||||
- name: Get hash in App Image
|
||||
run: |
|
||||
echo "Hash from app image: ${{ needs.ghcr_build_app.outputs.hash_from_app_image }}"
|
||||
echo "hash_from_app_image=${{ needs.ghcr_build_app.outputs.hash_from_app_image }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get hash using code (development mode)
|
||||
run: |
|
||||
mkdir -p containers/runtime
|
||||
poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild > output.txt 2>&1
|
||||
hash_from_code=$(cat output.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
|
||||
echo "hash_from_code=$hash_from_code" >> $GITHUB_ENV
|
||||
|
||||
- name: Compare hashes
|
||||
run: |
|
||||
echo "Hash from App Image: ${{ env.hash_from_app_image }}"
|
||||
echo "Hash from Code: ${{ env.hash_from_code }}"
|
||||
if [ "${{ env.hash_from_app_image }}" = "${{ env.hash_from_code }}" ]; then
|
||||
echo "Hashes match!"
|
||||
else
|
||||
echo "Hashes do not match!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run unit tests with the Docker runtime Docker images as root
|
||||
test_runtime_root:
|
||||
name: RT Unit Tests (Root)
|
||||
@@ -274,25 +197,17 @@ jobs:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Cache Poetry dependencies
|
||||
uses: useblacksmith/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pypoetry
|
||||
~/.virtualenvs
|
||||
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
|
||||
lookup-only: true
|
||||
restore-keys: |
|
||||
${{ runner.os }}-poetry-
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
|
||||
- name: Run docker runtime tests
|
||||
shell: bash
|
||||
run: |
|
||||
# We install pytest-xdist in order to run tests across CPUs
|
||||
poetry run pip install pytest-xdist
|
||||
@@ -344,25 +259,17 @@ jobs:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Cache Poetry dependencies
|
||||
uses: useblacksmith/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pypoetry
|
||||
~/.virtualenvs
|
||||
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
|
||||
lookup-only: true
|
||||
restore-keys: |
|
||||
${{ runner.os }}-poetry-
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
|
||||
- name: Run runtime tests
|
||||
shell: bash
|
||||
run: |
|
||||
# We install pytest-xdist in order to run tests across CPUs
|
||||
poetry run pip install pytest-xdist
|
||||
@@ -389,7 +296,7 @@ jobs:
|
||||
name: All Runtime Tests Passed
|
||||
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
|
||||
needs: [test_runtime_root, test_runtime_oh]
|
||||
steps:
|
||||
- name: All tests passed
|
||||
run: echo "All runtime tests have passed successfully!"
|
||||
@@ -398,7 +305,7 @@ jobs:
|
||||
name: All Runtime Tests Passed
|
||||
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
|
||||
needs: [test_runtime_root, test_runtime_oh]
|
||||
steps:
|
||||
- name: Some tests failed
|
||||
run: |
|
||||
@@ -423,6 +330,7 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "updating PR description"
|
||||
DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
|
||||
6
.github/workflows/integration-runner.yml
vendored
6
.github/workflows/integration-runner.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
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
|
||||
run: poetry install --with dev,test,runtime,evaluation
|
||||
|
||||
- name: Configure config.toml for testing with Haiku
|
||||
env:
|
||||
@@ -179,8 +179,8 @@ jobs:
|
||||
id: create_comment
|
||||
uses: KeisukeYamashita/create-comment@v1
|
||||
with:
|
||||
# if triggered by PR, use PR number, otherwise use 5318 as fallback issue number for manual triggers
|
||||
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 5318 }}
|
||||
# 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' }}
|
||||
|
||||
6
.github/workflows/lint-fix.yml
vendored
6
.github/workflows/lint-fix.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Node.js 20
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Fix python lint issues
|
||||
|
||||
8
.github/workflows/lint.yml
vendored
8
.github/workflows/lint.yml
vendored
@@ -7,7 +7,7 @@ name: Lint
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
@@ -22,10 +22,10 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 20
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Run pre-commit hooks
|
||||
|
||||
108
.github/workflows/npm-publish-ui.yml
vendored
Normal file
108
.github/workflows/npm-publish-ui.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
name: Publish OpenHands UI Package
|
||||
|
||||
# * Always run on "main"
|
||||
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "openhands-ui/**"
|
||||
- ".github/workflows/npm-publish-ui.yml"
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: npm-publish-ui
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check if version has changed
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
should-publish: ${{ steps.version-check.outputs.should-publish }}
|
||||
current-version: ${{ steps.version-check.outputs.current-version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
- name: Check if version changed
|
||||
id: version-check
|
||||
run: |
|
||||
# Get current version from package.json
|
||||
CURRENT_VERSION=$(jq -r .version openhands-ui/package.json)
|
||||
echo "current-version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if package.json version changed in this commit
|
||||
if git diff HEAD~1 HEAD --name-only | grep -q "openhands-ui/package.json"; then
|
||||
# Check if the version field specifically changed
|
||||
if git diff HEAD~1 HEAD openhands-ui/package.json | grep -q '"version"'; then
|
||||
echo "Version changed in package.json, will publish"
|
||||
echo "should-publish=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "package.json changed but version did not change, skipping publish"
|
||||
echo "should-publish=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "package.json did not change, skipping publish"
|
||||
echo "should-publish=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should-publish == 'true'
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: "openhands-ui/.bun-version"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./openhands-ui
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build package
|
||||
working-directory: ./openhands-ui
|
||||
run: bun run build
|
||||
|
||||
- name: Check if package already exists on npm
|
||||
id: npm-check
|
||||
working-directory: ./openhands-ui
|
||||
run: |
|
||||
PACKAGE_NAME=$(jq -r .name package.json)
|
||||
VERSION="${{ needs.check-version.outputs.current-version }}"
|
||||
|
||||
# Check if this version already exists on npm
|
||||
if npm view "$PACKAGE_NAME@$VERSION" version 2>/dev/null; then
|
||||
echo "Version $VERSION already exists on npm, skipping publish"
|
||||
echo "already-exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Version $VERSION does not exist on npm, proceeding with publish"
|
||||
echo "already-exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup npm authentication
|
||||
if: steps.npm-check.outputs.already-exists == 'false'
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.npm-check.outputs.already-exists == 'false'
|
||||
working-directory: ./openhands-ui
|
||||
run: |
|
||||
# The prepublishOnly script will run automatically and build the package
|
||||
npm publish
|
||||
echo "✅ Successfully published @openhands/ui@${{ needs.check-version.outputs.current-version }} to npm"
|
||||
@@ -1,5 +1,5 @@
|
||||
# Workflow that runs python unit tests
|
||||
name: Run Python Unit Tests
|
||||
# Workflow that runs python tests
|
||||
name: Run Python Tests
|
||||
|
||||
# The jobs in this workflow are required, so they must run at all times
|
||||
# * Always run on "main"
|
||||
@@ -16,9 +16,9 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Run python unit tests on Linux
|
||||
# Run python tests on Linux
|
||||
test-on-linux:
|
||||
name: Python Unit Tests on Linux
|
||||
name: Python Tests on Linux
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
env:
|
||||
INSTALL_DOCKER: '0' # Set to '0' to skip Docker installation
|
||||
@@ -51,6 +51,8 @@ jobs:
|
||||
run: poetry run pytest --forked -n auto -svv ./tests/unit
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
|
||||
- name: Run E2E Tests
|
||||
run: poetry run pytest -svv tests/e2e
|
||||
|
||||
# Run specific Windows python tests
|
||||
test-on-windows:
|
||||
121
.github/workflows/run-eval.yml
vendored
121
.github/workflows/run-eval.yml
vendored
@@ -1,56 +1,135 @@
|
||||
# Run evaluation on a PR
|
||||
# Run evaluation on a PR, after releases, or manually
|
||||
name: Run Eval
|
||||
|
||||
# Runs when a PR is labeled with one of the "run-eval-" labels
|
||||
# Runs when a PR is labeled with one of the "run-eval-" labels, after releases, or manually triggered
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to evaluate'
|
||||
required: true
|
||||
default: 'main'
|
||||
eval_instances:
|
||||
description: 'Number of evaluation instances'
|
||||
required: true
|
||||
default: '50'
|
||||
type: choice
|
||||
options:
|
||||
- '1'
|
||||
- '2'
|
||||
- '50'
|
||||
- '100'
|
||||
reason:
|
||||
description: 'Reason for manual trigger'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
# Environment variable for the master GitHub issue number where all evaluation results will be commented
|
||||
# This should be set to the issue number where you want all evaluation results to be posted
|
||||
MASTER_EVAL_ISSUE_NUMBER: ${{ vars.MASTER_EVAL_ISSUE_NUMBER || '0' }}
|
||||
|
||||
jobs:
|
||||
trigger-job:
|
||||
name: Trigger remote eval job
|
||||
if: ${{ github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100' }}
|
||||
if: ${{ (github.event_name == 'pull_request' && (github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100')) || github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
- name: Checkout branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.head_ref || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch) || github.ref }}
|
||||
|
||||
- name: Trigger remote job
|
||||
env:
|
||||
PR_BRANCH: ${{ github.head_ref }}
|
||||
- name: Set evaluation parameters
|
||||
id: eval_params
|
||||
run: |
|
||||
REPO_URL="https://github.com/${{ github.repository }}"
|
||||
echo "Repository URL: $REPO_URL"
|
||||
echo "PR Branch: $PR_BRANCH"
|
||||
|
||||
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
|
||||
EVAL_INSTANCES="1"
|
||||
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
|
||||
EVAL_INSTANCES="2"
|
||||
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
|
||||
# Determine branch based on trigger type
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
EVAL_BRANCH="${{ github.head_ref }}"
|
||||
echo "PR Branch: $EVAL_BRANCH"
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
EVAL_BRANCH="${{ github.event.inputs.branch }}"
|
||||
echo "Manual Branch: $EVAL_BRANCH"
|
||||
else
|
||||
# For release events, use the tag name or main branch
|
||||
EVAL_BRANCH="${{ github.ref_name }}"
|
||||
echo "Release Branch/Tag: $EVAL_BRANCH"
|
||||
fi
|
||||
|
||||
# Determine evaluation instances based on trigger type
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
|
||||
EVAL_INSTANCES="1"
|
||||
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
|
||||
EVAL_INSTANCES="2"
|
||||
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
|
||||
EVAL_INSTANCES="50"
|
||||
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
|
||||
EVAL_INSTANCES="100"
|
||||
fi
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
EVAL_INSTANCES="${{ github.event.inputs.eval_instances }}"
|
||||
else
|
||||
# For release events, default to 50 instances
|
||||
EVAL_INSTANCES="50"
|
||||
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
|
||||
EVAL_INSTANCES="100"
|
||||
fi
|
||||
|
||||
echo "Evaluation instances: $EVAL_INSTANCES"
|
||||
echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
|
||||
echo "eval_branch=$EVAL_BRANCH" >> $GITHUB_OUTPUT
|
||||
echo "eval_instances=$EVAL_INSTANCES" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
# Determine PR number for the remote evaluation system
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
else
|
||||
# For non-PR triggers, use the master issue number as PR number
|
||||
PR_NUMBER="${{ env.MASTER_EVAL_ISSUE_NUMBER }}"
|
||||
fi
|
||||
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${REPO_URL}\", \"github-branch\": \"${PR_BRANCH}\", \"pr-number\": \"${{ github.event.pull_request.number }}\", \"eval-instances\": \"${EVAL_INSTANCES}\"}}" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${{ steps.eval_params.outputs.repo_url }}\", \"github-branch\": \"${{ steps.eval_params.outputs.eval_branch }}\", \"pr-number\": \"${PR_NUMBER}\", \"eval-instances\": \"${{ steps.eval_params.outputs.eval_instances }}\"}}" \
|
||||
https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches
|
||||
|
||||
# Send Slack message
|
||||
PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
|
||||
slack_text="PR $PR_URL has triggered evaluation on $EVAL_INSTANCES instances..."
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
TRIGGER_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
|
||||
slack_text="PR $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
|
||||
elif [[ "${{ github.event_name }}" == "release" ]]; then
|
||||
TRIGGER_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
|
||||
slack_text="Release $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
|
||||
else
|
||||
TRIGGER_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
slack_text="Manual trigger (${{ github.event.inputs.reason || 'No reason provided' }}) has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances for branch ${{ steps.eval_params.outputs.eval_branch }}..."
|
||||
fi
|
||||
|
||||
curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \
|
||||
https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }}
|
||||
|
||||
- name: Comment on PR
|
||||
- name: Comment on issue/PR
|
||||
uses: KeisukeYamashita/create-comment@v1
|
||||
with:
|
||||
# For PR triggers, comment on the PR. For other triggers, comment on the master issue
|
||||
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || env.MASTER_EVAL_ISSUE_NUMBER }}
|
||||
unique: false
|
||||
comment: |
|
||||
Running evaluation on the PR. Once eval is done, the results will be posted.
|
||||
**Evaluation Triggered**
|
||||
|
||||
**Trigger:** ${{ github.event_name == 'pull_request' && format('Pull Request #{0}', github.event.pull_request.number) || (github.event_name == 'release' && 'Release') || format('Manual Trigger: {0}', github.event.inputs.reason || 'No reason provided') }}
|
||||
**Branch:** ${{ steps.eval_params.outputs.eval_branch }}
|
||||
**Instances:** ${{ steps.eval_params.outputs.eval_instances }}
|
||||
**Commit:** ${{ github.sha }}
|
||||
|
||||
Running evaluation on the specified branch. Once eval is done, the results will be posted here.
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
days-before-stale: 30
|
||||
exempt-issue-labels: 'tracked'
|
||||
exempt-issue-labels: 'roadmap'
|
||||
close-issue-message: 'This issue was closed because it has been stalled for over 30 days with no activity.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for over 30 days with no activity.'
|
||||
days-before-close: 7
|
||||
|
||||
34
.github/workflows/ui-build.yml
vendored
Normal file
34
.github/workflows/ui-build.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Run UI Component Build
|
||||
|
||||
# * Always run on "main"
|
||||
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openhands-ui/**'
|
||||
- '.github/workflows/ui-build.yml'
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ui-build:
|
||||
name: Build openhands-ui
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: "openhands-ui/.bun-version"
|
||||
- name: Install dependencies
|
||||
working-directory: ./openhands-ui
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build package
|
||||
working-directory: ./openhands-ui
|
||||
run: bun run build
|
||||
156
.github/workflows/vscode-extension-build.yml
vendored
Normal file
156
.github/workflows/vscode-extension-build.yml
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
# Workflow that validates the VSCode extension builds correctly
|
||||
name: VSCode Extension CI
|
||||
|
||||
# * Always run on "main"
|
||||
# * Run on PRs that have changes in the VSCode extension folder or this workflow
|
||||
# * Run on tags that start with "ext-v"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'ext-v*'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openhands/integrations/vscode/**'
|
||||
- 'build_vscode.py'
|
||||
- '.github/workflows/vscode-extension-build.yml'
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Validate VSCode extension builds correctly
|
||||
validate-vscode-extension:
|
||||
name: Validate VSCode Extension Build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install VSCode extension dependencies
|
||||
working-directory: ./openhands/integrations/vscode
|
||||
run: npm ci
|
||||
|
||||
- name: Build VSCode extension via build_vscode.py
|
||||
run: python build_vscode.py
|
||||
env:
|
||||
# Ensure we don't skip the build
|
||||
SKIP_VSCODE_BUILD: ""
|
||||
|
||||
- name: Validate .vsix file
|
||||
run: |
|
||||
# Verify the .vsix was created and is valid
|
||||
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
|
||||
echo "✅ VSCode extension built successfully"
|
||||
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
# Basic validation that the .vsix is a valid zip file
|
||||
echo "🔍 Validating .vsix structure..."
|
||||
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
echo "✅ VSCode extension validation passed"
|
||||
else
|
||||
echo "❌ VSCode extension build failed - .vsix not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload VSCode extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment on PR with artifact link
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Get file size for display
|
||||
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
|
||||
const stats = fs.statSync(vsixPath);
|
||||
const fileSizeKB = Math.round(stats.size / 1024);
|
||||
|
||||
const comment = `## 🔧 VSCode Extension Built Successfully!
|
||||
|
||||
The VSCode extension has been built and is ready for testing.
|
||||
|
||||
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
|
||||
|
||||
**🚀 To install**:
|
||||
1. Download the artifact from the workflow run above
|
||||
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
|
||||
3. Select the downloaded \`.vsix\` file
|
||||
|
||||
**✅ Tested with**: Node.js 22
|
||||
**🔍 Validation**: File structure and integrity verified
|
||||
|
||||
---
|
||||
*Built from commit ${{ github.sha }}*`;
|
||||
|
||||
// Check if we already commented on this PR and delete it
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('VSCode Extension Built Successfully')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: validate-vscode-extension
|
||||
if: startsWith(github.ref, 'refs/tags/ext-v')
|
||||
|
||||
steps:
|
||||
- name: Download .vsix artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: ./
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1.16.0
|
||||
with:
|
||||
artifacts: "*.vsix"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -182,6 +182,8 @@ cython_debug/
|
||||
.roo/rules
|
||||
.cline/rules
|
||||
.windsurf/rules
|
||||
.repomix
|
||||
repomix-output.txt
|
||||
|
||||
# evaluation
|
||||
evaluation/evaluation_outputs
|
||||
|
||||
@@ -15,10 +15,13 @@ make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.
|
||||
|
||||
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
|
||||
|
||||
|
||||
|
||||
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
|
||||
|
||||
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
|
||||
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
|
||||
* If you've made changes to the VSCode extension, you should run `cd openhands/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
|
||||
|
||||
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
|
||||
|
||||
@@ -60,6 +63,22 @@ Frontend:
|
||||
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
|
||||
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
|
||||
|
||||
VSCode Extension:
|
||||
- Located in the `openhands/integrations/vscode` directory
|
||||
- Setup: Run `npm install` in the extension directory
|
||||
- Linting:
|
||||
- Run linting with fixes: `npm run lint:fix`
|
||||
- Check only: `npm run lint`
|
||||
- Type checking: `npm run typecheck`
|
||||
- Building:
|
||||
- Compile TypeScript: `npm run compile`
|
||||
- Package extension: `npm run package-vsix`
|
||||
- Testing:
|
||||
- Run tests: `npm run test`
|
||||
- Development Best Practices:
|
||||
- Use `vscode.window.createOutputChannel()` for debug logging instead of `showErrorMessage()` popups
|
||||
- Pre-commit process runs both frontend and backend checks when committing extension changes
|
||||
|
||||
## Template for Github Pull Request
|
||||
|
||||
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
|
||||
@@ -118,3 +137,65 @@ Your specialized knowledge and instructions here...
|
||||
2. Add the setting to the backend:
|
||||
- Add the setting to the `Settings` model in `openhands/storage/data_models/settings.py`
|
||||
- Update any relevant backend code to apply the setting (e.g., in session creation)
|
||||
|
||||
### Adding New LLM Models
|
||||
|
||||
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:
|
||||
|
||||
#### Model Configuration Procedure:
|
||||
|
||||
1. **Frontend Model Arrays** (`frontend/src/utils/verified-models.ts`):
|
||||
- Add the model to `VERIFIED_MODELS` array (main list of all verified models)
|
||||
- Add to provider-specific arrays based on the model's provider:
|
||||
- `VERIFIED_OPENAI_MODELS` for OpenAI models
|
||||
- `VERIFIED_ANTHROPIC_MODELS` for Anthropic models
|
||||
- `VERIFIED_MISTRAL_MODELS` for Mistral models
|
||||
- `VERIFIED_OPENHANDS_MODELS` for models available through OpenHands provider
|
||||
|
||||
2. **Backend CLI Integration** (`openhands/cli/utils.py`):
|
||||
- Add the model to the appropriate `VERIFIED_*_MODELS` arrays
|
||||
- This ensures the model appears in CLI model selection
|
||||
|
||||
3. **Backend Model List** (`openhands/utils/llm.py`):
|
||||
- **CRITICAL**: Add the model to the `openhands_models` list (lines 57-66) if using OpenHands provider
|
||||
- This is required for the model to appear in the frontend model selector
|
||||
- Format: `'openhands/model-name'` (e.g., `'openhands/o3'`)
|
||||
|
||||
4. **Backend LLM Configuration** (`openhands/llm/llm.py`):
|
||||
- Add to feature-specific arrays based on model capabilities:
|
||||
- `FUNCTION_CALLING_SUPPORTED_MODELS` if the model supports function calling
|
||||
- `REASONING_EFFORT_SUPPORTED_MODELS` if the model supports reasoning effort parameters
|
||||
- `CACHE_PROMPT_SUPPORTED_MODELS` if the model supports prompt caching
|
||||
- `MODELS_WITHOUT_STOP_WORDS` if the model doesn't support stop words
|
||||
|
||||
5. **Validation**:
|
||||
- Run backend linting: `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml`
|
||||
- Run frontend linting: `cd frontend && npm run lint:fix`
|
||||
- Run frontend build: `cd frontend && npm run build`
|
||||
|
||||
#### Model Verification Arrays:
|
||||
|
||||
- **VERIFIED_MODELS**: Main array of all verified models shown in the UI
|
||||
- **VERIFIED_OPENAI_MODELS**: OpenAI models (LiteLLM doesn't return provider prefix)
|
||||
- **VERIFIED_ANTHROPIC_MODELS**: Anthropic models (LiteLLM doesn't return provider prefix)
|
||||
- **VERIFIED_MISTRAL_MODELS**: Mistral models (LiteLLM doesn't return provider prefix)
|
||||
- **VERIFIED_OPENHANDS_MODELS**: Models available through OpenHands managed provider
|
||||
|
||||
#### Model Feature Support Arrays:
|
||||
|
||||
- **FUNCTION_CALLING_SUPPORTED_MODELS**: Models that support structured function calling
|
||||
- **REASONING_EFFORT_SUPPORTED_MODELS**: Models that support reasoning effort parameters (like o1, o3)
|
||||
- **CACHE_PROMPT_SUPPORTED_MODELS**: Models that support prompt caching for efficiency
|
||||
- **MODELS_WITHOUT_STOP_WORDS**: Models that don't support stop word parameters
|
||||
|
||||
#### Frontend Model Integration:
|
||||
|
||||
- Models are automatically available in the model selector UI once added to verified arrays
|
||||
- The `extractModelAndProvider` utility automatically detects provider from model arrays
|
||||
- Provider-specific models are grouped and prioritized in the UI selection
|
||||
|
||||
#### CLI Model Integration:
|
||||
|
||||
- Models appear in CLI provider selection based on the verified arrays
|
||||
- The `organize_models_and_providers` function groups models by provider
|
||||
- Default model selection prioritizes verified models for each provider
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Running OpenHands pre-commit hook..."
|
||||
echo "This hook runs 'make lint' to ensure code quality before committing."
|
||||
|
||||
# Store the exit code to return at the end
|
||||
# This allows us to be additive to existing pre-commit hooks
|
||||
EXIT_CODE=0
|
||||
|
||||
# Run make lint to check both frontend and backend code
|
||||
echo "Running linting checks with 'make lint'..."
|
||||
make lint
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Linting failed. Please fix the issues before committing."
|
||||
EXIT_CODE=1
|
||||
else
|
||||
echo "Linting checks passed!"
|
||||
fi
|
||||
|
||||
# Check if frontend directory has changed
|
||||
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
|
||||
if [ -n "$frontend_changes" ]; then
|
||||
echo "Frontend changes detected. Running frontend checks..."
|
||||
echo "Frontend changes detected. Running additional frontend checks..."
|
||||
|
||||
# Check if frontend directory exists
|
||||
if [ -d "frontend" ]; then
|
||||
# Change to frontend directory
|
||||
cd frontend || exit 1
|
||||
|
||||
# Run lint:fix
|
||||
echo "Running npm lint:fix..."
|
||||
npm run lint:fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Frontend linting failed. Please fix the issues before committing."
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
# Run build
|
||||
echo "Running npm build..."
|
||||
npm run build
|
||||
@@ -50,7 +53,7 @@ if [ -n "$frontend_changes" ]; then
|
||||
echo "Frontend directory not found. Skipping frontend checks."
|
||||
fi
|
||||
else
|
||||
echo "No frontend changes detected. Skipping frontend checks."
|
||||
echo "No frontend changes detected. Skipping additional frontend checks."
|
||||
fi
|
||||
|
||||
# Run any existing pre-commit hooks that might have been installed by the user
|
||||
|
||||
@@ -31,7 +31,7 @@ We're always looking to improve the look and feel of the application. If you've
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #frontend channel in our Slack
|
||||
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
|
||||
to gather consensus from our design team first.
|
||||
|
||||
#### Improving the agent
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.47-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.49-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -62,17 +62,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
114
build_vscode.py
Normal file
114
build_vscode.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
# This script is intended to be run by Poetry during the build process.
|
||||
|
||||
# Define the expected name of the .vsix file based on the extension's package.json
|
||||
# This should match the name and version in openhands-vscode/package.json
|
||||
EXTENSION_NAME = 'openhands-vscode'
|
||||
EXTENSION_VERSION = '0.0.1'
|
||||
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
|
||||
|
||||
# Paths
|
||||
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
|
||||
|
||||
|
||||
def check_node_version():
|
||||
"""Check if Node.js version is sufficient for building the extension."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['node', '--version'], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Extract major version number (e.g., "v12.22.9" -> 12)
|
||||
major_version = int(version_str.lstrip('v').split('.')[0])
|
||||
return major_version >= 18 # Align with frontend actual usage (18.20.1)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def build_vscode_extension():
|
||||
"""Builds the VS Code extension."""
|
||||
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
|
||||
|
||||
# Check if VSCode extension build is disabled via environment variable
|
||||
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
|
||||
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
|
||||
if vsix_path.exists():
|
||||
print(f'--- Using existing VS Code extension: {vsix_path} ---')
|
||||
else:
|
||||
print('--- No pre-built VS Code extension found ---')
|
||||
return
|
||||
|
||||
# Check Node.js version - if insufficient, use pre-built extension as fallback
|
||||
if not check_node_version():
|
||||
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
|
||||
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
|
||||
print('--- Using pre-built extension if available ---')
|
||||
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
else:
|
||||
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
|
||||
return
|
||||
|
||||
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
|
||||
|
||||
try:
|
||||
# Ensure npm dependencies are installed
|
||||
print('--- Running npm install for VS Code extension ---')
|
||||
subprocess.run(
|
||||
['npm', 'install'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Package the extension
|
||||
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
|
||||
subprocess.run(
|
||||
['npm', 'run', 'package-vsix'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Verify the generated .vsix file exists
|
||||
if not vsix_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f'VS Code extension package not found after build: {vsix_path}'
|
||||
)
|
||||
|
||||
print(f'--- VS Code extension built successfully: {vsix_path} ---')
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
|
||||
print('--- Continuing without building extension ---')
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
|
||||
|
||||
def build(setup_kwargs):
|
||||
"""
|
||||
This function is called by Poetry during the build process.
|
||||
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
|
||||
"""
|
||||
print('--- Running custom Poetry build script (build_vscode.py) ---')
|
||||
|
||||
# Build the VS Code extension and place the .vsix file
|
||||
build_vscode_extension()
|
||||
|
||||
# Poetry will handle including files based on pyproject.toml `include` patterns.
|
||||
# Ensure openhands/integrations/vscode/*.vsix is included there.
|
||||
|
||||
print('--- Custom Poetry build script (build_vscode.py) finished ---')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Running build_vscode.py directly for testing VS Code extension packaging...')
|
||||
build_vscode_extension()
|
||||
print('Direct execution of build_vscode.py finished.')
|
||||
@@ -18,9 +18,6 @@
|
||||
# Cache directory path
|
||||
#cache_dir = "/tmp/cache"
|
||||
|
||||
# Reasoning effort for o1 models (low, medium, high, or not set)
|
||||
#reasoning_effort = "medium"
|
||||
|
||||
# Debugging enabled
|
||||
#debug = false
|
||||
|
||||
@@ -49,6 +46,9 @@
|
||||
# Maximum file size for uploads, in megabytes
|
||||
#file_uploads_max_file_size_mb = 0
|
||||
|
||||
# Enable the browser environment
|
||||
#enable_browser = true
|
||||
|
||||
# Maximum budget per task, 0.0 means no limit
|
||||
#max_budget_per_task = 0.0
|
||||
|
||||
@@ -116,6 +116,9 @@ api_key = ""
|
||||
# API version
|
||||
#api_version = ""
|
||||
|
||||
# Reasoning effort for OpenAI o-series models (low, medium, high, or not set)
|
||||
#reasoning_effort = "medium"
|
||||
|
||||
# Cost per input token
|
||||
#input_cost_per_token = 0.0
|
||||
|
||||
@@ -226,6 +229,7 @@ model = "gpt-4o"
|
||||
[agent]
|
||||
|
||||
# Whether the browsing tool is enabled
|
||||
# Note: when this is set to true, enable_browser in the core config must also be true
|
||||
enable_browsing = true
|
||||
|
||||
# Whether the LLM draft editor is enabled
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG OPENHANDS_BUILD_VERSION=dev
|
||||
FROM node:22.16.0-bookworm-slim AS frontend-builder
|
||||
FROM node:24.3.0-bookworm-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -45,6 +45,7 @@ ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
|
||||
ENV SANDBOX_USER_ID=0
|
||||
ENV FILE_STORE=local
|
||||
ENV FILE_STORE_PATH=/.openhands
|
||||
ENV INIT_GIT_IN_EMPTY_WORKSPACE=1
|
||||
RUN mkdir -p $FILE_STORE_PATH
|
||||
RUN mkdir -p $WORKSPACE_BASE
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.47-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.49-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.49-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:
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{
|
||||
"group": "Integrations",
|
||||
"pages": [
|
||||
"usage/cloud/bitbucket-installation",
|
||||
"usage/cloud/github-installation",
|
||||
"usage/cloud/gitlab-installation",
|
||||
"usage/cloud/slack-installation"
|
||||
@@ -66,7 +67,9 @@
|
||||
"usage/llms/groq",
|
||||
"usage/llms/local-llms",
|
||||
"usage/llms/litellm-proxy",
|
||||
"usage/llms/moonshot",
|
||||
"usage/llms/openai-llms",
|
||||
"usage/llms/openhands-llms",
|
||||
"usage/llms/openrouter"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1827,6 +1827,11 @@
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"owner_type": {
|
||||
"type": "string",
|
||||
"enum": ["user", "organization"],
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
BIN
docs/static/img/connect-repo-no-github.png
vendored
Normal file
BIN
docs/static/img/connect-repo-no-github.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/static/img/openhands-llm-api-key.png
vendored
Normal file
BIN
docs/static/img/openhands-llm-api-key.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/static/img/openhands-provider-cli.png
vendored
Normal file
BIN
docs/static/img/openhands-provider-cli.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
25
docs/usage/cloud/bitbucket-installation.mdx
Normal file
25
docs/usage/cloud/bitbucket-installation.mdx
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Bitbucket Integration
|
||||
description: This guide walks you through the process of installing OpenHands Cloud for your Bitbucket repositories. Once
|
||||
set up, it will allow OpenHands to work with your Bitbucket repository.
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
|
||||
|
||||
## Adding Bitbucket Repository Access
|
||||
|
||||
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
|
||||
|
||||
## Working With Bitbucket Repos in Openhands Cloud
|
||||
|
||||
After signing in with a Bitbucket account, use the `select a repo` and `select a branch` dropdowns to select the
|
||||
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
|
||||
|
||||

|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
@@ -9,8 +9,9 @@ description: The Cloud UI provides a web interface for interacting with OpenHand
|
||||
The landing page is where you can:
|
||||
|
||||
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
|
||||
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud) or
|
||||
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
|
||||
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud),
|
||||
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) or
|
||||
[a Bitbucket repo](/usage/cloud/bitbucket-installation#working-with-bitbucket-repos-in-openhands-cloud) to start working on.
|
||||
- See `Suggested Tasks` for repositories that OpenHands has access to.
|
||||
- Launch an empty conversation using `Launch from Scratch`.
|
||||
|
||||
|
||||
@@ -51,8 +51,7 @@ Giving GitHub repository access to OpenHands also allows you to work on GitHub i
|
||||
|
||||
### Working with Issues
|
||||
|
||||
On your repository, label an issue with `openhands` or add a message starting with
|
||||
`@openhands`. OpenHands will:
|
||||
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
|
||||
1. Comment on the issue to let you know it is working on it.
|
||||
- You can click on the link to track the progress on OpenHands Cloud.
|
||||
2. Open a pull request if it determines that the issue has been successfully resolved.
|
||||
@@ -65,6 +64,8 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
|
||||
**Important Note**: The `@openhands` mention functionality in pull requests only works if the pull request is both *to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate permissions to access both repositories.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: GitLab Integration
|
||||
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
|
||||
set up, it will allow OpenHands to work with your GitLab repository.
|
||||
set up, it will allow OpenHands to work with your GitLab repository through the Cloud UI or straight from GitLab!.
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
@@ -17,7 +17,7 @@ Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have acc
|
||||
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
|
||||
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
|
||||
|
||||

|
||||

|
||||
|
||||
## Using Tokens with Reduced Scopes
|
||||
|
||||
@@ -25,6 +25,33 @@ OpenHands requests an API-scoped token during OAuth authentication. By default,
|
||||
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
|
||||
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
|
||||
|
||||
## Working on GitLab Issues and Merge Requests Using Openhands
|
||||
|
||||
<Note>
|
||||
This feature works for personal projects and is available for group projects with a
|
||||
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
|
||||
|
||||
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
|
||||
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
|
||||
</Note>
|
||||
|
||||
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.
|
||||
|
||||
### Working with Issues
|
||||
|
||||
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
|
||||
1. Comment on the issue to let you know it is working on it.
|
||||
- You can click on the link to track the progress on OpenHands Cloud.
|
||||
2. Open a merge request if it determines that the issue has been successfully resolved.
|
||||
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
|
||||
|
||||
### Working with Merge Requests
|
||||
|
||||
To get OpenHands to work on merge requests, mention `@openhands` in the comments to:
|
||||
- Ask questions
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
|
||||
@@ -8,9 +8,9 @@ description: Getting started with OpenHands Cloud.
|
||||
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands. To get started with OpenHands Cloud,
|
||||
visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
|
||||
You'll be prompted to connect with your GitHub or GitLab account:
|
||||
You'll be prompted to connect with your GitHub, GitLab or Bitbucket account:
|
||||
|
||||
1. Click `Log in with GitHub` or `Log in with GitLab`.
|
||||
1. Click `Log in with GitHub`, `Log in with GitLab` or `Log in with Bitbucket`.
|
||||
2. Review the permissions requested by OpenHands and authorize the application.
|
||||
- OpenHands will require certain permissions from your account. To read more about these permissions,
|
||||
you can click the `Learn more` link on the authorization page.
|
||||
@@ -22,5 +22,6 @@ Once you've connected your account, you can:
|
||||
|
||||
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
|
||||
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
|
||||
- [Install Bitbucket Integration](/usage/cloud/bitbucket-installation) to use OpenHands with your Bitbucket repositories.
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
|
||||
@@ -8,6 +8,12 @@ description: This page outlines all available configuration options for OpenHand
|
||||
In GUI Mode, any settings applied through the Settings UI will take precedence.
|
||||
</Note>
|
||||
|
||||
## Location of the `config.toml` File
|
||||
|
||||
When running OpenHands in CLI, headless, or development mode, you can use a project-specific `config.toml` file for configuration, which must be
|
||||
located in the same directory from which the command is run. Alternatively, you may use the `--config-file` option to
|
||||
specify a different path to the `config.toml` file.
|
||||
|
||||
## Core Configuration
|
||||
|
||||
The core configuration options are defined in the `[core]` section of the `config.toml` file.
|
||||
|
||||
@@ -12,7 +12,8 @@ icon: question
|
||||
[GitHub](/usage/cloud/github-installation), [GitLab](/usage/cloud/gitlab-installation),
|
||||
and [Slack](/usage/cloud/slack-installation) integrations.
|
||||
2. **Run on your own**: If you prefer to run it on your own hardware, follow our [Getting Started guide](/usage/local-setup).
|
||||
3. **First steps**: Complete the [start building tutorial](/usage/getting-started) to learn the basics.
|
||||
3. **First steps**: Read over the [start building guidelines](/usage/getting-started) and
|
||||
[prompting best practices](/usage/prompting/prompting-best-practices) to learn the basics.
|
||||
|
||||
### Can I use OpenHands for production workloads?
|
||||
|
||||
|
||||
@@ -33,6 +33,45 @@ pip install openhands-ai
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="Create shell aliases for easy access across environments">
|
||||
|
||||
Add the following to your shell configuration file (`.bashrc`, `.zshrc`, etc.):
|
||||
|
||||
```bash
|
||||
# Add OpenHands aliases
|
||||
alias openhands="uvx --python 3.12 --from openhands-ai openhands"
|
||||
alias oh="uvx --python 3.12 --from openhands-ai openhands"
|
||||
```
|
||||
|
||||
After adding these lines, reload your shell configuration with `source ~/.bashrc` or `source ~/.zshrc` (depending on your shell).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Install OpenHands in home directory without global installation">
|
||||
|
||||
You can install OpenHands in a virtual environment in your home directory using `uv`:
|
||||
|
||||
```bash
|
||||
# Create a virtual environment in your home directory
|
||||
cd ~
|
||||
uv venv .openhands-venv --python 3.12
|
||||
|
||||
# Install OpenHands in the virtual environment
|
||||
uv pip install -t ~/.openhands-venv/lib/python3.12/site-packages openhands-ai
|
||||
|
||||
# Add the bin directory to your PATH in your shell configuration file
|
||||
echo 'export PATH="$PATH:$HOME/.openhands-venv/bin"' >> ~/.bashrc # or ~/.zshrc
|
||||
|
||||
# Reload your shell configuration
|
||||
source ~/.bashrc # or source ~/.zshrc
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
2. Launch an interactive OpenHands conversation from the command line:
|
||||
```bash
|
||||
openhands
|
||||
@@ -64,7 +103,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +112,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
@@ -84,7 +123,8 @@ docker run -it \
|
||||
|
||||
This launches the CLI in Docker, allowing you to interact with OpenHands.
|
||||
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` ensures files created by the agent in your workspace have the correct permissions.
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
The conversation history will be saved in `~/.openhands/sessions`.
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ You can use the Settings page at any time to:
|
||||
- Setup the LLM provider and model for OpenHands.
|
||||
- [Setup the search engine](/usage/search-engine-setup).
|
||||
- [Configure MCP servers](/usage/mcp).
|
||||
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup).
|
||||
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup), [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
|
||||
and [connect to Bitbucket](/usage/how-to/gui-mode#bitbucket-setup).
|
||||
- Set application settings like your preferred language, notifications and other preferences.
|
||||
- [Manage custom secrets](/usage/common-settings#secrets-management).
|
||||
|
||||
@@ -122,17 +123,15 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
#### BitBucket Setup (Coming soon ...)
|
||||
#### BitBucket Setup
|
||||
<AccordionGroup>
|
||||
<Accordion title="Setting Up a BitBucket Password">
|
||||
1. **Generate an App Password**:
|
||||
- On BitBucket, go to Personal Settings > App Password.
|
||||
- Create a new password with the following scopes:
|
||||
- `repository: read`
|
||||
- `account`: `read`
|
||||
- `repository: write`
|
||||
- `pull requests: read`
|
||||
- `pull requests: write`
|
||||
- `issues: read`
|
||||
- `issues: write`
|
||||
- App passwords are non-expiring token. OpenHands will migrate to using API tokens in the future.
|
||||
2. **Enter Token in OpenHands**:
|
||||
|
||||
@@ -18,42 +18,79 @@ poetry run python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
You'll need to be sure to set your model, API key, and other settings via environment variables
|
||||
[or the `config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml).
|
||||
|
||||
## With Docker
|
||||
### Working with Repositories
|
||||
|
||||
To run OpenHands in Headless mode with Docker:
|
||||
You can specify a repository for OpenHands to work with using `--selected-repo` or the `SANDBOX_SELECTED_REPO` environment variable:
|
||||
|
||||
1. Set the following environment variables in your terminal:
|
||||
- `SANDBOX_VOLUMES` to specify the directory you want OpenHands to access ([See using SANDBOX_VOLUMES for more info](../runtimes/docker#using-sandbox_volumes))
|
||||
- `LLM_MODEL` - the LLM model to use (e.g. `export LLM_MODEL="anthropic/claude-sonnet-4-20250514"`)
|
||||
- `LLM_API_KEY` - your API key (e.g. `export LLM_API_KEY="sk_test_12345"`)
|
||||
|
||||
2. Run the following Docker command:
|
||||
> **Note**: Currently, authentication tokens (GITHUB_TOKEN, GITLAB_TOKEN, or BITBUCKET_TOKEN) are required for all repository operations, including public repositories. This is a known limitation that may be addressed in future versions to allow tokenless access to public repositories.
|
||||
|
||||
```bash
|
||||
# Using command-line argument
|
||||
poetry run python -m openhands.core.main \
|
||||
--selected-repo "owner/repo-name" \
|
||||
-t "analyze the codebase and suggest improvements"
|
||||
|
||||
# Using environment variable
|
||||
export SANDBOX_SELECTED_REPO="owner/repo-name"
|
||||
poetry run python -m openhands.core.main -t "fix any linting issues"
|
||||
|
||||
# Authentication tokens are currently required for ALL repository operations (public and private)
|
||||
# This includes GitHub, GitLab, and Bitbucket repositories
|
||||
export GITHUB_TOKEN="your-token" # or GITLAB_TOKEN, BITBUCKET_TOKEN
|
||||
poetry run python -m openhands.core.main \
|
||||
--selected-repo "owner/repo-name" \
|
||||
-t "review the security implementation"
|
||||
|
||||
# Using task files instead of inline task
|
||||
echo "Review the README and suggest improvements" > task.txt
|
||||
poetry run python -m openhands.core.main -f task.txt --selected-repo "owner/repo"
|
||||
```
|
||||
|
||||
## With Docker
|
||||
|
||||
Set environment variables and run the Docker command:
|
||||
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
|
||||
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
|
||||
export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
-e LLM_MODEL=$LLM_MODEL \
|
||||
-e SANDBOX_SELECTED_REPO=$SANDBOX_SELECTED_REPO \
|
||||
-e GITHUB_TOKEN=$GITHUB_TOKEN \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history.
|
||||
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
## Advanced Headless Configurations
|
||||
## Additional Options
|
||||
|
||||
To view all available configuration options for headless mode, run the Python command with the `--help` flag.
|
||||
Common command-line options:
|
||||
- `-d "/path/to/workspace"` - Set working directory
|
||||
- `-f task.txt` - Load task from file
|
||||
- `-i 50` - Set max iterations
|
||||
- `-b 10.0` - Set budget limit (USD)
|
||||
- `--no-auto-continue` - Interactive mode
|
||||
|
||||
### Additional Logs
|
||||
Run `poetry run python -m openhands.core.main --help` for all options.
|
||||
|
||||
For the headless mode to log all the agent actions, in the terminal run: `export LOG_ALL_EVENTS=true`
|
||||
Set `export LOG_ALL_EVENTS=true` to log all agent actions.
|
||||
|
||||
@@ -10,7 +10,8 @@ This section is for users who want to connect OpenHands to different LLMs.
|
||||
## Model Recommendations
|
||||
|
||||
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
|
||||
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
|
||||
recommendations for model selection. Our latest benchmarking results can be found in
|
||||
[this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
|
||||
|
||||
Based on these findings and community feedback, these are the latest models that have been verified to work reasonably well with OpenHands:
|
||||
|
||||
@@ -20,6 +21,7 @@ Based on these findings and community feedback, these are the latest models that
|
||||
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
|
||||
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
|
||||
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
|
||||
- [moonshot/kimi-k2-0711-preview](https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2)
|
||||
|
||||
If you have successfully run OpenHands with specific providers, we encourage you to open a PR to share your setup process
|
||||
to help others using the same provider!
|
||||
@@ -70,17 +72,20 @@ We have a few guides for running OpenHands with specific model providers:
|
||||
- [Groq](/usage/llms/groq)
|
||||
- [Local LLMs with SGLang or vLLM](/usage/llms/local-llms)
|
||||
- [LiteLLM Proxy](/usage/llms/litellm-proxy)
|
||||
- [Moonshot AI](/usage/llms/moonshot)
|
||||
- [OpenAI](/usage/llms/openai-llms)
|
||||
- [OpenHands](/usage/llms/openhands-llms)
|
||||
- [OpenRouter](/usage/llms/openrouter)
|
||||
|
||||
## Model Customization
|
||||
|
||||
LLM providers have specific settings that can be customized to optimize their performance with OpenHands, such as:
|
||||
|
||||
- **Custom Tokenizers**: For specialized models, you can add a suitable tokenizer
|
||||
- **Native Tool Calling**: Toggle native function/tool calling capabilities
|
||||
- **Custom Tokenizers**: For specialized models, you can add a suitable tokenizer.
|
||||
- **Native Tool Calling**: Toggle native function/tool calling capabilities.
|
||||
|
||||
For detailed information about model customization, see [LLM Configuration Options](configuration-options#llm-customization).
|
||||
For detailed information about model customization, see
|
||||
[LLM Configuration Options](/usage/configuration-options#llm-configuration).
|
||||
|
||||
### API retries and rate limits
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
@@ -175,6 +175,27 @@ vllm serve mistralai/Devstral-Small-2505 \
|
||||
--enable-prefix-caching
|
||||
```
|
||||
|
||||
If you are interested in further improved inference speed, you can also try Snowflake's version
|
||||
of vLLM, [ArcticInference](https://www.snowflake.com/en/engineering-blog/fast-speculative-decoding-vllm-arctic/),
|
||||
which can achieve up to 2x speedup in some cases.
|
||||
|
||||
1. Install the Arctic Inference library that automatically patches vLLM:
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/snowflakedb/ArcticInference.git
|
||||
```
|
||||
|
||||
2. Run the launch command with speculative decoding enabled:
|
||||
|
||||
```bash
|
||||
vllm serve mistralai/Devstral-Small-2505 \
|
||||
--host 0.0.0.0 --port 8000 \
|
||||
--api-key mykey \
|
||||
--tensor-parallel-size 2 \
|
||||
--served-model-name Devstral-Small-2505 \
|
||||
--speculative-config '{"method": "suffix"}'
|
||||
```
|
||||
|
||||
### Run OpenHands (Alternative Backends)
|
||||
|
||||
#### Using Docker
|
||||
|
||||
25
docs/usage/llms/moonshot.mdx
Normal file
25
docs/usage/llms/moonshot.mdx
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Moonshot AI
|
||||
description: How to use Moonshot AI models with OpenHands
|
||||
---
|
||||
|
||||
## Using Moonshot AI with OpenHands
|
||||
|
||||
[Moonshot AI](https://platform.moonshot.ai/) offers several powerful models, including Kimi-K2, which has been verified to work well with OpenHands.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Sign up for an account at [Moonshot AI Platform](https://platform.moonshot.ai/)
|
||||
2. Generate an API key from your account settings
|
||||
3. Configure OpenHands to use Moonshot AI:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| LLM Provider | `moonshot` |
|
||||
| LLM Model | `kimi-k2-0711-preview` |
|
||||
| API Key | Your Moonshot API key |
|
||||
|
||||
### Recommended Models
|
||||
|
||||
- `moonshot/kimi-k2-0711-preview` - Kimi-K2 is Moonshot's most powerful model with a 131K context window, function calling support, and web search capabilities.
|
||||
|
||||
34
docs/usage/llms/openhands-llms.mdx
Normal file
34
docs/usage/llms/openhands-llms.mdx
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: OpenHands
|
||||
description: OpenHands LLM provider with access to state-of-the-art (SOTA) agentic coding models.
|
||||
---
|
||||
|
||||
## Obtain Your OpenHands LLM API Key
|
||||
|
||||
1. [Log in to OpenHands Cloud](/usage/cloud/openhands-cloud).
|
||||
2. Go to the Settings page and navigate to the `API Keys` tab.
|
||||
3. Copy your `LLM API Key`.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
|
||||
- `LLM Provider` to `OpenHands`
|
||||
- `LLM Model` to the model you will be using (e.g. claude-sonnet-4-20250514)
|
||||
- `API Key` to your OpenHands LLM API key copied from above
|
||||
|
||||
## Using OpenHands LLM Provider in the CLI
|
||||
|
||||
1. [Run OpenHands CLI](/usage/how-to/cli-mode).
|
||||
2. To select OpenHands as the LLM provider:
|
||||
- If this is your first time running the CLI, choose `openhands` and then select the model that you would like to use.
|
||||
- If you have previously run the CLI, run the `/settings` command and select to modify the `Basic` settings. Then
|
||||
choose `openhands` and finally the model.
|
||||
|
||||

|
||||
|
||||
## Pricing
|
||||
|
||||
Pricing follows official API provider rates.
|
||||
[You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
|
||||
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
### Start the App
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -29,6 +29,15 @@ sse_servers = [
|
||||
{url="https://secure-example.com/mcp", api_key="your-api-key"}
|
||||
]
|
||||
|
||||
# SHTTP Servers - External servers that communicate via Streamable HTTP
|
||||
shttp_servers = [
|
||||
# Basic SHTTP server with just a URL
|
||||
"http://example.com:8080/mcp",
|
||||
|
||||
# SHTTP server with API key authentication
|
||||
{url="https://secure-example.com/mcp", api_key="your-api-key"}
|
||||
]
|
||||
|
||||
# Stdio Servers - Local processes that communicate via standard input/output
|
||||
stdio_servers = [
|
||||
# Basic stdio server
|
||||
@@ -57,6 +66,22 @@ SSE servers are configured using either a string URL or an object with the follo
|
||||
- Type: `str`
|
||||
- Description: The URL of the SSE server
|
||||
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication
|
||||
|
||||
### SHTTP Servers
|
||||
|
||||
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
|
||||
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SHTTP server
|
||||
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication
|
||||
|
||||
### Stdio Servers
|
||||
|
||||
Stdio servers are configured using an object with the following properties:
|
||||
@@ -84,7 +109,7 @@ Stdio servers are configured using an object with the following properties:
|
||||
When OpenHands starts, it:
|
||||
|
||||
1. Reads the MCP configuration.
|
||||
2. Connects to any configured SSE servers.
|
||||
2. Connects to any configured SSE and SHTTP servers.
|
||||
3. Starts any configured stdio servers.
|
||||
4. Registers the tools provided by these servers with the agent.
|
||||
|
||||
@@ -93,3 +118,23 @@ The agent can then use these tools just like any built-in tool. When the agent c
|
||||
1. OpenHands routes the call to the appropriate MCP server.
|
||||
2. The server processes the request and returns a response.
|
||||
3. OpenHands converts the response to an observation and presents it to the agent.
|
||||
|
||||
## Transport Protocols
|
||||
|
||||
OpenHands supports three different MCP transport protocols:
|
||||
|
||||
### Server-Sent Events (SSE)
|
||||
SSE is a legacy HTTP-based transport that uses Server-Sent Events for server-to-client communication and HTTP POST requests for client-to-server communication. This transport is suitable for basic streaming scenarios but has limitations in session management and connection resumability.
|
||||
|
||||
### Streamable HTTP (SHTTP)
|
||||
SHTTP is the modern HTTP-based transport protocol that provides enhanced features over SSE:
|
||||
|
||||
- **Improved Session Management**: Supports stateful sessions with session IDs for maintaining context across requests
|
||||
- **Connection Resumability**: Can resume broken connections and replay missed messages using event IDs
|
||||
- **Bidirectional Communication**: Uses HTTP POST for client-to-server and optional SSE streams for server-to-client communication
|
||||
- **Better Error Handling**: Enhanced error reporting and recovery mechanisms
|
||||
|
||||
SHTTP is the recommended transport for HTTP-based MCP servers as it provides better reliability and features compared to the legacy SSE transport.
|
||||
|
||||
### Standard Input/Output (stdio)
|
||||
Stdio transport enables communication through standard input and output streams, making it ideal for local integrations and command-line tools. This transport is used for locally executed MCP servers that run as separate processes.
|
||||
|
||||
@@ -24,3 +24,12 @@ General microagent file example for organization `Great-Co` located inside the `
|
||||
```
|
||||
|
||||
For GitLab organizations, the same microagent would be located inside the `openhands-config` repository.
|
||||
|
||||
## User Microagents When Running Openhands on Your Own
|
||||
|
||||
<Note>
|
||||
This works with CLI, headless and development modes. It does not work out of the box when running OpenHands using the docker command.
|
||||
</Note>
|
||||
|
||||
When running OpenHands on your own, you can place microagents in the `~/.openhands/microagents` folder on your local
|
||||
system and OpenHands will always load it for all your conversations.
|
||||
|
||||
@@ -38,6 +38,21 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
|
||||
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
|
||||
OpenHands.
|
||||
|
||||
### Internal Server Error. Ports are not available
|
||||
|
||||
**Description**
|
||||
|
||||
When running on Windows, the error `Internal Server Error ("ports are not available: exposing port TCP
|
||||
...: bind: An attempt was made to access a socket in a
|
||||
way forbidden by its access permissions.")` is encountered.
|
||||
|
||||
**Resolution**
|
||||
|
||||
* Run the following command in PowerShell, as Administrator to reset the NAT service and release the ports:
|
||||
```
|
||||
Restart-Service -Name "winnat"
|
||||
```
|
||||
|
||||
### Unable to access VS Code tab via local IP
|
||||
|
||||
**Description**
|
||||
|
||||
@@ -101,13 +101,14 @@ The OpenHands evaluation harness supports a wide variety of benchmarks across [s
|
||||
- SWE-Bench: [`evaluation/benchmarks/swe_bench`](./benchmarks/swe_bench)
|
||||
- HumanEvalFix: [`evaluation/benchmarks/humanevalfix`](./benchmarks/humanevalfix)
|
||||
- BIRD: [`evaluation/benchmarks/bird`](./benchmarks/bird)
|
||||
- BioCoder: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
|
||||
- BioCoder: [`evaluation/benchmarks/biocoder`](./benchmarks/biocoder)
|
||||
- ML-Bench: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
|
||||
- APIBench: [`evaluation/benchmarks/gorilla`](./benchmarks/gorilla/)
|
||||
- ToolQA: [`evaluation/benchmarks/toolqa`](./benchmarks/toolqa/)
|
||||
- AiderBench: [`evaluation/benchmarks/aider_bench`](./benchmarks/aider_bench/)
|
||||
- Commit0: [`evaluation/benchmarks/commit0_bench`](./benchmarks/commit0_bench/)
|
||||
- DiscoveryBench: [`evaluation/benchmarks/discoverybench`](./benchmarks/discoverybench/)
|
||||
- TerminalBench: [`evaluation/benchmarks/terminal_bench`](./benchmarks/terminal_bench)
|
||||
|
||||
### Web Browsing
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
|
||||
@@ -41,6 +41,10 @@ default, it is set to 1.
|
||||
- `language`, the language of your evaluating dataset.
|
||||
- `dataset`, the absolute position of the dataset jsonl.
|
||||
|
||||
**Skipping errors on build**
|
||||
|
||||
For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=true` to continue evaluation even when instances reach maximum retries. After evaluation completes, check `maximum_retries_exceeded.jsonl` for a list of failed instances, fix those issues, and then run the evaluation again with `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false`.
|
||||
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
assert_and_raise,
|
||||
check_maximum_retries_exceeded,
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
@@ -344,6 +345,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
@@ -843,3 +845,5 @@ if __name__ == '__main__':
|
||||
timeout_seconds=120 * 60, # 2 hour PER instance should be more than enough
|
||||
max_retries=5,
|
||||
)
|
||||
# Check if any instances reached maximum retries
|
||||
check_maximum_retries_exceeded(metadata.eval_output_dir)
|
||||
|
||||
@@ -38,6 +38,10 @@ Please follow instruction [here](../../README.md#setup) to setup your local deve
|
||||
> - If your LLM config has temperature=0, we will automatically use temperature=0.1 for the 2nd and 3rd attempts
|
||||
>
|
||||
> To enable this iterative protocol, set `export ITERATIVE_EVAL_MODE=true`
|
||||
>
|
||||
> **Skipping errors on build**
|
||||
>
|
||||
> For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=true` to continue evaluation even when instances reach maximum retries. After evaluation completes, check `maximum_retries_exceeded.jsonl` for a list of failed instances, fix those issues, and then run the evaluation again with `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false`.
|
||||
|
||||
|
||||
### Running Locally with Docker
|
||||
|
||||
45
evaluation/benchmarks/swe_bench/loc_eval/README.md
Normal file
45
evaluation/benchmarks/swe_bench/loc_eval/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# **Localization Evaluation for SWE-Bench**
|
||||
|
||||
This folder implements localization evaluation at both file and function levels to complementing the assessment of agent inference on [SWE-Bench](https://www.swebench.com/).
|
||||
|
||||
## **1. Environment Setup**
|
||||
- Python env: [Install python environment](../../../README.md#development-environment)
|
||||
- LLM config: [Configure LLM config](../../../README.md#configure-openhands-and-your-llm)
|
||||
|
||||
## **2. Inference & Evaluation**
|
||||
- Inference and evaluation follow the original `run_infer.sh` and `run_eval.sh` implementation
|
||||
- You may refer to instructions at [README.md](../README.md) for running inference and evaluation on SWE-Bench
|
||||
|
||||
## **3. Localization Evaluation**
|
||||
- Localization evaluation computes two-level localization accuracy, while also considers task success as an additional metric for overall evaluation:
|
||||
- **File Localization Accuracy:** Accuracy of correctly localizing the target file
|
||||
- **Function Localization Accuracy:** Accuracy of correctly localizing the target function
|
||||
- **Resolve Rate** (will be auto-skipped if missing): Success rate of whether tasks are successfully resolved
|
||||
- **File Localization Efficiency:** Average number of iterations taken to successfully localize the target file
|
||||
- **Function Localization Efficiency:** Average number of iterations taken to successfully localize the target file
|
||||
- **Task success efficiency:** Average number of iterations taken to resolve the task
|
||||
- **Resource efficiency:** the API expenditure of the agent running inference on SWE-Bench instances
|
||||
|
||||
- Run localization evaluation
|
||||
- Format:
|
||||
```bash
|
||||
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh [infer-dir] [split] [dataset] [max-infer-turn] [align-with-max]
|
||||
```
|
||||
- `infer-dir`: inference directory containing inference outputs
|
||||
- `split`: SWE-Bench dataset split to use
|
||||
- `dataset`: SWE-Bench dataset name
|
||||
- `max-infer-turn`: the maximum number of iterations the agent took to run inference
|
||||
- `align-with-max`: whether to align failure indices (e.g., incorrect localization, unresolved tasks) with `max_iter`
|
||||
|
||||
- Example:
|
||||
```bash
|
||||
# Example
|
||||
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh \
|
||||
--infer-dir ./evaluation/evaluation_outputs/outputs/princeton-nlp__SWE-bench_Verified-test/CodeActAgent/gpt_4o_100_N \
|
||||
--split test \
|
||||
--dataset princeton-nlp/SWE-bench_Verified \
|
||||
--max-infer-turn 100 \
|
||||
--align-with-max true
|
||||
```
|
||||
|
||||
- Localization evaluation results will be automatically saved to `[infer-dir]/loc_eval`
|
||||
1006
evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py
Normal file
1006
evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py
Normal file
File diff suppressed because it is too large
Load Diff
1110
evaluation/benchmarks/swe_bench/loc_eval/loc_utils.py
Normal file
1110
evaluation/benchmarks/swe_bench/loc_eval/loc_utils.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
assert_and_raise,
|
||||
check_maximum_retries_exceeded,
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
@@ -109,9 +110,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
|
||||
template_name = 'swt.j2'
|
||||
elif mode == 'swe':
|
||||
if 'claude' in llm_model:
|
||||
template_name = 'swe_claude.j2'
|
||||
elif 'gemini' in llm_model:
|
||||
template_name = 'swe_gemini.j2'
|
||||
template_name = 'swe_default.j2'
|
||||
elif 'gpt-4.1' in llm_model:
|
||||
template_name = 'swe_gpt4.j2'
|
||||
else:
|
||||
@@ -227,6 +226,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
@@ -970,3 +970,5 @@ if __name__ == '__main__':
|
||||
logger.info(
|
||||
f'Done! Total {len(added_instance_ids)} instances added to {output_file}'
|
||||
)
|
||||
# Check if any instances reached maximum retries
|
||||
check_maximum_retries_exceeded(metadata.eval_output_dir)
|
||||
|
||||
@@ -203,6 +203,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
|
||||
227
evaluation/benchmarks/swe_bench/scripts/eval_localization.sh
Executable file
227
evaluation/benchmarks/swe_bench/scripts/eval_localization.sh
Executable file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
source "evaluation/utils/version_control.sh"
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo "Options:"
|
||||
echo " --infer-dir DIR Directory containing model inference outputs"
|
||||
echo " --split SPLIT SWE-Bench dataset split selection"
|
||||
echo " --dataset DATASET Dataset name"
|
||||
echo " --max-infer-turn NUM Max number of turns for coding agent"
|
||||
echo " --align-with-max BOOL Align failed instance indices with max iteration (true/false)"
|
||||
echo " -h, --help Display this help message"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " $0 --infer-dir ./inference_outputs --split test --align-with-max false"
|
||||
}
|
||||
|
||||
# Check if no arguments were provided
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--infer-dir)
|
||||
INFER_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--split)
|
||||
SPLIT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dataset)
|
||||
DATASET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--max-infer-turn)
|
||||
MAX_TURN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--align-with-max)
|
||||
ALIGN_WITH_MAX="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check for required arguments (only INFER_DIR is required)
|
||||
if [ -z "$INFER_DIR" ]; then
|
||||
echo "Error: Missing required arguments (--infer-dir is required)"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set defaults for optional arguments if not provided
|
||||
if [ -z "$SPLIT" ]; then
|
||||
SPLIT="test"
|
||||
echo "Split not specified, using default: $SPLIT"
|
||||
fi
|
||||
|
||||
if [ -z "$DATASET" ]; then
|
||||
DATASET="princeton-nlp/SWE-bench_Verified"
|
||||
echo "Dataset not specified, using default: $DATASET"
|
||||
fi
|
||||
|
||||
if [ -z "$MAX_TURN" ]; then
|
||||
MAX_TURN=20
|
||||
echo "Max inference turn not specified, using default: $MAX_TURN"
|
||||
fi
|
||||
|
||||
if [ -z "$ALIGN_WITH_MAX" ]; then
|
||||
ALIGN_WITH_MAX="true"
|
||||
echo "Align with max not specified, using default: $ALIGN_WITH_MAX"
|
||||
fi
|
||||
|
||||
# Validate align-with-max value
|
||||
if [ "$ALIGN_WITH_MAX" != "true" ] && [ "$ALIGN_WITH_MAX" != "false" ]; then
|
||||
print_error "Invalid value for --align-with-max: $ALIGN_WITH_MAX. Must be 'true' or 'false'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_header() {
|
||||
echo -e "${BLUE}[TASK]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if Python is available
|
||||
print_header "Checking Python installation..."
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
if ! command -v python &> /dev/null; then
|
||||
print_error "Python is not installed or not in PATH"
|
||||
exit 1
|
||||
else
|
||||
PYTHON_CMD="python"
|
||||
print_status "Using python command"
|
||||
fi
|
||||
else
|
||||
PYTHON_CMD="python3"
|
||||
print_status "Using python3 command"
|
||||
fi
|
||||
|
||||
# Check if the Python script exists
|
||||
SCRIPT_NAME="./evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py"
|
||||
if [ ! -f "$SCRIPT_NAME" ]; then
|
||||
print_error "Python script '$SCRIPT_NAME' not found in current directory"
|
||||
print_warning "Make sure the Python script is in the same directory as this bash script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if required directories exist
|
||||
print_header "Validating directories..."
|
||||
if [ ! -d "$INFER_DIR" ]; then
|
||||
print_error "Inference directory not found: $INFER_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Evaluation outputs
|
||||
EVAL_DIR="$INFER_DIR/eval_outputs"
|
||||
|
||||
# Display configuration
|
||||
print_header "Starting Localization Evaluation with the following configuration:"
|
||||
echo " Inference Directory: $INFER_DIR"
|
||||
if [ -d "$EVAL_DIR" ]; then
|
||||
echo " Evaluation Directory: $EVAL_DIR"
|
||||
else
|
||||
echo " Evaluation Directory: None (evaluation outputs doesn't exist)"
|
||||
fi
|
||||
echo " Output Directory: $INFER_DIR/loc_eval"
|
||||
echo " Split: $SPLIT"
|
||||
echo " Dataset: $DATASET"
|
||||
echo " Max Turns: $MAX_TURN"
|
||||
echo " Align with Max: $ALIGN_WITH_MAX"
|
||||
echo " Python Command: $PYTHON_CMD"
|
||||
echo ""
|
||||
|
||||
# Check Python dependencies (optional check)
|
||||
print_header "Checking Python dependencies..."
|
||||
$PYTHON_CMD -c "
|
||||
import sys
|
||||
required_modules = ['pandas', 'json', 'os', 'argparse', 'collections']
|
||||
missing_modules = []
|
||||
|
||||
for module in required_modules:
|
||||
try:
|
||||
__import__(module)
|
||||
except ImportError:
|
||||
missing_modules.append(module)
|
||||
|
||||
if missing_modules:
|
||||
print(f'Missing required modules: {missing_modules}')
|
||||
sys.exit(1)
|
||||
else:
|
||||
print('All basic dependencies are available')
|
||||
" || {
|
||||
print_error "Some Python dependencies are missing"
|
||||
print_warning "Please install required packages: pip install pandas"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Create log directory if doesn't exists
|
||||
mkdir -p "$INFER_DIR/loc_eval"
|
||||
|
||||
# Set up logging
|
||||
LOG_FILE="$INFER_DIR/loc_eval/loc_evaluation_$(date +%Y%m%d_%H%M%S).log"
|
||||
print_status "Logging output to: $LOG_FILE"
|
||||
|
||||
# Build the command
|
||||
CMD_ARGS="\"$SCRIPT_NAME\" \
|
||||
--infer-dir \"$INFER_DIR\" \
|
||||
--split \"$SPLIT\" \
|
||||
--dataset \"$DATASET\" \
|
||||
--max-infer-turn \"$MAX_TURN\" \
|
||||
--align-with-max \"$ALIGN_WITH_MAX\""
|
||||
|
||||
# Run the Python script
|
||||
print_header "Running localization evaluation..."
|
||||
eval "$PYTHON_CMD $CMD_ARGS" 2>&1 | tee "$LOG_FILE"
|
||||
|
||||
# Check if the script ran successfully
|
||||
if [ ${PIPESTATUS[0]} -eq 0 ]; then
|
||||
print_status "Localization evaluation completed successfully!"
|
||||
print_status "Results saved to: $INFER_DIR/loc_eval"
|
||||
print_status "Log file: $LOG_FILE"
|
||||
|
||||
# Display summary if results exist
|
||||
if [ -f "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json" ]; then
|
||||
print_header "Evaluation Summary:"
|
||||
cat "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json"
|
||||
echo
|
||||
fi
|
||||
else
|
||||
print_error "Localization evaluation failed!"
|
||||
print_warning "Check the log file for details: $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
31
evaluation/benchmarks/terminal_bench/README.md
Normal file
31
evaluation/benchmarks/terminal_bench/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Terminal-Bench Evaluation on OpenHands
|
||||
|
||||
Terminal-Bench has its own evaluation harness that is very different from OpenHands'. We
|
||||
implemented [OpenHands agent](https://github.com/laude-institute/terminal-bench/tree/main/terminal_bench/agents/installed_agents/openhands) using OpenHands local runtime
|
||||
inside terminal-bench framework. Hereby we introduce how to use the terminal-bench
|
||||
harness to evaluate OpenHands.
|
||||
|
||||
## Installation
|
||||
|
||||
Terminal-bench ships a CLI tool to manage tasks and run evaluation.
|
||||
Please follow official [Installation Doc](https://www.tbench.ai/docs/installation). You could also clone terminal-bench [source code](https://github.com/laude-institute/terminal-bench) and use `uv run tb` CLI.
|
||||
|
||||
## Evaluation
|
||||
|
||||
Please see [Terminal-Bench Leaderboard](https://www.tbench.ai/leaderboard) for the latest
|
||||
instruction on benchmarking guidance. The dataset might evolve.
|
||||
|
||||
Sample command:
|
||||
|
||||
```bash
|
||||
export LLM_BASE_URL=<optional base url>
|
||||
export LLM_API_KEY=<llm key>
|
||||
tb run \
|
||||
--dataset-name terminal-bench-core \
|
||||
--dataset-version 0.1.1 \
|
||||
--agent openhands \
|
||||
--model <model> \
|
||||
--cleanup
|
||||
```
|
||||
|
||||
You could run `tb --help` or `tb run --help` to learn more about their CLI.
|
||||
@@ -164,6 +164,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
|
||||
@@ -25,7 +25,8 @@ class Test(BaseIntegrationTest):
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
# git add
|
||||
action = CmdRunAction(command='git add hello.py .vscode/')
|
||||
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}')
|
||||
|
||||
@@ -40,15 +41,6 @@ class Test(BaseIntegrationTest):
|
||||
reason=f'Failed to cat /workspace/hello.py: {obs.content}.',
|
||||
)
|
||||
|
||||
# check if the file /workspace/.vscode/settings.json exists
|
||||
action = CmdRunAction(command='cat /workspace/.vscode/settings.json')
|
||||
obs = runtime.run_action(action)
|
||||
if obs.exit_code != 0:
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'Failed to cat /workspace/.vscode/settings.json: {obs.content}.',
|
||||
)
|
||||
|
||||
# check if the staging area is empty
|
||||
action = CmdRunAction(command='git status')
|
||||
obs = runtime.run_action(action)
|
||||
|
||||
@@ -311,6 +311,76 @@ def assert_and_raise(condition: bool, msg: str):
|
||||
raise EvalException(msg)
|
||||
|
||||
|
||||
def log_skipped_maximum_retries_exceeded(instance, metadata, error, max_retries=5):
|
||||
"""Log and skip the instance when maximum retries are exceeded.
|
||||
|
||||
Args:
|
||||
instance: The instance that failed
|
||||
metadata: The evaluation metadata
|
||||
error: The error that occurred
|
||||
max_retries: The maximum number of retries that were attempted
|
||||
|
||||
Returns:
|
||||
EvalOutput with the error information
|
||||
"""
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Log the error
|
||||
logger.exception(error)
|
||||
logger.error(
|
||||
f'Maximum error retries reached for instance {instance.instance_id}. '
|
||||
f'Check maximum_retries_exceeded.jsonl, fix the issue and run evaluation again. '
|
||||
f'Skipping this instance and continuing with others.'
|
||||
)
|
||||
|
||||
# Add the instance name to maximum_retries_exceeded.jsonl in the same folder as output.jsonl
|
||||
if metadata and metadata.eval_output_dir:
|
||||
retries_file_path = os.path.join(
|
||||
metadata.eval_output_dir,
|
||||
'maximum_retries_exceeded.jsonl',
|
||||
)
|
||||
try:
|
||||
# Write the instance info as a JSON line
|
||||
with open(retries_file_path, 'a') as f:
|
||||
import json
|
||||
|
||||
# No need to get Docker image as we're not including it in the error entry
|
||||
|
||||
error_entry = {
|
||||
'instance_id': instance.instance_id,
|
||||
'error': str(error),
|
||||
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
}
|
||||
f.write(json.dumps(error_entry) + '\n')
|
||||
logger.info(f'Added instance {instance.instance_id} to {retries_file_path}')
|
||||
except Exception as write_error:
|
||||
logger.error(
|
||||
f'Failed to write to maximum_retries_exceeded.jsonl: {write_error}'
|
||||
)
|
||||
|
||||
return EvalOutput(
|
||||
instance_id=instance.instance_id,
|
||||
test_result={},
|
||||
error=f'Maximum retries ({max_retries}) reached: {str(error)}',
|
||||
status='error',
|
||||
)
|
||||
|
||||
|
||||
def check_maximum_retries_exceeded(eval_output_dir):
|
||||
"""Check if maximum_retries_exceeded.jsonl exists and output a message."""
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
retries_file_path = os.path.join(eval_output_dir, 'maximum_retries_exceeded.jsonl')
|
||||
if os.path.exists(retries_file_path):
|
||||
logger.info(
|
||||
'ATTENTION: Some instances reached maximum error retries and were skipped.'
|
||||
)
|
||||
logger.info(f'These instances are listed in: {retries_file_path}')
|
||||
logger.info(
|
||||
'Fix these instances and run evaluation again with EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false'
|
||||
)
|
||||
|
||||
|
||||
def _process_instance_wrapper(
|
||||
process_instance_func: Callable[[pd.Series, EvalMetadata, bool], EvalOutput],
|
||||
instance: pd.Series,
|
||||
@@ -363,11 +433,26 @@ def _process_instance_wrapper(
|
||||
+ f'[Encountered after {max_retries} retries. Please check the logs and report the issue.]'
|
||||
+ '-' * 10
|
||||
)
|
||||
# Raise an error after all retries & stop the evaluation
|
||||
logger.exception(e)
|
||||
raise RuntimeError(
|
||||
f'Maximum error retries reached for instance {instance.instance_id}'
|
||||
) from e
|
||||
|
||||
# Check if EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED is set to true
|
||||
skip_errors = (
|
||||
os.environ.get(
|
||||
'EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED', 'false'
|
||||
).lower()
|
||||
== 'true'
|
||||
)
|
||||
|
||||
if skip_errors:
|
||||
# Use the dedicated function to log and skip maximum retries exceeded
|
||||
return log_skipped_maximum_retries_exceeded(
|
||||
instance, metadata, e, max_retries
|
||||
)
|
||||
else:
|
||||
# Raise an error after all retries & stop the evaluation
|
||||
logger.exception(e)
|
||||
raise RuntimeError(
|
||||
f'Maximum error retries reached for instance {instance.instance_id}'
|
||||
) from e
|
||||
msg = (
|
||||
'-' * 10
|
||||
+ '\n'
|
||||
@@ -456,6 +541,10 @@ def run_evaluation(
|
||||
output_fp.close()
|
||||
logger.info('\nEvaluation finished.\n')
|
||||
|
||||
# Check if any instances reached maximum retries
|
||||
if metadata and metadata.eval_output_dir:
|
||||
check_maximum_retries_exceeded(metadata.eval_output_dir)
|
||||
|
||||
|
||||
def reset_logger_for_multiprocessing(
|
||||
logger: logging.Logger, instance_id: str, log_dir: str
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@tanstack/query/recommended",
|
||||
],
|
||||
"plugins": ["prettier", "unused-imports"],
|
||||
"plugins": ["prettier", "unused-imports", "i18next"],
|
||||
"rules": {
|
||||
"i18next/no-literal-string": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"prettier/prettier": ["error"],
|
||||
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Run frontend checks
|
||||
echo "Running frontend checks..."
|
||||
cd frontend
|
||||
npm run check-unlocalized-strings
|
||||
npm run lint
|
||||
npm run check-translation-completeness
|
||||
npx lint-staged
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ describe("ChatMessage", () => {
|
||||
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.todo("should render an assistant message");
|
||||
|
||||
it.skip("should support code syntax highlighting", () => {
|
||||
it("should support code syntax highlighting", () => {
|
||||
const code = "```js\nconsole.log('Hello, World!')\n```";
|
||||
render(<ChatMessage type="user" message={code} />);
|
||||
|
||||
@@ -46,8 +44,6 @@ describe("ChatMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should display an error toast if copying content to clipboard fails", async () => {});
|
||||
|
||||
it("should render a component passed as a prop", () => {
|
||||
function Component() {
|
||||
return <div data-testid="custom-component">Custom Component</div>;
|
||||
|
||||
@@ -129,4 +129,159 @@ describe("ActionSuggestions", () => {
|
||||
expect(createPRPrompt).toContain("meaningful branch name");
|
||||
expect(createPRPrompt).not.toContain("SAME branch name");
|
||||
});
|
||||
|
||||
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
|
||||
// Test case for GitHub repository
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-github",
|
||||
title: "GitHub Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "github",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
// Mock user having both GitHub and Bitbucket tokens
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "github-token",
|
||||
bitbucket: "bitbucket-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
expect(prButton).toBeInTheDocument();
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// The suggestion should mention GitHub, not Bitbucket
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GitHub")
|
||||
);
|
||||
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bitbucket")
|
||||
);
|
||||
});
|
||||
|
||||
it("should use GitLab terminology when git_provider is gitlab", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-gitlab",
|
||||
title: "GitLab Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "gitlab",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: "gitlab-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// Should mention GitLab and "merge request" instead of "pull request"
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GitLab")
|
||||
);
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("merge request")
|
||||
);
|
||||
});
|
||||
|
||||
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-bitbucket",
|
||||
title: "Bitbucket Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "bitbucket",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
bitbucket: "bitbucket-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// Should mention Bitbucket
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bitbucket")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,13 @@ describe("AuthModal", () => {
|
||||
});
|
||||
|
||||
it("should render the GitHub and GitLab buttons", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
render(
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
providersConfigured={["github", "gitlab"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
@@ -35,7 +41,13 @@ describe("AuthModal", () => {
|
||||
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUrl = "https://github.com/login/oauth/authorize";
|
||||
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
|
||||
render(
|
||||
<AuthModal
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
@@ -44,4 +56,63 @@ describe("AuthModal", () => {
|
||||
|
||||
expect(window.location.href).toBe(mockUrl);
|
||||
});
|
||||
|
||||
it("should render Terms of Service and Privacy Policy text with correct links", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
|
||||
// Find the terms of service section using data-testid
|
||||
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
|
||||
expect(termsSection).toBeInTheDocument();
|
||||
|
||||
// Check that all text content is present in the paragraph
|
||||
expect(termsSection).toHaveTextContent(
|
||||
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
|
||||
);
|
||||
expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE");
|
||||
expect(termsSection).toHaveTextContent("COMMON$AND");
|
||||
expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY");
|
||||
|
||||
// Check Terms of Service link
|
||||
const tosLink = screen.getByRole("link", {
|
||||
name: "COMMON$TERMS_OF_SERVICE",
|
||||
});
|
||||
expect(tosLink).toBeInTheDocument();
|
||||
expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos");
|
||||
expect(tosLink).toHaveAttribute("target", "_blank");
|
||||
expect(tosLink).toHaveClass("underline", "hover:text-primary");
|
||||
|
||||
// Check Privacy Policy link
|
||||
const privacyLink = screen.getByRole("link", {
|
||||
name: "COMMON$PRIVACY_POLICY",
|
||||
});
|
||||
expect(privacyLink).toBeInTheDocument();
|
||||
expect(privacyLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.all-hands.dev/privacy",
|
||||
);
|
||||
expect(privacyLink).toHaveAttribute("target", "_blank");
|
||||
expect(privacyLink).toHaveClass("underline", "hover:text-primary");
|
||||
|
||||
// Verify that both links are within the terms section
|
||||
expect(termsSection).toContainElement(tosLink);
|
||||
expect(termsSection).toContainElement(privacyLink);
|
||||
});
|
||||
|
||||
it("should open Terms of Service link in new tab", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
|
||||
const tosLink = screen.getByRole("link", {
|
||||
name: "COMMON$TERMS_OF_SERVICE",
|
||||
});
|
||||
expect(tosLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("should open Privacy Policy link in new tab", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
|
||||
const privacyLink = screen.getByRole("link", {
|
||||
name: "COMMON$PRIVACY_POLICY",
|
||||
});
|
||||
expect(privacyLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
useParams: vi.fn().mockReturnValue({
|
||||
conversationId: "123",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useHandleRuntimeActive hook
|
||||
vi.mock("#/hooks/use-handle-runtime-active", () => ({
|
||||
useHandleRuntimeActive: vi.fn().mockReturnValue({ runtimeActive: true }),
|
||||
}));
|
||||
|
||||
// Mock the useMicroagentPrompt hook
|
||||
vi.mock("#/hooks/query/use-microagent-prompt", () => ({
|
||||
useMicroagentPrompt: vi.fn().mockReturnValue({
|
||||
data: "Generated prompt",
|
||||
isLoading: false
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useGetMicroagents hook
|
||||
vi.mock("#/hooks/query/use-get-microagents", () => ({
|
||||
useGetMicroagents: vi.fn().mockReturnValue({
|
||||
data: ["file1", "file2"]
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useTranslation hook
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
[I18nKey.MICROAGENT$ADD_TO_MICROAGENT]: "Add to Microagent",
|
||||
[I18nKey.MICROAGENT$WHAT_TO_REMEMBER]: "What would you like your microagent to remember?",
|
||||
[I18nKey.MICROAGENT$WHERE_TO_PUT]: "Where should we put it?",
|
||||
[I18nKey.MICROAGENT$ADD_TRIGGERS]: "Add triggers for the microagent",
|
||||
[I18nKey.MICROAGENT$DESCRIBE_WHAT_TO_ADD]: "Describe what you want to add to the Microagent...",
|
||||
[I18nKey.MICROAGENT$SELECT_FILE_OR_CUSTOM]: "Select a microagent file or enter a custom value",
|
||||
[I18nKey.MICROAGENT$TYPE_TRIGGER_SPACE]: "Type a trigger and press Space to add it",
|
||||
[I18nKey.MICROAGENT$LOADING_PROMPT]: "Loading prompt...",
|
||||
[I18nKey.MICROAGENT$CANCEL]: "Cancel",
|
||||
[I18nKey.MICROAGENT$LAUNCH]: "Launch"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
|
||||
}));
|
||||
|
||||
describe("LaunchMicroagentModal", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const onLaunchMock = vi.fn();
|
||||
const eventId = 12;
|
||||
const conversationId = "123";
|
||||
|
||||
const renderMicroagentModal = (
|
||||
{ isLoading }: { isLoading: boolean } = { isLoading: false },
|
||||
) =>
|
||||
render(
|
||||
<LaunchMicroagentModal
|
||||
onClose={onCloseMock}
|
||||
onLaunch={onLaunchMock}
|
||||
eventId={eventId}
|
||||
selectedRepo="some-repo"
|
||||
isLoading={isLoading}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the launch microagent modal", () => {
|
||||
renderMicroagentModal();
|
||||
expect(screen.getByTestId("launch-microagent-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the form fields", () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// inputs
|
||||
screen.getByTestId("query-input");
|
||||
screen.getByTestId("target-input");
|
||||
screen.getByTestId("trigger-input");
|
||||
|
||||
// action buttons
|
||||
screen.getByRole("button", { name: "Launch" });
|
||||
screen.getByRole("button", { name: "Cancel" });
|
||||
});
|
||||
|
||||
it("should call onClose when pressing the cancel button", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
await userEvent.click(cancelButton);
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the prompt from the hook", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// Since we're mocking the hook, we just need to verify the UI shows the data
|
||||
const descriptionInput = screen.getByTestId("query-input");
|
||||
expect(descriptionInput).toHaveValue("Generated prompt");
|
||||
});
|
||||
|
||||
it("should display the list of microagent files from the hook", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// Since we're mocking the hook, we just need to verify the UI shows the data
|
||||
const targetInput = screen.getByTestId("target-input");
|
||||
expect(targetInput).toHaveValue("");
|
||||
|
||||
await userEvent.click(targetInput);
|
||||
|
||||
expect(screen.getByText("file1")).toBeInTheDocument();
|
||||
expect(screen.getByText("file2")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("file1"));
|
||||
expect(targetInput).toHaveValue("file1");
|
||||
});
|
||||
|
||||
it("should call onLaunch with the form data", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
const triggerInput = screen.getByTestId("trigger-input");
|
||||
await userEvent.type(triggerInput, "trigger1 ");
|
||||
await userEvent.type(triggerInput, "trigger2 ");
|
||||
|
||||
const targetInput = screen.getByTestId("target-input");
|
||||
await userEvent.click(targetInput);
|
||||
await userEvent.click(screen.getByText("file1"));
|
||||
|
||||
const launchButton = await screen.findByRole("button", { name: "Launch" });
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(onLaunchMock).toHaveBeenCalledWith("Generated prompt", "file1", [
|
||||
"trigger1",
|
||||
"trigger2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should disable the launch button if isLoading is true", async () => {
|
||||
renderMicroagentModal({ isLoading: true });
|
||||
|
||||
const launchButton = screen.getByRole("button", { name: "Launch" });
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Messages } from "#/components/features/chat/messages";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
OpenHandsAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useParams: () => ({ conversationId: "123" }),
|
||||
}));
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderMessages = ({
|
||||
messages,
|
||||
}: {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
}) => {
|
||||
const { rerender, ...rest } = render(
|
||||
<Messages messages={messages} isAwaitingUserConfirmation={false} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient!}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderMessages = (
|
||||
newMessages: (OpenHandsAction | OpenHandsObservation)[],
|
||||
) => {
|
||||
rerender(
|
||||
<Messages messages={newMessages} isAwaitingUserConfirmation={false} />,
|
||||
);
|
||||
};
|
||||
|
||||
return { ...rest, rerender: rerenderMessages };
|
||||
};
|
||||
|
||||
describe("Messages", () => {
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
const assistantMessage: AssistantMessageAction = {
|
||||
id: 0,
|
||||
action: "message",
|
||||
source: "agent",
|
||||
message: "Hello, Assistant!",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: {
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
thought: "",
|
||||
wait_for_response: false,
|
||||
},
|
||||
};
|
||||
|
||||
const userMessage: UserMessageAction = {
|
||||
id: 1,
|
||||
action: "message",
|
||||
source: "user",
|
||||
message: "Hello, User!",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: { content: "Hello, User!", image_urls: [], file_urls: [] },
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
renderMessages({ messages: [userMessage, assistantMessage] });
|
||||
|
||||
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a launch to microagent action button on chat messages only if it is a user message", () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
const mockConversation: Conversation = {
|
||||
conversation_id: "123",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
created_at: new Date().toISOString(),
|
||||
last_updated_at: new Date().toISOString(),
|
||||
selected_branch: null,
|
||||
selected_repository: null,
|
||||
git_provider: "github",
|
||||
session_api_key: null,
|
||||
url: null,
|
||||
};
|
||||
|
||||
getConversationSpy.mockResolvedValue(mockConversation);
|
||||
|
||||
renderMessages({
|
||||
messages: [userMessage, assistantMessage],
|
||||
});
|
||||
|
||||
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,6 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
|
||||
import { clickOnEditButton } from "./utils";
|
||||
|
||||
// We'll use the actual i18next implementation but override the translation function
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
|
||||
// Mock the t function to return our custom translations
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -27,9 +25,9 @@ vi.mock("react-i18next", async () => {
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"CONVERSATION$CREATED": "Created",
|
||||
"CONVERSATION$AGO": "ago",
|
||||
"CONVERSATION$UPDATED": "Updated"
|
||||
CONVERSATION$CREATED: "Created",
|
||||
CONVERSATION$AGO: "ago",
|
||||
CONVERSATION$UPDATED: "Updated",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -82,7 +80,9 @@ describe("ConversationCard", () => {
|
||||
expect(card).toHaveTextContent("ago");
|
||||
|
||||
// Use a regex to match the time part since it might have whitespace
|
||||
const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
|
||||
const timeRegex = new RegExp(
|
||||
formatTimeDelta(new Date("2021-10-01T12:00:00Z")),
|
||||
);
|
||||
expect(card).toHaveTextContent(timeRegex);
|
||||
});
|
||||
|
||||
@@ -108,7 +108,11 @@ describe("ConversationCard", () => {
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
selectedRepository={{
|
||||
selected_repository: "org/selectedRepository",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
}}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
@@ -118,7 +122,8 @@ describe("ConversationCard", () => {
|
||||
|
||||
it("should toggle a context menu when clicking the ellipsis button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
@@ -126,6 +131,8 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen={false}
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -134,15 +141,32 @@ describe("ConversationCard", () => {
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(onContextMenuToggle).toHaveBeenCalledWith(true);
|
||||
|
||||
// Simulate context menu being opened by parent
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("context-menu");
|
||||
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should call onDelete when the delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -151,18 +175,18 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const deleteButton = within(menu).getByTestId("delete-button");
|
||||
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
|
||||
@@ -173,7 +197,11 @@ describe("ConversationCard", () => {
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
selectedRepository={{
|
||||
selected_repository: "org/selectedRepository",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
}}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
@@ -188,7 +216,11 @@ describe("ConversationCard", () => {
|
||||
|
||||
test("conversation title should call onChangeTitle when changed and blurred", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
let menuOpen = true;
|
||||
const onContextMenuToggle = vi.fn((isOpen: boolean) => {
|
||||
menuOpen = isOpen;
|
||||
});
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
@@ -196,10 +228,27 @@ describe("ConversationCard", () => {
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
contextMenuOpen={menuOpen}
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
await clickOnEditButton(user);
|
||||
|
||||
// Re-render with updated state
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
contextMenuOpen={menuOpen}
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
|
||||
expect(title).toBeEnabled();
|
||||
@@ -217,6 +266,7 @@ describe("ConversationCard", () => {
|
||||
|
||||
it("should reset title and not call onChangeTitle when the title is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -225,6 +275,8 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -261,6 +313,7 @@ describe("ConversationCard", () => {
|
||||
|
||||
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -269,6 +322,8 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -282,6 +337,7 @@ describe("ConversationCard", () => {
|
||||
|
||||
test("clicking the delete button should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -290,12 +346,11 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const deleteButton = within(menu).getByTestId("delete-button");
|
||||
|
||||
@@ -305,7 +360,7 @@ describe("ConversationCard", () => {
|
||||
});
|
||||
|
||||
it("should show display cost button only when showOptions is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -314,21 +369,17 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Wait for context menu to appear
|
||||
const menu = await screen.findByTestId("context-menu");
|
||||
expect(
|
||||
within(menu).queryByTestId("display-cost-button"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Close menu
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -338,12 +389,11 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open menu again
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Wait for context menu to appear and check for display cost button
|
||||
const newMenu = await screen.findByTestId("context-menu");
|
||||
within(newMenu).getByTestId("display-cost-button");
|
||||
@@ -351,6 +401,7 @@ describe("ConversationCard", () => {
|
||||
|
||||
it("should show metrics modal when clicking the display cost button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -360,12 +411,11 @@ describe("ConversationCard", () => {
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showOptions
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const displayCostButton = within(menu).getByTestId("display-cost-button");
|
||||
|
||||
@@ -376,7 +426,7 @@ describe("ConversationCard", () => {
|
||||
});
|
||||
|
||||
it("should not display the edit or delete options if the handler is not provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
@@ -384,19 +434,15 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = await screen.findByTestId("context-menu");
|
||||
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
|
||||
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
|
||||
|
||||
// toggle to hide the context menu
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
@@ -404,10 +450,11 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(ellipsisButton);
|
||||
const newMenu = await screen.findByTestId("context-menu");
|
||||
expect(
|
||||
within(newMenu).queryByTestId("edit-button"),
|
||||
|
||||
@@ -295,4 +295,521 @@ describe("ConversationPanel", () => {
|
||||
const newCards = await screen.findAllByTestId("conversation-card");
|
||||
expect(newCards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should cancel stopping a conversation", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create mock data with a RUNNING conversation
|
||||
const mockRunningConversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
title: "Running Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
title: "Starting Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-02T12:00:00Z",
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
status: "STARTING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
title: "Stopped Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-03T12:00:00Z",
|
||||
created_at: "2021-10-03T12:00:00Z",
|
||||
status: "STOPPED" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(3);
|
||||
|
||||
// Click ellipsis on the first card (RUNNING status)
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Stop button should be available for RUNNING conversation
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
// Click the stop button
|
||||
await user.click(stopButton);
|
||||
|
||||
// Cancel the stopping action
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /cancel/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Ensure the conversation status hasn't changed
|
||||
const updatedCards = await screen.findAllByTestId("conversation-card");
|
||||
expect(updatedCards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should stop a conversation", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockData: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
title: "Running Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
title: "Starting Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-02T12:00:00Z",
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
status: "STARTING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => mockData);
|
||||
|
||||
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
const conversation = mockData.find((conv) => conv.conversation_id === id);
|
||||
if (conversation) {
|
||||
conversation.status = "STOPPED";
|
||||
return conversation;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(2);
|
||||
|
||||
// Click ellipsis on the first card (RUNNING status)
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
|
||||
// Click the stop button
|
||||
await user.click(stopButton);
|
||||
|
||||
// Confirm the stopping action
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /confirm/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify the API was called
|
||||
expect(stopConversationSpy).toHaveBeenCalledWith("1");
|
||||
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should only show stop button for STARTING or RUNNING conversations", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockMixedStatusConversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
title: "Running Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
title: "Starting Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-02T12:00:00Z",
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
status: "STARTING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
title: "Stopped Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-03T12:00:00Z",
|
||||
created_at: "2021-10-03T12:00:00Z",
|
||||
status: "STOPPED" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(3);
|
||||
|
||||
// Test RUNNING conversation - should show stop button
|
||||
const runningEllipsisButton = within(cards[0]).getByTestId(
|
||||
"ellipsis-button",
|
||||
);
|
||||
await user.click(runningEllipsisButton);
|
||||
|
||||
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
|
||||
|
||||
// Click outside to close the menu
|
||||
await user.click(document.body);
|
||||
|
||||
// Test STARTING conversation - should show stop button
|
||||
const startingEllipsisButton = within(cards[1]).getByTestId(
|
||||
"ellipsis-button",
|
||||
);
|
||||
await user.click(startingEllipsisButton);
|
||||
|
||||
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
|
||||
|
||||
// Click outside to close the menu
|
||||
await user.click(document.body);
|
||||
|
||||
// Test STOPPED conversation - should NOT show stop button
|
||||
const stoppedEllipsisButton = within(cards[2]).getByTestId(
|
||||
"ellipsis-button",
|
||||
);
|
||||
await user.click(stoppedEllipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show edit button in context menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(3);
|
||||
|
||||
// Click ellipsis to open context menu
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Edit button should be visible
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toHaveTextContent("BUTTON$EDIT_TITLE");
|
||||
});
|
||||
|
||||
it("should enter edit mode when edit button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Click ellipsis to open context menu
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Click edit button
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Should find input field instead of title text
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
expect(titleInput).toBeInTheDocument();
|
||||
expect(titleInput.tagName).toBe("INPUT");
|
||||
expect(titleInput).toHaveValue("Conversation 1");
|
||||
expect(titleInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it("should successfully update conversation title", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock the updateConversation API call
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
// Mock the toast function
|
||||
const mockToast = vi.fn();
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: mockToast,
|
||||
}));
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Enter edit mode
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Updated Title");
|
||||
|
||||
// Blur the input to save
|
||||
await user.tab();
|
||||
|
||||
// Verify API call was made with correct parameters
|
||||
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
|
||||
title: "Updated Title",
|
||||
});
|
||||
});
|
||||
|
||||
it("should save title when Enter key is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Enter edit mode
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title and press Enter
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Title Updated via Enter");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Verify API call was made
|
||||
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
|
||||
title: "Title Updated via Enter",
|
||||
});
|
||||
});
|
||||
|
||||
it("should trim whitespace from title", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Enter edit mode
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title with extra whitespace
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, " Trimmed Title ");
|
||||
await user.tab();
|
||||
|
||||
// Verify API call was made with trimmed title
|
||||
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
|
||||
title: "Trimmed Title",
|
||||
});
|
||||
|
||||
// Verify input shows trimmed value
|
||||
expect(titleInput).toHaveValue("Trimmed Title");
|
||||
});
|
||||
|
||||
it("should revert to original title when empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Enter edit mode
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Clear the title completely
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
await user.clear(titleInput);
|
||||
await user.tab();
|
||||
|
||||
// Verify API was not called
|
||||
expect(updateConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Verify input reverted to original value
|
||||
expect(titleInput).toHaveValue("Conversation 1");
|
||||
});
|
||||
|
||||
it("should handle API error when updating title", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockRejectedValue(new Error("API Error"));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Enter edit mode
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Failed Update");
|
||||
await user.tab();
|
||||
|
||||
// Verify API call was made
|
||||
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
|
||||
title: "Failed Update",
|
||||
});
|
||||
|
||||
// Wait for error handling
|
||||
await waitFor(() => {
|
||||
expect(updateConversationSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should close context menu when edit button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Click ellipsis to open context menu
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Verify context menu is open
|
||||
const contextMenu = screen.getByTestId("context-menu");
|
||||
expect(contextMenu).toBeInTheDocument();
|
||||
|
||||
// Click edit button
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Verify context menu is closed
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call API when title is unchanged", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Enter edit mode
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Don't change the title, just blur
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
await user.tab();
|
||||
|
||||
// Verify API was called with the same title (since handleConversationTitleChange will always be called)
|
||||
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
|
||||
title: "Conversation 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle special characters in title", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
// Enter edit mode
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title with special characters
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Special @#$%^&*()_+ Characters");
|
||||
await user.tab();
|
||||
|
||||
// Verify API call was made with special characters
|
||||
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
|
||||
title: "Special @#$%^&*()_+ Characters",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,12 @@ vi.mock("react-i18next", async () => {
|
||||
t: (key: string) => {
|
||||
// Return a mock translation for the test
|
||||
const translations: Record<string, string> = {
|
||||
"HOME$LETS_START_BUILDING": "Let's start building",
|
||||
"HOME$LAUNCH_FROM_SCRATCH": "Launch from Scratch",
|
||||
"HOME$LOADING": "Loading...",
|
||||
"HOME$OPENHANDS_DESCRIPTION": "OpenHands is an AI software engineer",
|
||||
"HOME$NOT_SURE_HOW_TO_START": "Not sure how to start?",
|
||||
"HOME$READ_THIS": "Read this"
|
||||
HOME$LETS_START_BUILDING: "Let's start building",
|
||||
HOME$LAUNCH_FROM_SCRATCH: "Launch from Scratch",
|
||||
HOME$LOADING: "Loading...",
|
||||
HOME$OPENHANDS_DESCRIPTION: "OpenHands is an AI software engineer",
|
||||
HOME$NOT_SURE_HOW_TO_START: "Not sure how to start?",
|
||||
HOME$READ_THIS: "Read this",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -69,7 +69,6 @@ describe("HomeHeader", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -119,18 +119,48 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("should render the 'add git(hub|lab) repos' links if saas mode", async () => {
|
||||
it("should render the 'add github repos' link if saas mode and github provider is set", async () => {
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "some-token",
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
await screen.findByText("HOME$ADD_GITHUB_REPOS");
|
||||
});
|
||||
|
||||
it("should not render the 'add github repos' link if github provider is not set", async () => {
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: "some-token",
|
||||
github: null,
|
||||
},
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE
|
||||
@@ -176,9 +206,8 @@ describe("RepoConnector", () => {
|
||||
"rbren/polaris",
|
||||
"github",
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
"main",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,6 +66,11 @@ vi.mock("#/hooks/use-debounce", () => ({
|
||||
useDebounce: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useNavigate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
const renderForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
@@ -252,8 +257,6 @@ describe("RepositorySelectionForm", () => {
|
||||
expect(searchedRepo).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchedRepo);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(
|
||||
MOCK_SEARCH_REPOS[0].full_name,
|
||||
);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,9 +88,14 @@ describe("TaskCard", () => {
|
||||
MOCK_RESPOSITORIES[0].full_name,
|
||||
MOCK_RESPOSITORIES[0].git_provider,
|
||||
undefined,
|
||||
[],
|
||||
{
|
||||
git_provider: "github",
|
||||
issue_number: 123,
|
||||
repo: "repo1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
title: "Task 1",
|
||||
},
|
||||
undefined,
|
||||
MOCK_TASK_1,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -105,4 +110,29 @@ describe("TaskCard", () => {
|
||||
expect(launchButton).toHaveTextContent(/Loading/i);
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should navigate to the conversation page after creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null
|
||||
});
|
||||
|
||||
renderTaskCard();
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
// Wait for navigation to the conversation page
|
||||
await screen.findByTestId("conversation-screen");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider } from "react-redux";
|
||||
@@ -7,6 +7,21 @@ import { setupStore } from "test-utils";
|
||||
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
|
||||
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
|
||||
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...(actual as object),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderTaskSuggestions = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -93,4 +108,26 @@ describe("TaskSuggestions", () => {
|
||||
|
||||
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the tooltip button", () => {
|
||||
renderTaskSuggestions();
|
||||
const tooltipButton = screen.getByTestId("task-suggestions-info");
|
||||
expect(tooltipButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have the correct aria-label", () => {
|
||||
renderTaskSuggestions();
|
||||
const tooltipButton = screen.getByTestId("task-suggestions-info");
|
||||
expect(tooltipButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
"TASKS$TASK_SUGGESTIONS_INFO",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the info icon", () => {
|
||||
renderTaskSuggestions();
|
||||
const tooltipButton = screen.getByTestId("task-suggestions-info");
|
||||
const icon = tooltipButton.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
vi.mock("react-redux", async () => {
|
||||
const actual = await vi.importActual("react-redux");
|
||||
return {
|
||||
...actual,
|
||||
useDispatch: () => vi.fn(),
|
||||
useSelector: () => ({
|
||||
agent: {
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("MicroagentsModal - Refresh Button", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const conversationId = "test-conversation-id";
|
||||
|
||||
const defaultProps = {
|
||||
onClose: mockOnClose,
|
||||
conversationId,
|
||||
};
|
||||
|
||||
const mockMicroagents = [
|
||||
{
|
||||
name: "Test Agent 1",
|
||||
type: "repo" as const,
|
||||
triggers: ["test", "example"],
|
||||
content: "This is test content for agent 1",
|
||||
},
|
||||
{
|
||||
name: "Test Agent 2",
|
||||
type: "knowledge" as const,
|
||||
triggers: ["help", "support"],
|
||||
content: "This is test content for agent 2",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(OpenHands, "getMicroagents").mockResolvedValue({
|
||||
microagents: mockMicroagents,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Refresh Button Rendering", () => {
|
||||
it("should render the refresh button with correct text and test ID", () => {
|
||||
renderWithProviders(<MicroagentsModal {...defaultProps} />);
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh-microagents");
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh Button Functionality", () => {
|
||||
it("should call refetch when refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<MicroagentsModal {...defaultProps} />);
|
||||
|
||||
const refreshSpy = vi.spyOn(OpenHands, "getMicroagents");
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh-microagents");
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
|
||||
describe("BadgeInput", () => {
|
||||
it("should render the values", () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["test", "test2"]} onChange={onChangeMock} />);
|
||||
|
||||
expect(screen.getByText("test")).toBeInTheDocument();
|
||||
expect(screen.getByText("test2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the input's as a badge on space", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, "test");
|
||||
await userEvent.type(input, " ");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(["badge1", "test"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should remove the badge on backspace", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1", "badge2"]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, "{backspace}");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(["badge1"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should remove the badge on click", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
const removeButton = screen.getByTestId("remove-button");
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("should not create empty badges", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={[]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, " ");
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,12 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should toggle the user menu when the user avatar is clicked", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
render(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
@@ -57,15 +62,102 @@ describe("UserActions", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("logout button is always enabled", async () => {
|
||||
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
// Context menu should NOT appear because user is undefined
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
it("should show context menu even when user has no avatar_url", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu SHOULD appear because user object exists (even with empty avatar_url)
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT be able to access logout when no user is provided", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Logout option should not be accessible because context menu doesn't appear
|
||||
expect(
|
||||
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onLogoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from undefined to defined", () => {
|
||||
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
// Initially no user - context menu shouldn't work
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Add user prop
|
||||
rerender(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Component should still render correctly
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from defined to undefined", async () => {
|
||||
const { rerender } = render(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click to open menu
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Remove user prop - menu should disappear
|
||||
rerender(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should work with loading state and user provided", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
isLoading={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should still appear even when loading
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
140
frontend/__tests__/hooks/query/use-feedback-exists.test.tsx
Normal file
140
frontend/__tests__/hooks/query/use-feedback-exists.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
|
||||
|
||||
// Mock the useConfig hook
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useConversationId hook
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: "test-conversation-id" }),
|
||||
}));
|
||||
|
||||
describe("useFeedbackExists", () => {
|
||||
let queryClient: QueryClient;
|
||||
const mockCheckFeedbackExists = vi.spyOn(OpenHands, "checkFeedbackExists");
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
mockCheckFeedbackExists.mockClear();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
it("should not call API when APP_MODE is not saas", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "oss" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call API when APP_MODE is saas", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
mockCheckFeedbackExists.mockResolvedValue({
|
||||
exists: true,
|
||||
rating: 5,
|
||||
reason: "Great job!",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for the query to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was called
|
||||
expect(mockCheckFeedbackExists).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
123,
|
||||
);
|
||||
|
||||
// Verify that the data is returned
|
||||
expect(result.current.data).toEqual({
|
||||
exists: true,
|
||||
rating: 5,
|
||||
reason: "Great job!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call API when eventId is not provided", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(undefined), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not call API when config is not loaded yet", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MicroagentStatusIndicator } from "#/components/features/chat/microagent/microagent-status-indicator";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
// Mock the translation hook
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MicroagentStatusIndicator", () => {
|
||||
it("should show 'View your PR' when status is completed and PR URL is provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should show default completed message when status is completed but no PR URL", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "MICROAGENT$STATUS_COMPLETED",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/conversations/test-conversation");
|
||||
});
|
||||
|
||||
it("should show creating status without PR URL", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.CREATING}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error status", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.ERROR}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should prioritize PR URL over conversation link when both are provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
);
|
||||
// Should not link to conversation when PR URL is available
|
||||
expect(link).not.toHaveAttribute(
|
||||
"href",
|
||||
"/conversations/test-conversation",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with GitLab MR URLs", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
prUrl="https://gitlab.com/owner/repo/-/merge_requests/456"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
});
|
||||
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractPRUrls,
|
||||
containsPRUrl,
|
||||
getFirstPRUrl,
|
||||
} from "#/utils/parse-pr-url";
|
||||
|
||||
describe("parse-pr-url", () => {
|
||||
describe("extractPRUrls", () => {
|
||||
it("should extract GitHub PR URLs", () => {
|
||||
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
|
||||
it("should extract GitLab MR URLs", () => {
|
||||
const text =
|
||||
"Merge request: https://gitlab.com/owner/repo/-/merge_requests/456";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract Bitbucket PR URLs", () => {
|
||||
const text =
|
||||
"PR link: https://bitbucket.org/owner/repo/pull-requests/789";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://bitbucket.org/owner/repo/pull-requests/789",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract Azure DevOps PR URLs", () => {
|
||||
const text =
|
||||
"Azure PR: https://dev.azure.com/org/project/_git/repo/pullrequest/101";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://dev.azure.com/org/project/_git/repo/pullrequest/101",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract multiple PR URLs", () => {
|
||||
const text = `
|
||||
GitHub: https://github.com/owner/repo/pull/123
|
||||
GitLab: https://gitlab.com/owner/repo/-/merge_requests/456
|
||||
`;
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toHaveLength(2);
|
||||
expect(urls).toContain("https://github.com/owner/repo/pull/123");
|
||||
expect(urls).toContain(
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle self-hosted GitLab URLs", () => {
|
||||
const text =
|
||||
"Self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://gitlab.example.com/owner/repo/-/merge_requests/123",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array when no PR URLs found", () => {
|
||||
const text = "This is just regular text with no PR URLs";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle URLs with HTTP instead of HTTPS", () => {
|
||||
const text = "HTTP PR: http://github.com/owner/repo/pull/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["http://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
|
||||
it("should remove duplicate URLs", () => {
|
||||
const text = `
|
||||
Same PR mentioned twice:
|
||||
https://github.com/owner/repo/pull/123
|
||||
https://github.com/owner/repo/pull/123
|
||||
`;
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("containsPRUrl", () => {
|
||||
it("should return true when PR URL is present", () => {
|
||||
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
|
||||
expect(containsPRUrl(text)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when no PR URL is present", () => {
|
||||
const text = "This is just regular text";
|
||||
expect(containsPRUrl(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstPRUrl", () => {
|
||||
it("should return the first PR URL found", () => {
|
||||
const text = `
|
||||
First: https://github.com/owner/repo/pull/123
|
||||
Second: https://gitlab.com/owner/repo/-/merge_requests/456
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/123");
|
||||
});
|
||||
|
||||
it("should return null when no PR URL is found", () => {
|
||||
const text = "This is just regular text";
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
it("should handle typical microagent finish messages", () => {
|
||||
const text = `
|
||||
I have successfully created a pull request with the requested changes.
|
||||
You can view the PR here: https://github.com/All-Hands-AI/OpenHands/pull/1234
|
||||
|
||||
The changes include:
|
||||
- Updated the component
|
||||
- Added tests
|
||||
- Fixed the issue
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/All-Hands-AI/OpenHands/pull/1234");
|
||||
});
|
||||
|
||||
it("should handle messages with PR URLs in the middle", () => {
|
||||
const text = `
|
||||
Task completed successfully! I've created a pull request at
|
||||
https://github.com/owner/repo/pull/567 with all the requested changes.
|
||||
Please review when you have a chance.
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/567");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ describe("HomeScreen", () => {
|
||||
const mainContainer = screen
|
||||
.getByTestId("home-screen")
|
||||
.querySelector("main");
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
|
||||
});
|
||||
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
// Mock the i18next hook
|
||||
@@ -31,16 +31,27 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("Settings Screen", () => {
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
|
||||
handleLogoutMock: vi.fn(),
|
||||
mockQueryClient: (() => {
|
||||
const { QueryClient } = require("@tanstack/react-query");
|
||||
return new QueryClient();
|
||||
})(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-app-logout", () => ({
|
||||
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
|
||||
}));
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: mockQueryClient,
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
// @ts-expect-error - custom loader
|
||||
clientLoader,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
@@ -56,8 +67,8 @@ describe("Settings Screen", () => {
|
||||
path: "/settings/app",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="credits-settings-screen" />,
|
||||
path: "/settings/credits",
|
||||
Component: () => <div data-testid="billing-settings-screen" />,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="api-keys-settings-screen" />,
|
||||
@@ -67,26 +78,27 @@ describe("Settings Screen", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = (path = "/settings") => {
|
||||
const queryClient = new QueryClient();
|
||||
return render(<RouterStub initialEntries={[path]} />, {
|
||||
const renderSettingsScreen = (path = "/settings") =>
|
||||
render(<RouterStub initialEntries={[path]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
it("should render the navbar", async () => {
|
||||
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
|
||||
const sectionsToExclude = ["api keys", "credits"];
|
||||
const sectionsToExclude = ["api keys", "credits", "billing"];
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -102,6 +114,8 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should render the saas navbar", async () => {
|
||||
@@ -113,12 +127,15 @@ describe("Settings Screen", () => {
|
||||
const sectionsToInclude = [
|
||||
"integrations",
|
||||
"application",
|
||||
"credits",
|
||||
"credits", // The nav item shows "credits" text but routes to /billing
|
||||
"secrets",
|
||||
"api keys",
|
||||
];
|
||||
const sectionsToExclude = ["llm"];
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -134,30 +151,44 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not be able to access oss-restricted routes in oss", async () => {
|
||||
it("should not be able to access saas-only routes in oss mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
const { rerender } = renderSettingsScreen("/settings/credits");
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
// In OSS mode, accessing restricted routes should redirect to /settings
|
||||
// Since createRoutesStub doesn't handle clientLoader redirects properly,
|
||||
// we test that the correct navbar is shown (OSS navbar) and that
|
||||
// the restricted route components are not rendered when accessing /settings
|
||||
renderSettingsScreen("/settings");
|
||||
|
||||
// Verify we're in OSS mode by checking the navbar
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
expect(within(navbar).getByText("LLM")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("credits-settings-screen"),
|
||||
within(navbar).queryByText("credits", { exact: false }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(<RouterStub initialEntries={["/settings/api-keys"]} />);
|
||||
expect(
|
||||
screen.queryByTestId("api-keys-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
rerender(<RouterStub initialEntries={["/settings/billing"]} />);
|
||||
// Verify the LLM settings screen is shown
|
||||
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("billing-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
rerender(<RouterStub initialEntries={["/settings"]} />);
|
||||
expect(
|
||||
screen.queryByTestId("api-keys-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.todo("should not be able to access saas-restricted routes in saas");
|
||||
it.todo("should not be able to access oss-only routes in saas mode");
|
||||
});
|
||||
|
||||
95
frontend/__tests__/services/actions.test.tsx
Normal file
95
frontend/__tests__/services/actions.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import ActionType from "#/types/action-type";
|
||||
import { ActionMessage } from "#/types/message";
|
||||
|
||||
// Mock the store and actions
|
||||
const mockDispatch = vi.fn();
|
||||
const mockAppendInput = vi.fn();
|
||||
const mockAppendJupyterInput = vi.fn();
|
||||
|
||||
vi.mock("#/store", () => ({
|
||||
default: {
|
||||
dispatch: mockDispatch,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/command-slice", () => ({
|
||||
appendInput: mockAppendInput,
|
||||
}));
|
||||
|
||||
vi.mock("#/state/jupyter-slice", () => ({
|
||||
appendJupyterInput: mockAppendJupyterInput,
|
||||
}));
|
||||
|
||||
describe("handleActionMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should handle RUN actions by adding input to terminal", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const runAction: ActionMessage = {
|
||||
id: 1,
|
||||
source: "agent",
|
||||
action: ActionType.RUN,
|
||||
args: {
|
||||
command: "ls -la",
|
||||
},
|
||||
message: "Running command: ls -la",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(runAction);
|
||||
|
||||
// Check that appendInput was called with the command
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockAppendInput("ls -la"));
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const ipythonAction: ActionMessage = {
|
||||
id: 2,
|
||||
source: "agent",
|
||||
action: ActionType.RUN_IPYTHON,
|
||||
args: {
|
||||
code: "print('Hello from Jupyter!')",
|
||||
},
|
||||
message: "Running Python code interactively: print('Hello from Jupyter!')",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(ipythonAction);
|
||||
|
||||
// Check that appendJupyterInput was called with the code
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockAppendJupyterInput("print('Hello from Jupyter!')"));
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not process hidden actions", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const hiddenAction: ActionMessage = {
|
||||
id: 3,
|
||||
source: "agent",
|
||||
action: ActionType.RUN,
|
||||
args: {
|
||||
command: "secret command",
|
||||
hidden: "true",
|
||||
},
|
||||
message: "Running command: secret command",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(hiddenAction);
|
||||
|
||||
// Check that nothing was dispatched
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { HomeHeader } from "#/components/features/home/home-header";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
|
||||
useCreateConversation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-creating-conversation", () => ({
|
||||
useIsCreatingConversation: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Check for hardcoded English strings in Home components", () => {
|
||||
test("HomeHeader should not have hardcoded English strings", () => {
|
||||
const { container } = render(<HomeHeader />);
|
||||
|
||||
// Get all text content
|
||||
const text = container.textContent;
|
||||
|
||||
// List of English strings that should be translated
|
||||
const hardcodedStrings = [
|
||||
"Launch from Scratch",
|
||||
"Read this",
|
||||
];
|
||||
|
||||
// Check each string
|
||||
hardcodedStrings.forEach((str) => {
|
||||
expect(text).not.toContain(str);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,17 +82,5 @@ describe("extractModelAndProvider", () => {
|
||||
model: "claude-opus-4-20250514",
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-3-haiku-20240307")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-haiku-20240307",
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-2.1")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-2.1",
|
||||
separator: "/",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,14 +52,16 @@ test("organizeModelsAndProviders", () => {
|
||||
separator: "/",
|
||||
models: [
|
||||
"claude-3-5-sonnet-20241022",
|
||||
],
|
||||
},
|
||||
other: {
|
||||
separator: "",
|
||||
models: [
|
||||
"together-ai-21.1b-41b",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-2",
|
||||
"claude-2.1",
|
||||
],
|
||||
},
|
||||
other: {
|
||||
separator: "",
|
||||
models: ["together-ai-21.1b-41b"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
2373
frontend/package-lock.json
generated
2373
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,56 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.47.0",
|
||||
"version": "0.49.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.0-beta.10",
|
||||
"@heroui/react": "^2.8.1",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.6.3",
|
||||
"@react-router/serve": "^7.6.3",
|
||||
"@react-router/node": "^7.7.0",
|
||||
"@react-router/serve": "^7.7.0",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.4.0",
|
||||
"@stripe/stripe-js": "^7.5.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.81.4",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.19.2",
|
||||
"i18next": "^25.2.1",
|
||||
"framer-motion": "^12.23.6",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.525.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.255.1",
|
||||
"posthog-js": "^1.257.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.6.3",
|
||||
"react-router": "^7.7.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.0.0",
|
||||
"vite": "^7.0.5",
|
||||
"web-vitals": "^5.0.3",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -70,7 +71,6 @@
|
||||
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
|
||||
"prepare": "cd .. && husky frontend/.husky",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs",
|
||||
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
@@ -80,19 +80,19 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.27.7",
|
||||
"@babel/traverse": "^7.27.7",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.28.1",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@react-router/dev": "^7.6.3",
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@react-router/dev": "^7.7.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.81.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.5",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -106,10 +106,11 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-i18next": "^6.1.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
@@ -118,7 +119,7 @@
|
||||
"lint-staged": "^16.1.2",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.2.1",
|
||||
"stripe": "^18.3.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.2'
|
||||
const PACKAGE_VERSION = '2.10.3'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@@ -1,739 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Pre-commit hook script to check for unlocalized strings in the frontend code
|
||||
* This script is based on the test in __tests__/utils/check-hardcoded-strings.test.tsx
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const parser = require('@babel/parser');
|
||||
const traverse = require('@babel/traverse').default;
|
||||
|
||||
// Files/directories to ignore
|
||||
const IGNORE_PATHS = [
|
||||
// Build and dependency files
|
||||
"node_modules",
|
||||
"dist",
|
||||
".git",
|
||||
"test",
|
||||
"__tests__",
|
||||
".d.ts",
|
||||
"i18n",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
|
||||
// Internal code that doesn't need localization
|
||||
"mocks", // Mock data
|
||||
"assets", // SVG paths and CSS classes
|
||||
"types", // Type definitions and constants
|
||||
"state", // Redux state management
|
||||
"api", // API endpoints
|
||||
"services", // Internal services
|
||||
"hooks", // React hooks
|
||||
"context", // React context
|
||||
"store", // Redux store
|
||||
"routes.ts", // Route definitions
|
||||
"root.tsx", // Root component
|
||||
"entry.client.tsx", // Client entry point
|
||||
"utils/scan-unlocalized-strings.ts", // Original scanner
|
||||
"utils/scan-unlocalized-strings-ast.ts", // This file itself
|
||||
"frontend/src/components/features/home/tasks/get-prompt-for-query.ts", // Only contains agent prompts
|
||||
];
|
||||
|
||||
// Extensions to scan
|
||||
const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
||||
|
||||
// Attributes that typically don't contain user-facing text
|
||||
const NON_TEXT_ATTRIBUTES = [
|
||||
"allow",
|
||||
"className",
|
||||
"i18nKey",
|
||||
"testId",
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"href",
|
||||
"src",
|
||||
"rel",
|
||||
"target",
|
||||
"style",
|
||||
"onClick",
|
||||
"onChange",
|
||||
"onSubmit",
|
||||
"data-testid",
|
||||
"aria-labelledby",
|
||||
"aria-describedby",
|
||||
"aria-hidden",
|
||||
"role",
|
||||
"sandbox",
|
||||
];
|
||||
|
||||
function shouldIgnorePath(filePath) {
|
||||
return IGNORE_PATHS.some((ignore) => filePath.includes(ignore));
|
||||
}
|
||||
|
||||
// Check if a string looks like a translation key
|
||||
// Translation keys typically use dots, underscores, or are all caps
|
||||
// Also check for the pattern with $ which is used in our translation keys
|
||||
function isLikelyTranslationKey(str) {
|
||||
return (
|
||||
/^[A-Z0-9_$.]+$/.test(str) ||
|
||||
str.includes(".") ||
|
||||
/[A-Z0-9_]+\$[A-Z0-9_]+/.test(str)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if a string is a raw translation key that should be wrapped in t()
|
||||
function isRawTranslationKey(str) {
|
||||
// Check for our specific translation key pattern (e.g., "SETTINGS$GITHUB_SETTINGS")
|
||||
// Exclude specific keys that are already properly used with i18next.t() in the code
|
||||
const excludedKeys = [
|
||||
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
|
||||
"ERROR$GENERIC",
|
||||
"GITHUB$AUTH_SCOPE",
|
||||
];
|
||||
|
||||
if (excludedKeys.includes(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str);
|
||||
}
|
||||
|
||||
// Specific technical strings that should be excluded from localization
|
||||
const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"openid email profile", // OAuth scope string - not user-facing
|
||||
"OPEN_ISSUE", // Task type identifier, not a UI string
|
||||
"Merge Request", // Git provider specific terminology
|
||||
"GitLab API", // Git provider specific terminology
|
||||
"Pull Request", // Git provider specific terminology
|
||||
"GitHub API", // Git provider specific terminology
|
||||
"add-secret-form", // Test ID for secret form
|
||||
"edit-secret-form", // Test ID for secret form
|
||||
"search-api-key-input", // Input name for search API key
|
||||
"noopener,noreferrer", // Options for window.open
|
||||
"STATUS$READY",
|
||||
"STATUS$STOPPED",
|
||||
"STATUS$ERROR",
|
||||
];
|
||||
|
||||
function isExcludedTechnicalString(str) {
|
||||
return EXCLUDED_TECHNICAL_STRINGS.includes(str);
|
||||
}
|
||||
|
||||
function isLikelyCode(str) {
|
||||
// A string with no spaces and at least one underscore or colon is likely a code.
|
||||
// (e.g.: "browser_interactive" or "error:")
|
||||
if (str.includes(" ")) {
|
||||
return false
|
||||
}
|
||||
if (str.includes(":") || str.includes("_")){
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isCommonDevelopmentString(str) {
|
||||
|
||||
// Technical patterns that are definitely not UI strings
|
||||
const technicalPatterns = [
|
||||
// URLs and paths
|
||||
/^https?:\/\//, // URLs
|
||||
/^\/[a-zA-Z0-9_\-./]*$/, // File paths
|
||||
/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/, // File extensions, class names
|
||||
/^@[a-zA-Z0-9/-]+$/, // Import paths
|
||||
/^#\/[a-zA-Z0-9/-]+$/, // Alias imports
|
||||
/^[a-zA-Z0-9/-]+\/[a-zA-Z0-9/-]+$/, // Module paths
|
||||
/^data:image\/[a-zA-Z0-9;,]+$/, // Data URLs
|
||||
/^application\/[a-zA-Z0-9-]+$/, // MIME types
|
||||
/^!\[image]\(data:image\/png;base64,$/, // Markdown image with base64 data
|
||||
|
||||
// Numbers, IDs, and technical values
|
||||
/^\d+(\.\d+)?$/, // Numbers
|
||||
/^#[0-9a-fA-F]{3,8}$/, // Color codes
|
||||
/^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$/, // Key-value pairs
|
||||
/^mm:ss$/, // Time format
|
||||
/^[a-zA-Z0-9]+\/[a-zA-Z0-9-]+$/, // Provider/model format
|
||||
/^\?[a-zA-Z0-9_-]+$/, // URL parameters
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID
|
||||
/^[A-Za-z0-9+/=]+$/, // Base64
|
||||
|
||||
// HTML and CSS selectors
|
||||
/^[a-z]+(\[[^\]]+\])+$/, // CSS attribute selectors
|
||||
/^[a-z]+:[a-z-]+$/, // CSS pseudo-selectors
|
||||
/^[a-z]+\.[a-z0-9_-]+$/, // CSS class selectors
|
||||
/^[a-z]+#[a-z0-9_-]+$/, // CSS ID selectors
|
||||
/^[a-z]+\s*>\s*[a-z]+$/, // CSS child selectors
|
||||
/^[a-z]+\s+[a-z]+$/, // CSS descendant selectors
|
||||
|
||||
// CSS and styling patterns
|
||||
/^[a-z0-9-]+:[a-z0-9-]+$/, // CSS property:value
|
||||
/^[a-z0-9-]+:[a-z0-9-]+;[a-z0-9-]+:[a-z0-9-]+$/, // Multiple CSS properties
|
||||
];
|
||||
|
||||
// File extensions and media types
|
||||
const fileExtensionPattern =
|
||||
/^\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|pdf|mp4|webm|ogg|mp3|wav|json|xml|csv|txt|md|html|css|js|jsx|ts|tsx)$/i;
|
||||
if (fileExtensionPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AI model and provider patterns
|
||||
const aiRelatedPattern =
|
||||
/^(AI|OpenAI|VertexAI|PaLM|Gemini|Anthropic|Anyscale|Databricks|Ollama|FriendliAI|Groq|DeepInfra|AI21|Replicate|OpenRouter|Azure|AWS|SageMaker|Bedrock|Mistral|Perplexity|Fireworks|Cloudflare|Workers|Voyage|claude-|gpt-|o1-|o3-)/i;
|
||||
if (aiRelatedPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CSS units and values
|
||||
const cssUnitsPattern =
|
||||
/\b\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$|^(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/;
|
||||
const cssValuesPattern =
|
||||
/(rgb|rgba|hsl|hsla|#[0-9a-fA-F]+|solid|absolute|relative|sticky|fixed|static|block|inline|flex|grid|none|auto|hidden|visible)/;
|
||||
|
||||
if (cssUnitsPattern.test(str) || cssValuesPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for CSS class strings with brackets (common in the codebase)
|
||||
if (
|
||||
str.includes("[") &&
|
||||
str.includes("]") &&
|
||||
(str.includes("px") ||
|
||||
str.includes("rem") ||
|
||||
str.includes("em") ||
|
||||
str.includes("w-") ||
|
||||
str.includes("h-") ||
|
||||
str.includes("p-") ||
|
||||
str.includes("m-"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for CSS class strings with specific patterns
|
||||
if (
|
||||
str.includes("border-") ||
|
||||
str.includes("rounded-") ||
|
||||
str.includes("cursor-") ||
|
||||
str.includes("opacity-") ||
|
||||
str.includes("disabled:") ||
|
||||
str.includes("hover:") ||
|
||||
str.includes("focus-within:") ||
|
||||
str.includes("first-of-type:") ||
|
||||
str.includes("last-of-type:") ||
|
||||
str.includes("group-data-")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it looks like a Tailwind class string
|
||||
if (/^[a-z0-9-]+(\s+[a-z0-9-]+)*$/.test(str)) {
|
||||
// Common Tailwind prefixes and patterns
|
||||
const tailwindPrefixes = [
|
||||
"bg-", "text-", "border-", "rounded-", "p-", "m-", "px-", "py-", "mx-", "my-",
|
||||
"w-", "h-", "min-w-", "min-h-", "max-w-", "max-h-", "flex-", "grid-", "gap-",
|
||||
"space-", "items-", "justify-", "self-", "col-", "row-", "order-", "object-",
|
||||
"overflow-", "opacity-", "z-", "top-", "right-", "bottom-", "left-", "inset-",
|
||||
"font-", "tracking-", "leading-", "list-", "placeholder-", "shadow-", "ring-",
|
||||
"transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-",
|
||||
"translate-", "skew-", "origin-", "cursor-", "select-", "resize-", "fill-", "stroke-",
|
||||
];
|
||||
|
||||
// Check if any word in the string starts with a Tailwind prefix
|
||||
const words = str.split(/\s+/);
|
||||
for (const word of words) {
|
||||
for (const prefix of tailwindPrefixes) {
|
||||
if (word.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Tailwind modifiers
|
||||
const tailwindModifiers = [
|
||||
"hover:", "focus:", "active:", "disabled:", "visited:", "first:", "last:",
|
||||
"odd:", "even:", "group-hover:", "focus-within:", "focus-visible:", "motion-safe:",
|
||||
"motion-reduce:", "dark:", "light:", "sm:", "md:", "lg:", "xl:", "2xl:",
|
||||
];
|
||||
|
||||
for (const word of words) {
|
||||
for (const modifier of tailwindModifiers) {
|
||||
if (word.includes(modifier)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CSS property combinations
|
||||
const cssProperties = [
|
||||
"border", "rounded", "px", "py", "mx", "my", "p", "m", "w", "h", "flex",
|
||||
"grid", "gap", "transition", "duration", "font", "leading", "tracking",
|
||||
];
|
||||
|
||||
// If the string contains multiple CSS properties, it's likely a CSS class string
|
||||
let cssPropertyCount = 0;
|
||||
for (const word of words) {
|
||||
if (
|
||||
cssProperties.some(
|
||||
(prop) => word === prop || word.startsWith(`${prop}-`),
|
||||
)
|
||||
) {
|
||||
cssPropertyCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (cssPropertyCount >= 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for specific CSS class patterns that appear in the test failures
|
||||
if (
|
||||
str.match(
|
||||
/^(border|rounded|flex|grid|transition|duration|ease|hover:|focus:|active:|disabled:|placeholder:|text-|bg-|w-|h-|p-|m-|gap-|items-|justify-|self-|overflow-|cursor-|opacity-|z-|top-|right-|bottom-|left-|inset-|font-|tracking-|leading-|whitespace-|break-|truncate|shadow-|ring-|outline-|animate-|transform|rotate-|scale-|skew-|translate-|origin-|first-of-type:|last-of-type:|group-data-|max-|min-|px-|py-|mx-|my-|grow|shrink|resize-|underline|italic|normal)/,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTML tags and attributes
|
||||
if (
|
||||
/^<[a-z0-9]+(?:\s[^>]*)?>.*<\/[a-z0-9]+>$/i.test(str) ||
|
||||
/^<[a-z0-9]+ [^>]+\/>$/i.test(str)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific patterns in suggestions and examples
|
||||
if (
|
||||
str.includes("* ") &&
|
||||
(str.includes("create a") ||
|
||||
str.includes("build a") ||
|
||||
str.includes("make a"))
|
||||
) {
|
||||
// This is likely a suggestion or example, not a UI string
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for specific technical identifiers from the test failures
|
||||
if (
|
||||
/^(download_via_vscode_button_clicked|open-vscode-error-|set-indicator|settings_saved|openhands-trace-|provider-item-|last_browser_action_error)$/.test(
|
||||
str,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for URL paths and query parameters
|
||||
if (
|
||||
str.startsWith("?") ||
|
||||
str.startsWith("/") ||
|
||||
str.includes("auth.") ||
|
||||
str.includes("$1auth.")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific strings that should be excluded
|
||||
if (
|
||||
str === "Cache Hit:" ||
|
||||
str === "Cache Write:" ||
|
||||
str === "ADD_DOCS" ||
|
||||
str === "ADD_DOCKERFILE" ||
|
||||
str === "Verified" ||
|
||||
str === "Others" ||
|
||||
str === "Feedback" ||
|
||||
str === "JSON File" ||
|
||||
str === "mt-0.5 md:mt-0"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for long suggestion texts
|
||||
if (
|
||||
str.length > 100 &&
|
||||
(str.includes("Please write a bash script") ||
|
||||
str.includes("Please investigate the repo") ||
|
||||
str.includes("Please push the changes") ||
|
||||
str.includes("Examine the dependencies") ||
|
||||
str.includes("Investigate the documentation") ||
|
||||
str.includes("Investigate the current repo") ||
|
||||
str.includes("I want to create a Hello World app") ||
|
||||
str.includes("I want to create a VueJS app") ||
|
||||
str.includes("This should be a client-only app"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific error messages and UI text
|
||||
if (
|
||||
str === "All data associated with this project will be lost." ||
|
||||
str === "You will lose any unsaved information." ||
|
||||
str ===
|
||||
"This conversation does not exist, or you do not have permission to access it." ||
|
||||
str === "Failed to fetch settings. Please try reloading." ||
|
||||
str ===
|
||||
"If you tell OpenHands to start a web server, the app will appear here." ||
|
||||
str ===
|
||||
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API." ||
|
||||
str ===
|
||||
"Something went wrong while fetching settings. Please reload the page." ||
|
||||
str ===
|
||||
"To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." ||
|
||||
str === "Please push the latest changes to the existing pull request."
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check against all technical patterns
|
||||
return technicalPatterns.some((pattern) => pattern.test(str));
|
||||
}
|
||||
|
||||
function isLikelyUserFacingText(str) {
|
||||
|
||||
// Basic validation - skip very short strings or strings without letters
|
||||
if (!str || str.length <= 2 || !/[a-zA-Z]/.test(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a specifically excluded technical string
|
||||
if (isExcludedTechnicalString(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it looks like a code rather than a key
|
||||
if (isLikelyCode(str)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a raw translation key that should be wrapped in t()
|
||||
if (isRawTranslationKey(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a translation key pattern (e.g., "SETTINGS$BASE_URL")
|
||||
// These should be wrapped in t() or use I18nKey enum
|
||||
if (isLikelyTranslationKey(str) && /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// First, check if it's a common development string (not user-facing)
|
||||
if (isCommonDevelopmentString(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Multi-word phrases are likely UI text
|
||||
const hasMultipleWords = /\s+/.test(str) && str.split(/\s+/).length > 1;
|
||||
|
||||
// Sentences and questions are likely UI text
|
||||
const hasPunctuation = /[?!.,:]/.test(str);
|
||||
const isCapitalizedPhrase = /^[A-Z]/.test(str) && hasMultipleWords;
|
||||
const isTitleCase = hasMultipleWords && /\s[A-Z]/.test(str);
|
||||
const hasSentenceStructure = /^[A-Z].*[.!?]$/.test(str); // Starts with capital, ends with punctuation
|
||||
const hasQuestionForm =
|
||||
/^(What|How|Why|When|Where|Who|Can|Could|Would|Will|Is|Are|Do|Does|Did|Should|May|Might)/.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Product names and camelCase identifiers are likely UI text
|
||||
const hasInternalCapitals = /[a-z][A-Z]/.test(str); // CamelCase product names
|
||||
|
||||
// Instruction text patterns are likely UI text
|
||||
const looksLikeInstruction =
|
||||
/^(Enter|Type|Select|Choose|Provide|Specify|Search|Find|Input|Add|Write|Describe|Set|Pick|Browse|Upload|Download|Click|Tap|Press|Go to|Visit|Open|Close)/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Error and status messages are likely UI text
|
||||
const looksLikeErrorOrStatus =
|
||||
/(failed|error|invalid|required|missing|incorrect|wrong|unavailable|not found|not available|try again|success|completed|finished|done|saved|updated|created|deleted|removed|added)/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Single word check - assume it's UI text unless proven otherwise
|
||||
const isSingleWord =
|
||||
!str.includes(" ") && str.length > 1 && /^[a-zA-Z]+$/.test(str);
|
||||
|
||||
// For single words, we need to be more careful
|
||||
if (isSingleWord) {
|
||||
// Skip common programming terms and variable names
|
||||
const isCommonProgrammingTerm =
|
||||
/^(null|undefined|true|false|function|class|interface|type|enum|const|let|var|return|import|export|default|async|await|try|catch|finally|throw|new|this|super|extends|implements|instanceof|typeof|void|delete|in|of|for|while|do|if|else|switch|case|break|continue|yield|static|get|set|public|private|protected|readonly|abstract|implements|namespace|module|declare|as|from|with)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonProgrammingTerm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common variable name patterns
|
||||
const looksLikeVariableName =
|
||||
/^[a-z][a-zA-Z0-9]*$/.test(str) && str.length <= 20;
|
||||
|
||||
if (looksLikeVariableName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common CSS values
|
||||
const isCommonCssValue =
|
||||
/^(auto|none|hidden|visible|block|inline|flex|grid|row|column|wrap|nowrap|center|start|end|stretch|cover|contain|fixed|absolute|relative|static|sticky|pointer|default|inherit|initial|unset)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonCssValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common file extensions
|
||||
const isFileExtension = /^\.[a-z0-9]+$/i.test(str);
|
||||
if (isFileExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common abbreviations
|
||||
const isCommonAbbreviation =
|
||||
/^(id|src|href|url|alt|img|btn|nav|div|span|ul|li|ol|dl|dt|dd|svg|png|jpg|gif|pdf|doc|txt|md|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|toml|csv|mp3|mp4|wav|avi|mov|mpeg|webm|webp|ttf|woff|eot|otf)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonAbbreviation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a single word that's not a programming term, variable name, CSS value, file extension, or abbreviation,
|
||||
// it might be UI text, but we'll be conservative and return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it has multiple words, punctuation, or looks like a sentence, it's likely UI text
|
||||
return (
|
||||
hasMultipleWords ||
|
||||
hasPunctuation ||
|
||||
isCapitalizedPhrase ||
|
||||
isTitleCase ||
|
||||
hasSentenceStructure ||
|
||||
hasQuestionForm ||
|
||||
hasInternalCapitals ||
|
||||
looksLikeInstruction ||
|
||||
looksLikeErrorOrStatus
|
||||
);
|
||||
}
|
||||
|
||||
function isInTranslationContext(path) {
|
||||
// Check if the JSX text is inside a <Trans> component
|
||||
let current = path;
|
||||
while (current.parentPath) {
|
||||
if (
|
||||
current.isJSXElement() &&
|
||||
current.node.openingElement &&
|
||||
current.node.openingElement.name &&
|
||||
current.node.openingElement.name.name === "Trans"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
current = current.parentPath;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanFileForUnlocalizedStrings(filePath) {
|
||||
// Skip suggestion content files as they contain special strings that are already properly localized
|
||||
if (filePath.includes("utils/suggestions/") || filePath.includes("mocks/task-suggestions-handlers.ts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const unlocalizedStrings = [];
|
||||
|
||||
// Skip files that are too large
|
||||
if (content.length > 1000000) {
|
||||
console.warn(`Skipping large file: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the file
|
||||
const ast = parser.parse(content, {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx", "typescript", "classProperties", "decorators-legacy"],
|
||||
});
|
||||
|
||||
// Traverse the AST
|
||||
traverse(ast, {
|
||||
// Find JSX text content
|
||||
JSXText(jsxTextPath) {
|
||||
const text = jsxTextPath.node.value.trim();
|
||||
if (
|
||||
text &&
|
||||
isLikelyUserFacingText(text) &&
|
||||
!isInTranslationContext(jsxTextPath)
|
||||
) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
},
|
||||
|
||||
// Find string literals in JSX attributes
|
||||
JSXAttribute(jsxAttrPath) {
|
||||
const attrName = jsxAttrPath.node.name.name.toString();
|
||||
|
||||
// Skip technical attributes that don't contain user-facing text
|
||||
if (NON_TEXT_ATTRIBUTES.includes(attrName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip styling attributes
|
||||
if (
|
||||
attrName === "className" ||
|
||||
attrName === "class" ||
|
||||
attrName === "style"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip data attributes and event handlers
|
||||
if (attrName.startsWith("data-") || attrName.startsWith("on")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the attribute value
|
||||
const value = jsxAttrPath.node.value;
|
||||
if (value && value.type === "StringLiteral") {
|
||||
const text = value.value.trim();
|
||||
if (text && isLikelyUserFacingText(text)) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Find string literals in code
|
||||
StringLiteral(stringPath) {
|
||||
// Skip if parent is JSX attribute (already handled above)
|
||||
if (stringPath.parent.type === "JSXAttribute") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if parent is import/export declaration
|
||||
if (
|
||||
stringPath.parent.type === "ImportDeclaration" ||
|
||||
stringPath.parent.type === "ExportDeclaration"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if parent is object property key
|
||||
if (
|
||||
stringPath.parent.type === "ObjectProperty" &&
|
||||
stringPath.parent.key === stringPath.node
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if inside a t() call or Trans component
|
||||
let isInsideTranslation = false;
|
||||
let current = stringPath;
|
||||
|
||||
while (current.parentPath && !isInsideTranslation) {
|
||||
// Check for t() function call
|
||||
if (
|
||||
current.parent.type === "CallExpression" &&
|
||||
current.parent.callee &&
|
||||
((current.parent.callee.type === "Identifier" &&
|
||||
current.parent.callee.name === "t") ||
|
||||
(current.parent.callee.type === "MemberExpression" &&
|
||||
current.parent.callee.property &&
|
||||
current.parent.callee.property.name === "t"))
|
||||
) {
|
||||
isInsideTranslation = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for <Trans> component
|
||||
if (
|
||||
current.parent.type === "JSXElement" &&
|
||||
current.parent.openingElement &&
|
||||
current.parent.openingElement.name &&
|
||||
current.parent.openingElement.name.name === "Trans"
|
||||
) {
|
||||
isInsideTranslation = true;
|
||||
break;
|
||||
}
|
||||
|
||||
current = current.parentPath;
|
||||
}
|
||||
|
||||
if (!isInsideTranslation) {
|
||||
const text = stringPath.node.value.trim();
|
||||
if (text && isLikelyUserFacingText(text)) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return unlocalizedStrings;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing file ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function scanDirectoryForUnlocalizedStrings(dirPath) {
|
||||
const results = new Map();
|
||||
|
||||
function scanDir(currentPath) {
|
||||
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name);
|
||||
|
||||
if (!shouldIgnorePath(fullPath)) {
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
SCAN_EXTENSIONS.includes(path.extname(fullPath))
|
||||
) {
|
||||
const unlocalized = scanFileForUnlocalizedStrings(fullPath);
|
||||
if (unlocalized.length > 0) {
|
||||
results.set(fullPath, unlocalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDir(dirPath);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Run the check
|
||||
try {
|
||||
const srcPath = path.resolve(__dirname, '../src');
|
||||
console.log('Checking for unlocalized strings in frontend code...');
|
||||
|
||||
// Get unlocalized strings using the AST scanner
|
||||
const results = scanDirectoryForUnlocalizedStrings(srcPath);
|
||||
|
||||
// If we found any unlocalized strings, format them for output and exit with error
|
||||
if (results.size > 0) {
|
||||
const formattedResults = Array.from(results.entries())
|
||||
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
|
||||
.join('\n');
|
||||
|
||||
console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ No unlocalized strings found in frontend code.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error running unlocalized strings check:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
interface GetPromptResponse {
|
||||
status: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export class MemoryService {
|
||||
static async getPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<string> {
|
||||
const { data } = await openHands.get<GetPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
return data.prompt;
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
GitChange,
|
||||
GetMicroagentsResponse,
|
||||
GetMicroagentPromptResponse,
|
||||
CreateMicroagent,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
|
||||
class OpenHands {
|
||||
private static currentConversation: Conversation | null = null;
|
||||
@@ -250,6 +252,28 @@ class OpenHands {
|
||||
return data.results;
|
||||
}
|
||||
|
||||
static async searchConversations(
|
||||
selectedRepository?: string,
|
||||
conversationTrigger?: string,
|
||||
limit: number = 20,
|
||||
): Promise<Conversation[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
if (selectedRepository) {
|
||||
params.append("selected_repository", selectedRepository);
|
||||
}
|
||||
|
||||
if (conversationTrigger) {
|
||||
params.append("conversation_trigger", conversationTrigger);
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
`/api/conversations?${params.toString()}`,
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
|
||||
static async deleteUserConversation(conversationId: string): Promise<void> {
|
||||
await openHands.delete(`/api/conversations/${conversationId}`);
|
||||
}
|
||||
@@ -258,19 +282,19 @@ class OpenHands {
|
||||
selectedRepository?: string,
|
||||
git_provider?: Provider,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
replayJson?: string,
|
||||
suggested_task?: SuggestedTask,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
createMicroagent?: CreateMicroagent,
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
replay_json: replayJson,
|
||||
suggested_task,
|
||||
conversation_instructions: conversationInstructions,
|
||||
create_microagent: createMicroagent,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post<Conversation>(
|
||||
@@ -466,6 +490,22 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents for a specific repository
|
||||
* @param owner The repository owner
|
||||
* @param repo The repository name
|
||||
* @returns The available microagents for the repository
|
||||
*/
|
||||
static async getRepositoryMicroagents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<RepositoryMicroagent[]> {
|
||||
const { data } = await openHands.get<RepositoryMicroagent[]>(
|
||||
`/api/user/repository/${owner}/${repo}/microagents`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
@@ -479,6 +519,18 @@ class OpenHands {
|
||||
|
||||
return data.prompt;
|
||||
}
|
||||
|
||||
static async updateConversation(
|
||||
conversationId: string,
|
||||
updates: { title: string },
|
||||
): Promise<boolean> {
|
||||
const { data } = await openHands.patch<boolean>(
|
||||
`/api/conversations/${conversationId}`,
|
||||
updates,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { RuntimeStatus } from "#/types/runtime-status";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
@@ -49,9 +50,11 @@ export interface GetConfigResponse {
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
HIDE_MICROAGENT_MANAGEMENT?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,14 +73,24 @@ export interface AuthenticateResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
|
||||
export interface RepositorySelection {
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: Provider | null;
|
||||
}
|
||||
|
||||
export type ConversationTrigger =
|
||||
| "resolver"
|
||||
| "gui"
|
||||
| "suggested_task"
|
||||
| "microagent_management";
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
title: string;
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: string | null;
|
||||
git_provider: Provider | null;
|
||||
last_updated_at: string;
|
||||
created_at: string;
|
||||
status: ConversationStatus;
|
||||
@@ -85,6 +98,7 @@ export interface Conversation {
|
||||
trigger?: ConversationTrigger;
|
||||
url: string | null;
|
||||
session_api_key: string | null;
|
||||
pr_number?: number[] | null;
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
@@ -124,3 +138,9 @@ export interface GetMicroagentPromptResponse {
|
||||
status: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface CreateMicroagent {
|
||||
repo: string;
|
||||
git_provider?: Provider;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,11 @@ export function ActionSuggestions({
|
||||
const [hasPullRequest, setHasPullRequest] = React.useState(false);
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const isGitLab = providers.includes("gitlab");
|
||||
const isBitbucket = providers.includes("bitbucket");
|
||||
|
||||
// Use the git_provider from the conversation, not the user's authenticated providers
|
||||
const currentGitProvider = conversation?.git_provider;
|
||||
const isGitLab = currentGitProvider === "gitlab";
|
||||
const isBitbucket = currentGitProvider === "bitbucket";
|
||||
|
||||
const pr = isGitLab ? "merge request" : "pull request";
|
||||
const prShort = isGitLab ? "MR" : "PR";
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ChatInput({
|
||||
disabled,
|
||||
showButton = true,
|
||||
value,
|
||||
maxRows = 16,
|
||||
maxRows = 8,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onChange,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user