Compare commits

...

81 Commits

Author SHA1 Message Date
Alona King c126b042f4 fix: add newline at end of init.md file
Fixed linting issue - added required newline at end of file.
2025-10-24 14:23:04 -04:00
Alona King 76bb89b0cf feat: add /init microagent for repository initialization
Creates a new '/init' microagent that automatically generates a comprehensive
repo.md file containing repository documentation, structure, and CI/CD setup.

Users can now simply type '/init' to create a .openhands/microagents/repo.md
file that captures essential repository context, reducing repetitive searches
and improving OpenHands' understanding of the codebase.

The slash prefix makes it clear this is a command, consistent with other
task microagents like /codereview and /fix_test.
2025-10-24 14:15:54 -04:00
Samuel Akerele e450a3a603 fix(llm): Support nested paths in litellm_proxy/ model names (#11430)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-10-24 17:41:25 +00:00
softpudding 17e32af6fe Enhance dead-loop recovery by pausing agent and reprompting (#11439)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-24 11:25:14 +00:00
Tim O'Farrell 4b303ec9b4 Fixes to unblock frontend (#11488)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-10-23 14:43:45 -06:00
Ray Myers eb954164a5 chore - update ghcr enterprise build to new org 2025-10-23 12:53:01 -05:00
Tim O'Farrell 0c1c2163b1 The AsyncRemoteWorkspace class was moved to the SDK (#11471) 2025-10-23 09:39:56 -06:00
Hiep Le dd2a62c992 refactor(frontend): disable some agent server API until implemented in the server source code (#11476) 2025-10-23 19:38:18 +04:00
Rohit Malhotra f3d9faef34 SAAS: dedup fetching user settings from keycloak id (#11480)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-23 09:56:55 -04:00
Hiep Le 134c122026 fix: disable pro subscription upgrade on LLM page for self-hosted installs (#11479) 2025-10-23 01:11:04 +07:00
Rohit Malhotra 523b40dbfc SAAS: drop deprecated table (#11469)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-22 10:52:10 -04:00
sp.wack 6a5b915088 Add unified file upload support for V0 and V1 conversations (#11457) 2025-10-22 17:44:38 +04:00
sp.wack a5c5133961 Remove queries from cache and do not refetch them after starting a conversation (#11453) 2025-10-22 13:42:09 +00:00
sp.wack eea1e7f4e1 Prevent calling V1 "start tasks” API if feature flag is disabled + always set “start tasks” query cache to stale (#11454) 2025-10-22 20:38:32 +07:00
Hiep Le e2d990f3a0 feat(backend): implement get_remote_runtime_config support for V1 conversations (#11466) 2025-10-22 15:38:25 +07:00
Hiep Le f258eafa37 feat(backend): add support for updating the title in V1 conversations (#11446) 2025-10-22 13:36:56 +07:00
Hiep Le 19634f364e fix(backend): repository pill does not display the selected repository when a conversation is initiated via slack (#11225) 2025-10-22 13:12:32 +07:00
Alona aa6446038c fix: remove accidentally committed Docker image tags from config.sh (#11470) 2025-10-22 04:48:17 +00:00
Tim O'Farrell dbddc1868e Fixes for VSCode code completion (#11449) 2025-10-21 21:39:50 +00:00
Rohit Malhotra cd967ef4bc SAAS: add local development helper scripts (#11459) 2025-10-21 21:26:23 +00:00
Tim O'Farrell e34c13ea3c Set dump mode to json to convert UUIDs to strings (#11467) 2025-10-21 19:20:56 +00:00
Hiep Le 1f35a73cc4 fix(frontend): display repository information after creating a V1 conversation (#11463) 2025-10-21 18:24:26 +00:00
Alona 267528fa82 fix: refresh provider tokens proactively and update git URLs on resume (#11296) 2025-10-22 01:19:08 +07:00
sp.wack 49f360d021 Fix toast dismissal to target specific toast IDs instead of all toasts (#11455) 2025-10-21 17:43:14 +00:00
sp.wack 9520da668c Prevent WebSocket provider remount by defaulting to V1 (#11458) 2025-10-21 17:11:15 +00:00
Rohit Malhotra 9d19292619 V1: Experiment manager (#11388)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-21 16:04:48 +00:00
sp.wack fc9a87550d Fix zero state not showing for V1 conversations (#11452) 2025-10-21 20:04:01 +04:00
sp.wack 490d3dba10 Remove toast notifications for starting/resuming conversation sandbox (#11456) 2025-10-21 20:03:45 +04:00
Rohit Malhotra 5ed1dde2e9 CLI Patch Release 1.0.2 (#11448) 2025-10-21 15:32:00 +00:00
sp.wack a68576b876 Clear conversation state when switching between V1 conversations (#11447) 2025-10-21 20:21:58 +07:00
mamoodi 722124ae83 Move Search API Key and Confirmation Mode to Advanced settings (#11390)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-21 08:51:21 -04:00
Tim O'Farrell 44578664ed Add Concurrency Limits to SandboxService (#11399)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-20 20:22:12 +00:00
Rohit Malhotra 9efe6eb776 Simplify security analyzer confirmation: replace two reject options with single 'Reject' option (#11443)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-10-20 19:45:42 +00:00
Tim O'Farrell 6d137e883f Add VSCode URL support and worker ports to sandbox services (#11426)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-20 18:43:08 +00:00
Xingyao Wang 2889f736d9 Use PyPI version of Agent-SDK (#11411) 2025-10-20 17:25:54 +00:00
sp.wack 531683abae feat(frontend): V1 conversation API (PARTIAL) (#11336)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-10-20 20:57:40 +04:00
Ryan H. Tran fab64a51b7 Add support for claude-haiku-4-5 (#11434) 2025-10-20 19:56:40 +07:00
Rohit Malhotra cc18a18874 [Hotfix, V1 CLI]: Include missing condenser prompt template in binary executable (#11428)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-19 18:18:23 +00:00
Graham Neubig 7525a95af0 Fix excessive error logging for missing org-level microagents (#11425)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-19 13:41:28 -04:00
Rohit Malhotra 640f50d525 Fix: exception handling for get convo metadata (#11421) 2025-10-17 18:12:18 +00:00
mamoodi 6f2f85073d Update PR template (#11420) 2025-10-17 13:57:42 -04:00
jpelletier1 9f3b2425ec Experimental first-time user onboarding microagent (#11413) 2025-10-17 12:35:24 -04:00
Tim O'Farrell 1ebc3ab04e Fix FastMCP authentication API breaking change (#11416)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-17 16:32:36 +00:00
Graham Neubig 9bd0566e4e fix(logging): Prevent LiteLLM logs from leaking through root logger (#11356)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-17 11:19:22 -04:00
Engel Nyst d82972e126 FE: Replace AllHands logo with OpenHands logo (#11417)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-17 11:44:56 +02:00
Boxuan Li e1b94732a8 Implement graceful shutdown for headless mode (#11401)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-16 23:09:31 -07:00
olyashok 5219f85bfa feat: make websocket client wait timeout configurable (#11405)
Co-authored-by: Alex <alex@cellect.ai>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-10-16 16:49:50 +00:00
Kevin Musgrave a237b578c0 feat(evaluation): Add multi-swe-bench dependency and fix rollout script (#11326)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-10-16 14:35:19 +00:00
mogith-pn f42a4f75cb feat: Clarifai Integration as LLM Provider (#11324) 2025-10-16 18:23:00 +04:00
Engel Nyst 3e645f8649 fix(integration-tests): accept --eval-num-workers and --eval-note in integration test runner (#11387) 2025-10-16 09:50:24 -04:00
Ryan H. Tran 5182388323 Extend context truncation cases (#11393) 2025-10-16 17:55:57 +07:00
juanmichelini 471d272c7c Mint security eval fix (#11273)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-10-16 01:42:05 +00:00
Tim O'Farrell 0522734875 Add ProcessSandboxService implementation for process-based sandboxes (#11394)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-15 17:53:50 -06:00
Tim O'Farrell f4fd8ea907 Added flag to disable the V1 endpoints inside nested V0 runtimes (#11391) 2025-10-15 15:33:52 -06:00
Engel Nyst e9413aaded Update header logo branding to OpenHands (#11383)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-15 21:28:22 +02:00
sp.wack ef004962cc hotfix(backend): Update route parameters from 'id' to 'sandbox_id' (#11389) 2025-10-15 16:40:10 +00:00
Hiep Le 58d67a2480 fix(backend): repository search is not working in the production environment (#11386) 2025-10-15 23:24:27 +07:00
Tim O'Farrell 72179f45d3 Fir for broken V1 db connection (#11382) 2025-10-15 08:07:43 -04:00
Ray Myers 15e7709ff6 chore - Add README notice of coming org rename (#11381) 2025-10-14 23:39:12 -05:00
Christopher Pereira bb563d6dd1 Fix typos (#11162) 2025-10-14 14:01:51 -04:00
Hiep Le d991b9880d fix(frontend): reo tracker should be available only in the SaaS environment, not in self-hosted instances (#11367) 2025-10-14 22:16:45 +07:00
Rohit Malhotra fe82cfd277 Hotfix(CLI VI): unable to launch via default entrypoint (#11354) 2025-10-14 10:39:49 -04:00
Cesar Garcia 16fa8ea7be Fix broken logo in README.md (#11366) 2025-10-14 14:29:27 +00:00
Tim O'Farrell f292f3a84d V1 Integration (#11183)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-10-14 02:16:44 +00:00
Rohit Malhotra 5076f21e86 CLI(V1): Patch release (#11349) 2025-10-13 22:11:59 +00:00
Rohit Malhotra 2640d43159 Fix API key disappearing bug when updating CLI settings (#11351)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-13 21:02:58 +00:00
Rohit Malhotra 609fefc1b6 Fix CLI binary GLIBC compatibility for older Linux systems (#11337)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-13 18:52:52 +00:00
Rohit Malhotra 5db0d495d4 RM CLI version on opening page (#11347) 2025-10-13 18:33:57 +00:00
Rohit Malhotra 60fa7b3d01 [Hotfix, CLI(V1)]: Prevent crashing cli when confirmation mode disabled (#11343) 2025-10-13 17:43:22 +00:00
Rohit Malhotra cca2a55166 Fix openhands CLI executable entry point in pyproject.toml (#11338)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-13 15:04:46 +00:00
Ryan H. Tran c5e58572d5 fix(cli): escape action content before passing to HTML (#11333) 2025-10-13 22:02:26 +07:00
Alona baaa41ed99 feat: Add Bitbucket Resolver templates (#10880) 2025-10-13 10:23:24 -04:00
Kevin Musgrave 19bae5ac0f feat(evaluation): Add placeholders to swe_gpt4.j2 (#11228)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-10-13 22:15:05 +08:00
rstar327 93e1cd44c6 fix: (frontend) clean up unsed error variable in try/catch block (#11325) 2025-10-13 14:13:11 +00:00
llamantino c0ce78c64a fix: remove the hardcoded 5-minute timeout from the docker pull command (#11322) 2025-10-13 10:00:10 -04:00
Bogdan Petković 399bf92ed1 Fix: Correct rename detection in apply_patch to check per-diff instead of full patch (#10913)
Signed-off-by: Bogdan Petkovic <bogdan@fatdragon.dev>
Co-authored-by: Bogdan Petkovic <bogdan@fatdragon.dev>
2025-10-13 09:47:01 -04:00
Ray Myers 2bbe15a329 chore - CI check migrations are in sync and warn (#10946) 2025-10-10 15:19:00 -05:00
mamoodi 6f22092d07 Release 0.59.0 (#11319) 2025-10-10 15:31:38 -04:00
Rohit Malhotra c034cc5dfb Refactor: move helper function to avoid circular imports (#11310)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-10 12:40:03 -04:00
Hiep Le 9bd02440b0 fix(frontend): some user interface elements are overlapping with the Create API Key modal (#11301) 2025-10-10 22:54:10 +07:00
Rohit Malhotra c9d8782566 V1(CLI): Release (#11317) 2025-10-10 15:25:19 +00:00
387 changed files with 32222 additions and 2670 deletions
+26 -7
View File
@@ -1,12 +1,31 @@
- [ ] This change is worth documenting at https://docs.all-hands.dev/
- [ ] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below
## Summary of PR
**End-user friendly description of the problem this fixes or functionality this introduces.**
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
## Change Type
---
**Summarize what the PR does, explaining any non-trivial design decisions.**
<!-- Choose the types that apply to your PR and remove the rest. -->
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Refactor
- [ ] Other (dependency update, docs, typo fixes, etc.)
---
**Link of any specific issues this addresses:**
## Checklist
- [ ] I have read and reviewed the code and I understand what the code is doing.
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
## Fixes
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
Resolves #(issue)
## Release Notes
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
end-user friendly description for your change below the checkbox. -->
- [ ] Include this change in the Release Notes.
@@ -12,17 +12,28 @@ on:
paths:
- "openhands-cli/**"
permissions:
contents: write # needed to create releases or upload assets
# Cancel previous runs if a new commit is pushed
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
build-and-test-binary:
name: Build and test binary executable
build-binary:
name: Build binary executable
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
include:
# Build on Ubuntu 22.04 for maximum GLIBC compatibility (GLIBC 2.31)
- os: ubuntu-22.04
platform: linux
artifact_name: openhands-cli-linux
# Build on macOS for macOS users
- os: macos-15
platform: macos
artifact_name: openhands-cli-macos
runs-on: ${{ matrix.os }}
steps:
@@ -60,18 +71,17 @@ jobs:
echo "✅ Build & test finished without ❌ markers"
- name: Upload binary artifact (for releases only)
if: startsWith(github.ref, 'refs/tags/')
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: openhands-cli-${{ matrix.os }}
name: ${{ matrix.artifact_name }}
path: openhands-cli/dist/openhands*
retention-days: 30
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: build-and-test-binary
needs: build-binary
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout repository
@@ -85,12 +95,12 @@ jobs:
- name: Prepare release assets
run: |
mkdir -p release-assets
# Rename binaries to include OS in filename
if [ -f artifacts/openhands-cli-ubuntu-latest/openhands ]; then
cp artifacts/openhands-cli-ubuntu-latest/openhands release-assets/openhands-linux
# Copy binaries with appropriate names for release
if [ -f artifacts/openhands-cli-linux/openhands ]; then
cp artifacts/openhands-cli-linux/openhands release-assets/openhands-linux
fi
if [ -f artifacts/openhands-cli-macos-latest/openhands ]; then
cp artifacts/openhands-cli-macos-latest/openhands release-assets/openhands-macos
if [ -f artifacts/openhands-cli-macos/openhands ]; then
cp artifacts/openhands-cli-macos/openhands release-assets/openhands-macos
fi
ls -la release-assets/
@@ -101,4 +111,4 @@ jobs:
draft: true
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.CLI_RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-107
View File
@@ -1,107 +0,0 @@
# Workflow that builds and tests the CLI binary executable
name: CLI - Build and Test Binary
# Run on pushes to main branch and CLI tags, and on pull requests when CLI files change
on:
push:
branches:
- main
tags:
- "*-cli"
pull_request:
paths:
- "openhands-cli/**"
permissions:
contents: write # needed to create releases or upload assets
# Cancel previous runs if a new commit is pushed
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
build-and-test-binary:
name: Build and test binary executable
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
- name: Install dependencies
working-directory: openhands-cli
run: |
uv sync
- name: Build binary executable
working-directory: openhands-cli
run: |
./build.sh --install-pyinstaller | tee output.log
echo "Full output:"
cat output.log
if grep -q "❌" output.log; then
echo "❌ Found failure marker in output"
exit 1
fi
echo "✅ Build & test finished without ❌ markers"
- name: Upload binary artifact (for releases only)
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v4
with:
name: openhands-cli-${{ matrix.os }}
path: openhands-cli/dist/openhands*
retention-days: 30
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: build-and-test-binary
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release assets
run: |
mkdir -p release-assets
# Rename binaries to include OS in filename
if [ -f artifacts/openhands-cli-ubuntu-latest/openhands ]; then
cp artifacts/openhands-cli-ubuntu-latest/openhands release-assets/openhands-linux
fi
if [ -f artifacts/openhands-cli-macos-latest/openhands ]; then
cp artifacts/openhands-cli-macos-latest/openhands release-assets/openhands-macos
fi
ls -la release-assets/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release-assets/*
draft: true
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,52 @@
name: Enterprise Check Migrations
on:
pull_request:
paths:
- 'enterprise/migrations/**'
jobs:
check-sync:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Fetch base branch
run: git fetch origin ${{ github.event.pull_request.base.ref }}
- name: Check if base branch is ancestor of PR
id: check_up_to_date
shell: bash
run: |
BASE="origin/${{ github.event.pull_request.base.ref }}"
HEAD="${{ github.event.pull_request.head.sha }}"
if git merge-base --is-ancestor "$BASE" "$HEAD"; then
echo "We're up to date with base $BASE"
exit 0
else
echo "NOT up to date with base $BASE"
exit 1
fi
- name: Find Comment
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: |
⚠️ This PR contains **migrations**
- name: Comment warning on PR
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
⚠️ This PR contains **migrations**. Please synchronize before merging to prevent conflicts.
+3 -3
View File
@@ -46,7 +46,7 @@ jobs:
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ghcr.io/all-hands-ai/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
{ image: "ghcr.io/openhands/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
@@ -126,7 +126,7 @@ jobs:
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
run: poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
@@ -200,7 +200,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/all-hands-ai/enterprise-server
images: ghcr.io/openhands/enterprise-server
tags: |
type=ref,event=branch
type=ref,event=pr
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
run: pip install pre-commit==4.2.0
- name: Run pre-commit hooks
working-directory: ./enterprise
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
run: pre-commit run --all-files --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
lint-cli-python:
name: Lint CLI python
+15 -8
View File
@@ -1,14 +1,17 @@
# Publishes the OpenHands PyPi package
name: Publish PyPi Package
on:
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual trigger'
description: "What are you publishing?"
required: true
default: ''
type: choice
options:
- app server
- cli
default: app server
push:
tags:
- "*"
@@ -16,8 +19,10 @@ on:
jobs:
release:
runs-on: blacksmith-4vcpu-ubuntu-2204
# Only run for tags that don't contain '-cli'
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli')
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli'
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
steps:
- uses: actions/checkout@v4
- uses: useblacksmith/setup-python@v6
@@ -38,8 +43,10 @@ jobs:
release-cli:
name: Publish CLI to PyPI
runs-on: ubuntu-latest
# Only run for tags that contain '-cli'
if: startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-cli')
# Run when manually dispatched for "cli" OR for tag pushes that contain '-cli'
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'cli')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-cli'))
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -64,4 +71,4 @@ jobs:
- name: Publish CLI to PyPI
working-directory: openhands-cli
run: |
uv publish --token ${{ secrets.PYPI_TOKEN }}
uv publish --token ${{ secrets.PYPI_TOKEN_OPENHANDS }}
+16
View File
@@ -3,4 +3,20 @@
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"python.defaultInterpreterPath": "./.venv/bin/python",
"python.terminal.activateEnvironment": true,
"python.analysis.autoImportCompletions": true,
"python.analysis.autoSearchPaths": true,
"python.analysis.extraPaths": [
"./.venv/lib/python3.12/site-packages"
],
"python.analysis.packageIndexDepths": [
{
"name": "openhands",
"depth": 10,
"includeAllSymbols": true
}
],
"python.analysis.stubPath": "./.venv/lib/python3.12/site-packages",
}
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.58-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.59-nikolaik`
## Develop inside Docker container
+10 -4
View File
@@ -1,7 +1,7 @@
<a name="readme-top"></a>
<div align="center">
<img src="./docs/static/img/logo.png" alt="Logo" width="200">
<img src="https://raw.githubusercontent.com/All-Hands-AI/docs/main/openhands/static/img/logo.png" alt="Logo" width="200">
<h1 align="center">OpenHands: Code Less, Make More</h1>
</div>
@@ -38,6 +38,12 @@ call APIs, and yes—even copy code snippets from StackOverflow.
Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for OpenHands Cloud](https://app.all-hands.dev) to get started.
> [!IMPORTANT]
> **Upcoming change**: We are renaming our GitHub Org from `All-Hands-AI` to `OpenHands` on October 20th, 2025.
> Check the [tracking issue](https://github.com/All-Hands-AI/OpenHands/issues/11376) for more information.
> [!IMPORTANT]
> Using OpenHands for work? We'd love to chat! Fill out
> [this short form](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
@@ -76,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.59-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.58
docker.all-hands.dev/all-hands-ai/openhands:0.59
```
</details>
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.58-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.59-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -6,7 +6,7 @@ that depends on the `base_image` **AND** a [Python source distribution](https://
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `containers/runtime`:
```bash
poetry run python3 openhands/runtime/utils/runtime_build.py \
poetry run python3 -m openhands.runtime.utils.runtime_build \
--base_image nikolaik/python-nodejs:python3.12-nodejs22 \
--build_folder containers/runtime
```
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -1,5 +1,5 @@
ARG OPENHANDS_VERSION=latest
ARG BASE="ghcr.io/all-hands-ai/openhands"
ARG BASE="ghcr.io/openhands/openhands"
FROM ${BASE}:${OPENHANDS_VERSION}
# Datadog labels
+274
View File
@@ -0,0 +1,274 @@
# Instructions for developing SAAS locally
You have a few options here, which are expanded on below:
- A simple local development setup, with live reloading for both OSS and this repo
- A more complex setup that includes Redis
- An even more complex setup that includes GitHub events
## Prerequisites
Before starting, make sure you have the following tools installed:
### Required for all options:
- [gcloud CLI](https://cloud.google.com/sdk/docs/install) - For authentication and secrets management
- [sops](https://github.com/mozilla/sops) - For secrets decryption
- macOS: `brew install sops`
- Linux: `sudo apt-get install sops` or download from GitHub releases
- Windows: Install via Chocolatey `choco install sops` or download from GitHub releases
### Additional requirements for enabling GitHub webhook events
- make
- Python development tools (build-essential, python3-dev)
- [ngrok](https://ngrok.com/download) - For creating tunnels to localhost
## Option 1: Simple local development
This option will allow you to modify the both the OSS code and the code in this repo,
and see the changes in real-time.
This option works best for most scenarios. The only thing it's missing is
the GitHub events webhook, which is not necessary for most development.
### 1. OpenHands location
The open source OpenHands repo should be cloned as a sibling directory,
in `../OpenHands`. This is hard-coded in the pyproject.toml (edit if necessary)
If you're doing this the first time, you may need to run
```
poetry update openhands-ai
```
### 2. Set up env
First run this to retrieve Github App secrets
```
gcloud auth application-default login
gcloud config set project global-432717
local/decrypt_env.sh
```
Now run this to generate a `.env` file, which will used to run SAAS locally
```
python -m pip install PyYAML
export LITE_LLM_API_KEY=<your LLM API key>
python enterprise_local/convert_to_env.py
```
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
```
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
```
By default the application will log in json, you can override.
```
export LOG_PLAIN_TEXT=1
```
### 3. Start the OpenHands frontend
Start the frontend like you normally would in the open source OpenHands repo.
### 4. Start the SaaS backend
```
make build
make start-backend
```
You should have a server running on `localhost:3000`, similar to the open source backend.
Oauth should work properly.
## Option 2: With Redis
Follow all the steps above, then setup redis:
```bash
docker run -p 6379:6379 --name openhands-redis -d redis
export REDIS_HOST=host.docker.internal # you may want this to be localhost
export REDIS_PORT=6379
```
## Option 3: Work with GitHub events
### 1. Setup env file
(see above)
### 2. Build OSS Openhands
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
```
docker build -f containers/app/Dockerfile -t openhands .
```
### 3. Build SAAS Openhands
Build the SAAS image locally inside Deploy repo. Note that `openhands` is the name of the image built in Step 2
```
docker build -t openhands-saas ./app/ --build-arg BASE="openhands"
```
### 4. Create a tunnel
Run in a separate terminal
```
ngrok http 3000
```
There will be a line
```
Forwarding https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app
```
Remember this URL as it will be used in Step 5 and 6
### 5. Setup Staging Github App callback/webhook urls
Using the URL found in Step 4, add another callback URL (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app/oauth/github/callback`)
### 6. Run
This is the last step! Run SAAS openhands locally using
```
docker run --env-file ./app/.env -p 3000:3000 openhands-saas
```
Note `--env-file` is what injects the `.env` file created in Step 1
Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app`)
### Local Debugging with VSCode
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of the OSS OpenHands project running.
#### Redis
A Local redis instance is required for clustered communication between server nodes. The standard docker instance will suffice.
`docker run -it -p 6379:6379 --name my-redis -d redis`
#### Postgres
A Local postgres instance is required. I used the official docker image:
`docker run -p 5432:5432 --name my-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=openhands -d postgres`
Run the alembic migrations:
`poetry run alembic upgrade head `
#### VSCode launch.json
The VSCode launch.json below sets up 2 servers to test clustering, running independently on localhost:3030 and localhost:3031. Running only the server on 3030 is usually sufficient unless tests of the clustered functionality are required. Secrets may be harvested directly from staging by connecting...
`kubectl exec --stdin --tty <POD_NAME> -n <NAMESPACE> -- /bin/bash`
And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by the time you read this, nobody will have access.)
```
{
"configurations": [
{
"name": "Python Debugger: Python File",
"type": "debugpy",
"request": "launch",
"program": "${file}"
},
{
"name": "OpenHands Deploy",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"saas_server:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
"3030"
],
"env": {
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
{
"name": "OpenHands Deploy 2",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"saas_server:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
"3031"
],
"env": {
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
{
"name": "Unit Tests",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": [
"./tests/unit",
//"./tests/unit/test_clustered_conversation_manager.py",
"--durations=0"
],
"env": {
"DEBUG": "1"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
// set working directory...
]
}
```
@@ -0,0 +1,127 @@
import base64
import os
import sys
import yaml
def convert_yaml_to_env(yaml_file, target_parameters, output_env_file, prefix):
"""Converts a YAML file into .env file format for specified target parameters under 'stringData' and 'data'.
:param yaml_file: Path to the YAML file.
:param target_parameters: List of keys to extract from the YAML file.
:param output_env_file: Path to the output .env file.
:param prefix: Prefix for environment variables.
"""
try:
# Load the YAML file
with open(yaml_file, 'r') as file:
yaml_data = yaml.safe_load(file)
# Extract sections
string_data = yaml_data.get('stringData', None)
data = yaml_data.get('data', None)
if string_data:
env_source = string_data
process_base64 = False
elif data:
env_source = data
process_base64 = True
else:
print(
"Error: Neither 'stringData' nor 'data' section found in the YAML file."
)
return
env_lines = []
for param in target_parameters:
if param in env_source:
value = env_source[param]
if process_base64:
try:
decoded_value = base64.b64decode(value).decode('utf-8')
formatted_value = (
decoded_value.replace('\n', '\\n')
if '\n' in decoded_value
else decoded_value
)
except Exception as decode_error:
print(f"Error decoding base64 for '{param}': {decode_error}")
continue
else:
formatted_value = (
value.replace('\n', '\\n')
if isinstance(value, str) and '\n' in value
else value
)
new_key = prefix + param.upper().replace('-', '_')
env_lines.append(f'{new_key}={formatted_value}')
else:
print(
f"Warning: Parameter '{param}' not found in the selected section."
)
# Write to the .env file
with open(output_env_file, 'a') as env_file:
env_file.write('\n'.join(env_lines) + '\n')
except Exception as e:
print(f'Error: {e}')
lite_llm_api_key = os.getenv('LITE_LLM_API_KEY')
if not lite_llm_api_key:
print('Set the LITE_LLM_API_KEY environment variable to your API key')
sys.exit(1)
yaml_file = 'github_decrypted.yaml'
target_parameters = ['client-id', 'client-secret', 'webhook-secret', 'private-key']
output_env_file = './enterprise/.env'
if os.path.exists(output_env_file):
os.remove(output_env_file)
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'GITHUB_APP_')
os.remove(yaml_file)
yaml_file = 'keycloak_realm_decrypted.yaml'
target_parameters = ['client-id', 'client-secret', 'provider-name', 'realm-name']
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
os.remove(yaml_file)
yaml_file = 'keycloak_admin_decrypted.yaml'
target_parameters = ['admin-password']
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
os.remove(yaml_file)
lines = []
lines.append('KEYCLOAK_SERVER_URL=https://auth.staging.all-hands.dev/')
lines.append('KEYCLOAK_SERVER_URL_EXT=https://auth.staging.all-hands.dev/')
lines.append('OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig')
lines.append(
'OPENHANDS_GITHUB_SERVICE_CLS=integrations.github.github_service.SaaSGitHubService'
)
lines.append(
'OPENHANDS_GITLAB_SERVICE_CLS=integrations.gitlab.gitlab_service.SaaSGitLabService'
)
lines.append(
'OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService'
)
lines.append(
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
)
lines.append('POSTHOG_CLIENT_KEY=test')
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev')
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514')
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
lines.append('LOCAL_DEPLOYMENT=true')
lines.append('DB_HOST=localhost')
with open(output_env_file, 'a') as env_file:
env_file.write('\n'.join(lines))
print(f'.env file created at: {output_env_file}')
@@ -0,0 +1,27 @@
#!/bin/bash
set -euo pipefail
# Check if DEPLOY_DIR argument was provided
if [ $# -lt 1 ]; then
echo "Usage: $0 <DEPLOY_DIR>"
echo "Example: $0 /path/to/deploy"
exit 1
fi
# Normalize path (remove trailing slash)
DEPLOY_DIR="${DEPLOY_DIR%/}"
# Function to decrypt and rename
decrypt_and_move() {
local secret_path="$1"
local output_name="$2"
${DEPLOY_DIR}/scripts/decrypt.sh "${DEPLOY_DIR}/${secret_path}"
mv decrypted.yaml "${output_name}"
echo "Moved decrypted.yaml to ${output_name}"
}
# Decrypt each secret file
decrypt_and_move "openhands/envs/feature/secrets/github-app.yaml" "github_decrypted.yaml"
decrypt_and_move "openhands/envs/staging/secrets/keycloak-realm.yaml" "keycloak_realm_decrypted.yaml"
decrypt_and_move "openhands/envs/staging/secrets/keycloak-admin.yaml" "keycloak_admin_decrypted.yaml"
@@ -1,18 +1,47 @@
from uuid import UUID
from experiments.constants import (
ENABLE_EXPERIMENT_MANAGER,
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
)
from experiments.experiment_versions import (
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.experiments.experiment_manager import ExperimentManager
from openhands.sdk import Agent
from openhands.server.session.conversation_init_data import ConversationInitData
class SaaSExperimentManager(ExperimentManager):
@staticmethod
def run_agent_variant_tests__v1(
user_id: str | None, conversation_id: UUID, agent: Agent
) -> Agent:
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_conversation_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return agent
agent = handle_condenser_max_step_experiment__v1(
user_id, conversation_id, agent
)
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
)
return agent
@staticmethod
def run_conversation_variant_test(
user_id, conversation_id, conversation_settings
@@ -5,12 +5,18 @@ This module contains the handler for the condenser max step experiment that test
different max_size values for the condenser configuration.
"""
from uuid import UUID
import posthog
from experiments.constants import EXPERIMENT_CONDENSER_MAX_STEP
from server.constants import IS_FEATURE_ENV
from storage.experiment_assignment_store import ExperimentAssignmentStore
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import Agent
from openhands.sdk.context.condenser import (
LLMSummarizingCondenser,
)
from openhands.server.session.conversation_init_data import ConversationInitData
@@ -190,3 +196,37 @@ def handle_condenser_max_step_experiment(
return conversation_settings
return conversation_settings
def handle_condenser_max_step_experiment__v1(
user_id: str | None,
conversation_id: UUID,
agent: Agent,
) -> Agent:
enabled_variant = _get_condenser_max_step_variant(user_id, str(conversation_id))
if enabled_variant is None:
return agent
if enabled_variant == 'control':
condenser_max_size = 120
elif enabled_variant == 'treatment':
condenser_max_size = 80
else:
logger.error(
'condenser_max_step_experiment:unknown_variant',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'variant': enabled_variant,
'reason': 'unknown variant; returning original conversation settings',
},
)
return agent
condenser_llm = agent.llm.model_copy(update={'usage_id': 'condenser'})
condenser = LLMSummarizingCondenser(
llm=condenser_llm, max_size=condenser_max_size, keep_first=4
)
return agent.model_copy(update={'condenser': condenser})
@@ -13,7 +13,7 @@ from integrations.solvability.models.report import SolvabilityReport
from integrations.solvability.models.summary import SolvabilitySummary
from integrations.utils import ENABLE_SOLVABILITY_ANALYSIS
from pydantic import ValidationError
from server.auth.token_manager import get_config
from server.config import get_config
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
+13 -13
View File
@@ -19,11 +19,12 @@ from integrations.utils import (
from jinja2 import Environment
from pydantic.dataclasses import dataclass
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager, get_config
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.database import session_maker
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.user_settings import UserSettings
from storage.saas_settings_store import SaasSettingsStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
@@ -60,20 +61,19 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
if not user_id:
return False
def _get_setting():
with session_maker() as session:
settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
if not settings or settings.enable_proactive_conversation_starters is None:
return False
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
return settings.enable_proactive_conversation_starters
if not settings or settings.enable_proactive_conversation_starters is None:
return False
return await call_sync_from_async(_get_setting)
return settings.enable_proactive_conversation_starters
# =================================================
@@ -4,7 +4,8 @@ from integrations.models import Message
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import HOST, get_oh_labels, has_exact_mention
from jinja2 import Environment
from server.auth.token_manager import TokenManager, get_config
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.database import session_maker
from storage.saas_secrets_store import SaasSecretsStore
+4 -2
View File
@@ -132,8 +132,10 @@ class JiraExistingConversationView(JiraViewInterface):
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
metadata = await conversation_store.get_metadata(self.conversation_id)
if not metadata:
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
@@ -135,8 +135,10 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
metadata = await conversation_store.get_metadata(self.conversation_id)
if not metadata:
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
@@ -132,8 +132,10 @@ class LinearExistingConversationView(LinearViewInterface):
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
metadata = await conversation_store.get_metadata(self.conversation_id)
if not metadata:
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
+16 -5
View File
@@ -14,6 +14,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
@@ -188,19 +189,27 @@ class SlackNewConversationView(SlackViewInterface):
user_secrets = await self.saas_user_auth.get_user_secrets()
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_instructions,
conversation_instructions=conversation_instructions
if conversation_instructions
else None,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = agent_loop_info.conversation_id
@@ -263,8 +272,10 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
# Check if conversation has been deleted
# Update logic when soft delete is implemented
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
metadata = await conversation_store.get_metadata(self.conversation_id)
if not metadata:
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await saas_user_auth.get_provider_tokens()
+5
View File
@@ -0,0 +1,5 @@
# Enterprise Migrations
## Migration conflicts
OpenHands PRs can fall out of sync with `main` quickly. When adding a migration, it's safest to sync the PR with main before merging to ensure you are caught up to any others that have been added.
@@ -0,0 +1,259 @@
"""Sync DB with Models
Revision ID: 076
Revises: 075
Create Date: 2025-10-05 11:28:41.772294
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartTaskStatus,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResultStatus,
)
# revision identifiers, used by Alembic.
revision: str = '076'
down_revision: Union[str, Sequence[str], None] = '075'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
'conversation_metadata',
sa.Column('max_budget_per_task', sa.Float(), nullable=True),
)
op.add_column(
'conversation_metadata',
sa.Column('cache_read_tokens', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column('cache_write_tokens', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column('reasoning_tokens', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column('context_window', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column('per_turn_token', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column(
'conversation_version', sa.String(), nullable=False, server_default='V0'
),
)
op.create_index(
op.f('ix_conversation_metadata_conversation_version'),
'conversation_metadata',
['conversation_version'],
unique=False,
)
op.add_column('conversation_metadata', sa.Column('sandbox_id', sa.String()))
op.create_index(
op.f('ix_conversation_metadata_sandbox_id'),
'conversation_metadata',
['sandbox_id'],
unique=False,
)
op.create_table(
'app_conversation_start_task',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_by_user_id', sa.String(), nullable=True),
sa.Column('status', sa.Enum(AppConversationStartTaskStatus), nullable=True),
sa.Column('detail', sa.String(), nullable=True),
sa.Column('app_conversation_id', sa.UUID(), nullable=True),
sa.Column('sandbox_id', sa.String(), nullable=True),
sa.Column('agent_server_url', sa.String(), nullable=True),
sa.Column('request', sa.JSON(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_app_conversation_start_task_created_at'),
'app_conversation_start_task',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_app_conversation_start_task_created_by_user_id'),
'app_conversation_start_task',
['created_by_user_id'],
unique=False,
)
op.create_index(
op.f('ix_app_conversation_start_task_updated_at'),
'app_conversation_start_task',
['updated_at'],
unique=False,
)
op.create_table(
'event_callback',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('conversation_id', sa.UUID(), nullable=True),
sa.Column('processor', sa.JSON(), nullable=True),
sa.Column('event_kind', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_event_callback_created_at'),
'event_callback',
['created_at'],
unique=False,
)
op.create_table(
'event_callback_result',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('status', sa.Enum(EventCallbackResultStatus), nullable=True),
sa.Column('event_callback_id', sa.UUID(), nullable=True),
sa.Column('event_id', sa.UUID(), nullable=True),
sa.Column('conversation_id', sa.UUID(), nullable=True),
sa.Column('detail', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_event_callback_result_conversation_id'),
'event_callback_result',
['conversation_id'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_created_at'),
'event_callback_result',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_event_callback_id'),
'event_callback_result',
['event_callback_id'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
op.create_table(
'v1_remote_sandbox',
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_by_user_id', sa.String(), nullable=True),
sa.Column('sandbox_spec_id', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_v1_remote_sandbox_created_at'),
'v1_remote_sandbox',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_v1_remote_sandbox_created_by_user_id'),
'v1_remote_sandbox',
['created_by_user_id'],
unique=False,
)
op.create_index(
op.f('ix_v1_remote_sandbox_sandbox_spec_id'),
'v1_remote_sandbox',
['sandbox_spec_id'],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
op.f('ix_v1_remote_sandbox_sandbox_spec_id'), table_name='v1_remote_sandbox'
)
op.drop_index(
op.f('ix_v1_remote_sandbox_created_by_user_id'), table_name='v1_remote_sandbox'
)
op.drop_index(
op.f('ix_v1_remote_sandbox_created_at'), table_name='v1_remote_sandbox'
)
op.drop_table('v1_remote_sandbox')
op.drop_index(
op.f('ix_event_callback_result_event_id'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_event_callback_id'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_created_at'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_conversation_id'),
table_name='event_callback_result',
)
op.drop_table('event_callback_result')
op.drop_index(op.f('ix_event_callback_created_at'), table_name='event_callback')
op.drop_table('event_callback')
op.drop_index(
op.f('ix_app_conversation_start_task_updated_at'),
table_name='app_conversation_start_task',
)
op.drop_index(
op.f('ix_app_conversation_start_task_created_by_user_id'),
table_name='app_conversation_start_task',
)
op.drop_index(
op.f('ix_app_conversation_start_task_created_at'),
table_name='app_conversation_start_task',
)
op.drop_table('app_conversation_start_task')
op.drop_column('conversation_metadata', 'sandbox_id')
op.drop_column('conversation_metadata', 'conversation_version')
op.drop_column('conversation_metadata', 'per_turn_token')
op.drop_column('conversation_metadata', 'context_window')
op.drop_column('conversation_metadata', 'reasoning_tokens')
op.drop_column('conversation_metadata', 'cache_write_tokens')
op.drop_column('conversation_metadata', 'cache_read_tokens')
op.drop_column('conversation_metadata', 'max_budget_per_task')
op.execute('DROP TYPE appconversationstarttaskstatus')
op.execute('DROP TYPE eventcallbackresultstatus')
# ### end Alembic commands ###
@@ -0,0 +1,27 @@
"""drop settings table
Revision ID: 077
Revises: 076
Create Date: 2025-10-21 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '077'
down_revision: Union[str, None] = '076'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Drop the deprecated settings table."""
op.execute('DROP TABLE IF EXISTS settings')
def downgrade() -> None:
"""No-op downgrade since the settings table is deprecated."""
pass
+4145 -56
View File
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -13,7 +13,8 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.token_manager import TokenManager, get_config
from server.auth.token_manager import TokenManager
from server.config import get_config
from server.logger import logger
from server.rate_limit import RateLimiter, create_redis_rate_limiter
from storage.api_key_store import ApiKeyStore
@@ -223,6 +224,16 @@ class SaasUserAuth(UserAuth):
await rate_limiter.hit('auth_uid', user_id)
return instance
@classmethod
async def get_for_user(cls, user_id: str) -> UserAuth:
offline_token = await token_manager.load_offline_token(user_id)
assert offline_token is not None
return SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
)
def get_api_key_from_header(request: Request):
auth_header = request.headers.get('Authorization')
+4 -13
View File
@@ -26,6 +26,7 @@ from server.auth.constants import (
KEYCLOAK_SERVER_URL_EXT,
)
from server.auth.keycloak_manager import get_keycloak_admin, get_keycloak_openid
from server.config import get_config
from server.logger import logger
from sqlalchemy import String as SQLString
from sqlalchemy import type_coerce
@@ -35,19 +36,8 @@ from storage.github_app_installation import GithubAppInstallation
from storage.offline_token_store import OfflineTokenStore
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
from openhands.core.config import load_openhands_config
from openhands.integrations.service_types import ProviderType
# Create a function to get config to avoid circular imports
_config = None
def get_config():
global _config
if _config is None:
_config = load_openhands_config()
return _config
def _before_sleep_callback(retry_state: RetryCallState) -> None:
logger.info(f'Retry attempt {retry_state.attempt_number} for Keycloak operation')
@@ -303,11 +293,12 @@ class TokenManager:
refresh_token_expires_at: int,
) -> dict[str, str | int] | None:
current_time = int(time.time())
# expire access_token ten minutes before actual expiration
# expire access_token four hours before actual expiration
# This ensures tokens are refreshed on resume to have at least 4 hours validity
access_expired = (
False
if access_token_expires_at == 0
else access_token_expires_at < current_time + 600
else access_token_expires_at < current_time + 14400
)
refresh_expired = (
False
+11
View File
@@ -19,10 +19,21 @@ from server.auth.constants import (
GITLAB_APP_CLIENT_ID,
)
from openhands.core.config.utils import load_openhands_config
from openhands.integrations.service_types import ProviderType
from openhands.server.config.server_config import ServerConfig
from openhands.server.types import AppMode
# Create a function to get config to avoid circular imports
_config = None
def get_config():
global _config
if _config is None:
_config = load_openhands_config()
return _config
def sign_token(payload: dict[str, object], jwt_secret: str, algorithm='HS256') -> str:
"""Signs a JWT token."""
+18 -17
View File
@@ -3,10 +3,11 @@ from datetime import UTC, datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from server.config import get_config
from server.constants import LITE_LLM_API_KEY, LITE_LLM_API_URL
from storage.api_key_store import ApiKeyStore
from storage.database import session_maker
from storage.user_settings import UserSettings
from storage.saas_settings_store import SaasSettingsStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -16,30 +17,30 @@ from openhands.utils.async_utils import call_sync_from_async
# Helper functions for BYOR API key management
async def get_byor_key_from_db(user_id: str) -> str | None:
"""Get the BYOR key from the database for a user."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
def _get_byor_key():
with session_maker() as session:
user_db_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
if user_db_settings and user_db_settings.llm_api_key_for_byor:
return user_db_settings.llm_api_key_for_byor
return None
return await call_sync_from_async(_get_byor_key)
user_db_settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if user_db_settings and user_db_settings.llm_api_key_for_byor:
return user_db_settings.llm_api_key_for_byor
return None
async def store_byor_key_in_db(user_id: str, key: str) -> None:
"""Store the BYOR key in the database for a user."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
def _update_user_settings():
with session_maker() as session:
user_db_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
user_db_settings = settings_store.get_user_settings_by_keycloak_id(
user_id, session
)
if user_db_settings:
user_db_settings.llm_api_key_for_byor = key
+11 -12
View File
@@ -16,10 +16,11 @@ from server.auth.constants import (
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.config import sign_token
from server.config import get_config, sign_token
from server.constants import IS_FEATURE_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
from openhands.core.logger import openhands_logger as logger
@@ -212,16 +213,14 @@ async def keycloak_callback(
f'&state={state}'
)
has_accepted_tos = False
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
has_accepted_tos = (
user_settings is not None and user_settings.accepted_tos is not None
)
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
user_settings = settings_store.get_user_settings_by_keycloak_id(user_id)
has_accepted_tos = (
user_settings is not None and user_settings.accepted_tos is not None
)
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:
@@ -424,7 +423,7 @@ async def refresh_tokens(
provider_handler = ProviderHandler(
create_provider_tokens_object([provider]), external_auth_id=user_id
)
service = provider_handler._get_service(provider)
service = provider_handler.get_service(provider)
token = await service.get_latest_token()
if not token:
raise HTTPException(
+48 -5
View File
@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from integrations import stripe_service
from pydantic import BaseModel
from server.config import get_config
from server.constants import (
LITE_LLM_API_KEY,
LITE_LLM_API_URL,
@@ -22,8 +23,8 @@ from server.constants import (
from server.logger import logger
from storage.billing_session import BillingSession
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.subscription_access import SubscriptionAccess
from storage.user_settings import UserSettings
from openhands.server.user_auth import get_user_id
@@ -31,6 +32,37 @@ stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing')
# TODO: Add a new app_mode named "ON_PREM" to support self-hosted customers instead of doing this
# and members should comment out the "validate_saas_environment" function if they are developing and testing locally.
def is_all_hands_saas_environment(request: Request) -> bool:
"""Check if the current domain is an All Hands SaaS environment.
Args:
request: FastAPI Request object
Returns:
True if the current domain contains "all-hands.dev" or "openhands.dev" postfix
"""
hostname = request.url.hostname or ''
return hostname.endswith('all-hands.dev') or hostname.endswith('openhands.dev')
def validate_saas_environment(request: Request) -> None:
"""Validate that the request is coming from an All Hands SaaS environment.
Args:
request: FastAPI Request object
Raises:
HTTPException: If the request is not from an All Hands SaaS environment
"""
if not is_all_hands_saas_environment(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Checkout sessions are only available for All Hands SaaS environments',
)
class BillingSessionType(Enum):
DIRECT_PAYMENT = 'DIRECT_PAYMENT'
MONTHLY_SUBSCRIPTION = 'MONTHLY_SUBSCRIPTION'
@@ -196,6 +228,8 @@ async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONRespon
async def create_customer_setup_session(
request: Request, user_id: str = Depends(get_user_id)
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
customer_id = await stripe_service.find_or_create_customer(user_id)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
@@ -214,6 +248,8 @@ async def create_checkout_session(
request: Request,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
customer_id = await stripe_service.find_or_create_customer(user_id)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
@@ -268,6 +304,8 @@ async def create_subscription_checkout_session(
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
# Prevent duplicate subscriptions for the same user
with session_maker() as session:
now = datetime.now(UTC)
@@ -343,6 +381,8 @@ async def create_subscription_checkout_session_via_get(
user_id: str = Depends(get_user_id),
) -> RedirectResponse:
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)."""
validate_saas_environment(request)
response = await create_subscription_checkout_session(
request, billing_session_type, user_id
)
@@ -578,11 +618,14 @@ async def stripe_webhook(request: Request) -> JSONResponse:
def reset_user_to_free_tier_settings(user_id: str) -> None:
"""Reset user settings to free tier defaults when subscription ends."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
user_settings = settings_store.get_user_settings_by_keycloak_id(
user_id, session
)
if user_settings:
@@ -784,6 +784,7 @@ class SaasNestedConversationManager(ConversationManager):
env_vars['SKIP_DEPENDENCY_CHECK'] = '1'
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
env_vars['ENABLE_V1'] = '0'
# We need this for LLM traces tracking to identify the source of the LLM calls
env_vars['WEB_HOST'] = WEB_HOST
@@ -195,14 +195,11 @@ def update_active_working_seconds(
file_store: The FileStore instance for accessing conversation data
"""
try:
# Get all events for the conversation
events = list(event_store.get_events())
# Track agent state changes and calculate running time
running_start_time = None
total_running_seconds = 0.0
for event in events:
for event in event_store.search_events():
if isinstance(event, AgentStateChangedObservation) and event.timestamp:
event_timestamp = datetime.fromisoformat(event.timestamp).timestamp()
+2 -2
View File
@@ -2,6 +2,6 @@
Unified SQLAlchemy declarative base for all models.
"""
from sqlalchemy.orm import declarative_base
from openhands.app_server.utils.sql_utils import Base
Base = declarative_base()
__all__ = ['Base']
+4 -1
View File
@@ -1,7 +1,6 @@
import asyncio
import os
from google.cloud.sql.connector import Connector
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
@@ -26,6 +25,8 @@ def _get_db_engine():
if GCP_DB_INSTANCE: # GCP environments
def get_db_connection():
from google.cloud.sql.connector import Connector
connector = Connector()
instance_string = f'{GCP_PROJECT}:{GCP_REGION}:{GCP_DB_INSTANCE}'
return connector.connect(
@@ -52,6 +53,8 @@ def _get_db_engine():
async def async_creator():
from google.cloud.sql.connector import Connector
loop = asyncio.get_running_loop()
async with Connector(loop=loop) as connector:
conn = await connector.connect_async(
@@ -52,6 +52,14 @@ class SaasConversationStore(ConversationStore):
# Convert string to ProviderType enum
kwargs['git_provider'] = ProviderType(kwargs['git_provider'])
# Remove V1 attributes
kwargs.pop('max_budget_per_task', None)
kwargs.pop('cache_read_tokens', None)
kwargs.pop('cache_write_tokens', None)
kwargs.pop('reasoning_tokens', None)
kwargs.pop('context_window', None)
kwargs.pop('per_turn_token', None)
return ConversationMetadata(**kwargs)
async def save_metadata(self, metadata: ConversationMetadata):
+39 -42
View File
@@ -24,7 +24,6 @@ from server.constants import (
from server.logger import logger
from sqlalchemy.orm import sessionmaker
from storage.database import session_maker
from storage.stored_settings import StoredSettings
from storage.user_settings import UserSettings
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -40,15 +39,46 @@ class SaasSettingsStore(SettingsStore):
session_maker: sessionmaker
config: OpenHandsConfig
def get_user_settings_by_keycloak_id(
self, keycloak_user_id: str, session=None
) -> UserSettings | None:
"""
Get UserSettings by keycloak_user_id.
Args:
keycloak_user_id: The keycloak user ID to search for
session: Optional existing database session. If not provided, creates a new one.
Returns:
UserSettings object if found, None otherwise
"""
if not keycloak_user_id:
return None
def _get_settings():
if session:
# Use provided session
return (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == keycloak_user_id)
.first()
)
else:
# Create new session
with self.session_maker() as new_session:
return (
new_session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == keycloak_user_id)
.first()
)
return _get_settings()
async def load(self) -> Settings | None:
if not self.user_id:
return None
with self.session_maker() as session:
settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == self.user_id)
.first()
)
settings = self.get_user_settings_by_keycloak_id(self.user_id, session)
if not settings or settings.user_version != CURRENT_USER_SETTINGS_VERSION:
logger.info(
@@ -72,12 +102,8 @@ class SaasSettingsStore(SettingsStore):
if item:
kwargs = item.model_dump(context={'expose_secrets': True})
self._encrypt_kwargs(kwargs)
query = session.query(UserSettings).filter(
UserSettings.keycloak_user_id == self.user_id
)
# First check if we have an existing entry in the new table
existing = query.first()
existing = self.get_user_settings_by_keycloak_id(self.user_id, session)
kwargs = {
key: value
@@ -144,33 +170,6 @@ class SaasSettingsStore(SettingsStore):
await self.store(settings)
return settings
def load_legacy_db_settings(self, github_user_id: str) -> Settings | None:
if not github_user_id:
return None
with self.session_maker() as session:
settings = (
session.query(StoredSettings)
.filter(StoredSettings.id == github_user_id)
.first()
)
if settings is None:
return None
logger.info(
'saas_settings_store:load_legacy_db_settings:found',
extra={'github_user_id': github_user_id},
)
kwargs = {
c.name: getattr(settings, c.name)
for c in StoredSettings.__table__.columns
if c.name in Settings.model_fields
}
self._decrypt_kwargs(kwargs)
del kwargs['secrets_store']
settings = Settings(**kwargs)
return settings
async def load_legacy_file_store_settings(self, github_user_id: str):
if not github_user_id:
return None
@@ -235,10 +234,8 @@ class SaasSettingsStore(SettingsStore):
spend = user_info.get('spend') or 0
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == self.user_id)
.first()
user_settings = self.get_user_settings_by_keycloak_id(
self.user_id, session
)
# In upgrade to V4, we no longer use billing margin, but instead apply this directly
# in litellm. The default billing marign was 2 before this (hence the magic numbers below)
@@ -1,41 +1,8 @@
import uuid
from datetime import UTC, datetime
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
StoredConversationMetadata as _StoredConversationMetadata,
)
from sqlalchemy import JSON, Column, DateTime, Float, Integer, String
from storage.base import Base
StoredConversationMetadata = _StoredConversationMetadata
class StoredConversationMetadata(Base): # type: ignore
__tablename__ = 'conversation_metadata'
conversation_id = Column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
github_user_id = Column(String, nullable=True) # The GitHub user ID
user_id = Column(String, nullable=False) # The Keycloak User ID
selected_repository = Column(String, nullable=True)
selected_branch = Column(String, nullable=True)
git_provider = Column(
String, nullable=True
) # The git provider (GitHub, GitLab, etc.)
title = Column(String, nullable=True)
last_updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
)
trigger = Column(String, nullable=True)
pr_number = Column(
JSON, nullable=True
) # List of PR numbers associated with the conversation
# Cost and token metrics
accumulated_cost = Column(Float, default=0.0)
prompt_tokens = Column(Integer, default=0)
completion_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0)
# LLM model used for the conversation
llm_model = Column(String, nullable=True)
__all__ = ['StoredConversationMetadata']
-29
View File
@@ -1,29 +0,0 @@
import uuid
from sqlalchemy import JSON, Boolean, Column, Float, Integer, String
from storage.base import Base
class StoredSettings(Base): # type: ignore
"""
Legacy user settings storage. This should be considered deprecated - use UserSettings isntead
"""
__tablename__ = 'settings'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
language = Column(String, nullable=True)
agent = Column(String, nullable=True)
max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
llm_model = Column(String, nullable=True)
llm_api_key = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
user_consents_to_analytics = Column(Boolean, nullable=True)
margin = Column(Float, nullable=True)
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
sandbox_base_container_image = Column(String, nullable=True)
sandbox_runtime_container_image = Column(String, nullable=True)
secrets_store = Column(JSON, nullable=True)
+1 -2
View File
@@ -17,7 +17,6 @@ from storage.github_app_installation import GithubAppInstallation
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_offline_token import StoredOfflineToken
from storage.stored_settings import StoredSettings
from storage.stripe_customer import StripeCustomer
from storage.user_settings import UserSettings
@@ -85,7 +84,7 @@ def add_minimal_fixtures(session_maker):
updated_at=datetime.fromisoformat('2025-03-08'),
)
)
session.add(StoredSettings(id='mock-user-id', user_consents_to_analytics=True))
session.add(
StripeCustomer(
keycloak_user_id='mock-user-id',
@@ -0,0 +1 @@
"""Unit tests for experiments module."""
@@ -0,0 +1,137 @@
# tests/test_condenser_max_step_experiment_v1.py
from unittest.mock import patch
from uuid import uuid4
from experiments.experiment_manager import SaaSExperimentManager
# SUT imports (update the module path if needed)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from pydantic import SecretStr
from openhands.sdk import LLM, Agent
from openhands.sdk.context.condenser import LLMSummarizingCondenser
def make_agent() -> Agent:
"""Build a minimal valid Agent."""
llm = LLM(
usage_id='primary-llm',
model='provider/model',
api_key=SecretStr('sk-test'),
)
return Agent(llm=llm)
def _patch_variant(monkeypatch, return_value):
"""Patch the internal variant getter to return a specific value."""
monkeypatch.setattr(
'experiments.experiment_versions._004_condenser_max_step_experiment._get_condenser_max_step_variant',
lambda user_id, conv_id: return_value,
raising=True,
)
def test_control_variant_sets_condenser_with_max_size_120(monkeypatch):
_patch_variant(monkeypatch, 'control')
agent = make_agent()
conv_id = uuid4()
result = handle_condenser_max_step_experiment__v1('user-1', conv_id, agent)
# Should be a new Agent instance with a condenser installed
assert result is not agent
assert isinstance(result.condenser, LLMSummarizingCondenser)
# The condenser should have its own LLM (usage_id overridden to "condenser")
assert result.condenser.llm.usage_id == 'condenser'
# The original agent LLM remains unchanged
assert agent.llm.usage_id == 'primary-llm'
# Control: max_size = 120, keep_first = 4
assert result.condenser.max_size == 120
assert result.condenser.keep_first == 4
def test_treatment_variant_sets_condenser_with_max_size_80(monkeypatch):
_patch_variant(monkeypatch, 'treatment')
agent = make_agent()
conv_id = uuid4()
result = handle_condenser_max_step_experiment__v1('user-2', conv_id, agent)
assert result is not agent
assert isinstance(result.condenser, LLMSummarizingCondenser)
assert result.condenser.llm.usage_id == 'condenser'
assert result.condenser.max_size == 80
assert result.condenser.keep_first == 4
def test_none_variant_returns_original_agent_without_changes(monkeypatch):
_patch_variant(monkeypatch, None)
agent = make_agent()
conv_id = uuid4()
result = handle_condenser_max_step_experiment__v1('user-3', conv_id, agent)
# No changes—same instance and no condenser attribute added
assert result is agent
assert getattr(result, 'condenser', None) is None
def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
_patch_variant(monkeypatch, 'weird-variant')
agent = make_agent()
conv_id = uuid4()
result = handle_condenser_max_step_experiment__v1('user-4', conv_id, agent)
assert result is agent
assert getattr(result, 'condenser', None) is None
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
mock_handle_condenser,
):
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
agent = make_agent()
conv_id = uuid4()
result = SaaSExperimentManager.run_agent_variant_tests__v1(
user_id='user-123',
conversation_id=conv_id,
agent=agent,
)
# Same object returned (no copy)
assert result is agent
# Handler should not have been called
mock_handle_condenser.assert_not_called()
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
@patch('experiments.experiment_manager.EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT', True)
def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeypatch):
"""When enabled, it should call the condenser experiment handler and set the long-horizon system prompt."""
agent = make_agent()
conv_id = uuid4()
_patch_variant(monkeypatch, 'treatment')
result: Agent = SaaSExperimentManager.run_agent_variant_tests__v1(
user_id='user-abc',
conversation_id=conv_id,
agent=agent,
)
# Should be a different instance than the original (copied after handler runs)
assert result is not agent
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
# The condenser returned by the handler must be preserved after the system-prompt override copy
assert isinstance(result.condenser, LLMSummarizingCondenser)
assert result.condenser.max_size == 80
@@ -137,7 +137,9 @@ class TestJiraExistingConversationView:
):
"""Test conversation update with no metadata"""
mock_store = AsyncMock()
mock_store.get_metadata.return_value = None
mock_store.get_metadata.side_effect = FileNotFoundError(
'No such file or directory'
)
mock_store_impl.return_value = mock_store
with pytest.raises(
@@ -137,7 +137,9 @@ class TestJiraDcExistingConversationView:
):
"""Test conversation update with no metadata"""
mock_store = AsyncMock()
mock_store.get_metadata.return_value = None
mock_store.get_metadata.side_effect = FileNotFoundError(
'No such file or directory'
)
mock_store_impl.return_value = mock_store
with pytest.raises(
@@ -137,7 +137,9 @@ class TestLinearExistingConversationView:
):
"""Test conversation update with no metadata"""
mock_store = AsyncMock()
mock_store.get_metadata.return_value = None
mock_store.get_metadata.side_effect = FileNotFoundError(
'No such file or directory'
)
mock_store_impl.return_value = mock_store
with pytest.raises(
@@ -80,7 +80,7 @@ class TestUpdateActiveWorkingSeconds:
events.append(event6)
# Configure the mock event store to return our test events
mock_event_store.get_events.return_value = events
mock_event_store.search_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -133,7 +133,7 @@ class TestUpdateActiveWorkingSeconds:
events = [event1, event2]
mock_event_store.get_events.return_value = events
mock_event_store.search_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -178,7 +178,7 @@ class TestUpdateActiveWorkingSeconds:
events = [event1, event2, event3]
# No final state change - agent still running
mock_event_store.get_events.return_value = events
mock_event_store.search_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -221,7 +221,7 @@ class TestUpdateActiveWorkingSeconds:
events = [event1, event2, event3]
mock_event_store.get_events.return_value = events
mock_event_store.search_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -267,7 +267,7 @@ class TestUpdateActiveWorkingSeconds:
events = [event1, event2, event3, event4]
mock_event_store.get_events.return_value = events
mock_event_store.search_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -297,7 +297,7 @@ class TestUpdateActiveWorkingSeconds:
user_id = 'test_user_error'
# Configure the mock to raise an exception
mock_event_store.get_events.side_effect = Exception('Test error')
mock_event_store.search_events.side_effect = Exception('Test error')
# Call the function under test
update_active_working_seconds(
@@ -376,7 +376,7 @@ class TestUpdateActiveWorkingSeconds:
event10.timestamp = '1970-01-01T00:00:37.000000'
events.append(event10)
mock_event_store.get_events.return_value = events
mock_event_store.search_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
+63 -25
View File
@@ -36,6 +36,46 @@ def session_maker(engine):
return sessionmaker(bind=engine)
@pytest.fixture
def mock_request():
"""Create a mock request object with proper URL structure for testing."""
return Request(
scope={
'type': 'http',
'path': '/api/billing/test',
'server': ('test.com', 80),
}
)
@pytest.fixture
def mock_checkout_request():
"""Create a mock request object for checkout session tests."""
request = Request(
scope={
'type': 'http',
'path': '/api/billing/create-checkout-session',
'server': ('test.com', 80),
}
)
request._base_url = URL('http://test.com/')
return request
@pytest.fixture
def mock_subscription_request():
"""Create a mock request object for subscription checkout session tests."""
request = Request(
scope={
'type': 'http',
'path': '/api/billing/subscription-checkout-session',
'server': ('test.com', 80),
}
)
request._base_url = URL('http://test.com/')
return request
@pytest.mark.asyncio
async def test_get_credits_lite_llm_error():
mock_request = Request(scope={'type': 'http', 'state': {'user_id': 'mock_user'}})
@@ -90,14 +130,10 @@ async def test_get_credits_success():
@pytest.mark.asyncio
async def test_create_checkout_session_stripe_error(session_maker):
async def test_create_checkout_session_stripe_error(
session_maker, mock_checkout_request
):
"""Test handling of Stripe API errors."""
mock_request = Request(
scope={
'type': 'http',
}
)
mock_request._base_url = URL('http://test.com/')
mock_customer = stripe.Customer(
id='mock-customer', metadata={'user_id': 'mock-user'}
@@ -118,17 +154,16 @@ async def test_create_checkout_session_stripe_error(session_maker):
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
AsyncMock(return_value={'email': 'testy@tester.com'}),
),
patch('server.routes.billing.validate_saas_environment'),
):
await create_checkout_session(
CreateCheckoutSessionRequest(amount=25), mock_request, 'mock_user'
CreateCheckoutSessionRequest(amount=25), mock_checkout_request, 'mock_user'
)
@pytest.mark.asyncio
async def test_create_checkout_session_success(session_maker):
async def test_create_checkout_session_success(session_maker, mock_checkout_request):
"""Test successful creation of checkout session."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_session = MagicMock()
mock_session.url = 'https://checkout.stripe.com/test-session'
@@ -152,12 +187,13 @@ async def test_create_checkout_session_success(session_maker):
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
AsyncMock(return_value={'email': 'testy@tester.com'}),
),
patch('server.routes.billing.validate_saas_environment'),
):
mock_db_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_db_session
result = await create_checkout_session(
CreateCheckoutSessionRequest(amount=25), mock_request, 'mock_user'
CreateCheckoutSessionRequest(amount=25), mock_checkout_request, 'mock_user'
)
assert isinstance(result, CreateBillingSessionResponse)
@@ -590,7 +626,9 @@ async def test_cancel_subscription_stripe_error():
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_duplicate_prevention():
async def test_create_subscription_checkout_session_duplicate_prevention(
mock_subscription_request,
):
"""Test that creating a subscription when user already has active subscription raises error."""
from datetime import UTC, datetime
@@ -609,11 +647,9 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
cancelled_at=None,
)
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
patch('server.routes.billing.validate_saas_environment'),
):
# Setup mock session to return existing active subscription
mock_session = MagicMock()
@@ -623,7 +659,7 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
# Call the function and expect HTTPException
with pytest.raises(HTTPException) as exc_info:
await create_subscription_checkout_session(
mock_request, user_id='test_user'
mock_subscription_request, user_id='test_user'
)
assert exc_info.value.status_code == 400
@@ -634,10 +670,10 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_allows_after_cancellation():
async def test_create_subscription_checkout_session_allows_after_cancellation(
mock_subscription_request,
):
"""Test that creating a subscription is allowed when previous subscription was cancelled."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_session_obj = MagicMock()
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
@@ -657,6 +693,7 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
),
patch('server.routes.billing.validate_saas_environment'),
):
# Setup mock session - the query should return None because cancelled subscriptions are filtered out
mock_session = MagicMock()
@@ -665,7 +702,7 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
# Should succeed
result = await create_subscription_checkout_session(
mock_request, user_id='test_user'
mock_subscription_request, user_id='test_user'
)
assert isinstance(result, CreateBillingSessionResponse)
@@ -673,10 +710,10 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_success_no_existing():
async def test_create_subscription_checkout_session_success_no_existing(
mock_subscription_request,
):
"""Test successful subscription creation when no existing subscription."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_session_obj = MagicMock()
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
@@ -696,6 +733,7 @@ async def test_create_subscription_checkout_session_success_no_existing():
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
),
patch('server.routes.billing.validate_saas_environment'),
):
# Setup mock session to return no existing subscription
mock_session = MagicMock()
@@ -704,7 +742,7 @@ async def test_create_subscription_checkout_session_success_no_existing():
# Should succeed
result = await create_subscription_checkout_session(
mock_request, user_id='test_user'
mock_subscription_request, user_id='test_user'
)
assert isinstance(result, CreateBillingSessionResponse)
@@ -20,7 +20,7 @@ def token_store(session_maker, mock_config):
@pytest.fixture
def token_manager():
with patch('server.auth.token_manager.get_config') as mock_get_config:
with patch('server.config.get_config') as mock_get_config:
mock_config = mock_get_config.return_value
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
return TokenManager(external=False)
@@ -8,8 +8,8 @@ pytestmark = pytest.mark.asyncio
# Mock the call_sync_from_async function to return the result of the function directly
def mock_call_sync_from_async(func):
return func()
def mock_call_sync_from_async(func, *args, **kwargs):
return func(*args, **kwargs)
@pytest.fixture
@@ -8,7 +8,6 @@ from server.constants import (
LITE_LLM_TEAM_ID,
)
from storage.saas_settings_store import SaasSettingsStore
from storage.stored_settings import StoredSettings
from storage.user_settings import UserSettings
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -303,26 +302,6 @@ async def test_create_default_settings_require_payment_disabled(
assert settings.language == 'en'
@pytest.mark.asyncio
async def test_create_default_settings_with_existing_llm_key(
settings_store, mock_stripe, mock_github_user, mock_litellm_api, session_maker
):
# Test that existing llm_api_key is preserved and not overwritten with litellm default
with (
patch('storage.saas_settings_store.REQUIRE_PAYMENT', False),
patch('storage.saas_settings_store.LITE_LLM_API_KEY', 'mock-api-key'),
patch('storage.saas_settings_store.session_maker', session_maker),
):
with settings_store.session_maker() as session:
kwargs = {'id': '12345', 'language': 'en', 'llm_api_key': 'existing_key'}
settings_store._encrypt_kwargs(kwargs)
session.merge(StoredSettings(**kwargs))
session.commit()
updated_settings = await settings_store.create_default_settings(None)
assert updated_settings is not None
assert updated_settings.llm_api_key.get_secret_value() == 'test_api_key'
@pytest.mark.asyncio
async def test_create_default_lite_llm_settings_no_api_config(settings_store):
with (
@@ -13,7 +13,6 @@ from integrations.stripe_service import (
)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from storage.stored_settings import Base as StoredBase
from storage.stripe_customer import Base as StripeCustomerBase
from storage.stripe_customer import StripeCustomer
from storage.user_settings import Base as UserBase
@@ -22,7 +21,7 @@ from storage.user_settings import Base as UserBase
@pytest.fixture
def engine():
engine = create_engine('sqlite:///:memory:')
StoredBase.metadata.create_all(engine)
UserBase.metadata.create_all(engine)
StripeCustomerBase.metadata.create_all(engine)
return engine
@@ -8,7 +8,7 @@ from openhands.integrations.service_types import ProviderType
@pytest.fixture
def token_manager():
with patch('server.auth.token_manager.get_config') as mock_get_config:
with patch('server.config.get_config') as mock_get_config:
mock_config = mock_get_config.return_value
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
return TokenManager(external=False)
@@ -307,7 +307,7 @@ class TheoremqaTask(Task):
# Converting the string answer to a number/list/bool/option
try:
prediction = eval(prediction)
prediction = ast.literal_eval(prediction)
except Exception:
LOGGER.warning(
f'[TASK] Failed to convert the answer: {prediction}\n{traceback.format_exc()}'
@@ -111,15 +111,10 @@ for run_idx in $(seq 1 $N_RUNS); do
echo "### Evaluating on $OUTPUT_FILE ... ###"
OUTPUT_CONFIG_FILE="${OUTPUT_FILE%.jsonl}_config.json"
export EVAL_SKIP_BUILD_ERRORS=true
pip install multi-swe-bench --quiet --disable-pip-version-check > /dev/null 2>&1
COMMAND="poetry run python ./evaluation/benchmarks/multi_swe_bench/scripts/eval/update_multi_swe_bench_config.py --input $OUTPUT_FILE --output $OUTPUT_CONFIG_FILE --dataset $EVAL_DATASET;
python -m multi_swe_bench.harness.run_evaluation --config $OUTPUT_CONFIG_FILE
poetry run python -m multi_swe_bench.harness.run_evaluation --config $OUTPUT_CONFIG_FILE
"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
echo "Running command: $COMMAND"
# Run the command
eval $COMMAND
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
# 2. Prepare DATA
echo "==== Prepare SWE-bench data ===="
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
if [ -d $EVAL_WORKSPACE/eval_data ]; then
@@ -16,6 +16,10 @@ At the end, you must test your code rigorously using the tools provided, and do
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
## Issue Description
{{ instance.problem_statement }}
# Workflow
## High-Level Problem Solving Strategy
@@ -73,6 +77,7 @@ Carefully read the issue and think hard about a plan to solve it before coding.
## 8. Final Reflection and Additional Testing
- Reflect carefully on the original intent of the user and the problem statement.
- Compare your changes with the base commit {{ instance.base_commit }} to ensure minimal and focused modifications.
- Think about potential edge cases or scenarios that may not be covered by existing tests.
- Write additional tests that would need to pass to fully validate the correctness of your solution.
- Run these new tests and ensure they all pass.
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
# 2. Prepare DATA
echo "==== Prepare SWE-bench data ===="
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
if [ -d $EVAL_WORKSPACE/eval_data ]; then
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
# 2. Prepare DATA
echo "==== Prepare SWE-bench data ===="
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
if [ -d $EVAL_WORKSPACE/eval_data ]; then
@@ -162,7 +162,7 @@ while IFS= read -r task_image; do
# Prune unused images and volumes
docker image rm "$task_image"
docker images "ghcr.io/all-hands-ai/runtime" -q | xargs -r docker rmi -f
docker images "ghcr.io/openhands/runtime" -q | xargs -r docker rmi -f
docker volume prune -f
docker system prune -f
done < "$temp_file"
+3 -2
View File
@@ -24,8 +24,8 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
OpenHandsConfig,
get_evaluation_parser,
get_llm_config_arg,
parse_arguments,
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
@@ -166,7 +166,8 @@ def load_integration_tests() -> pd.DataFrame:
if __name__ == '__main__':
args = parse_arguments()
parser = get_evaluation_parser()
args, _ = parser.parse_known_args()
integration_tests = load_integration_tests()
llm_config = None
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import {
FILE_VARIANTS_1,
FILE_VARIANTS_2,
} from "#/mocks/file-service-handlers";
/**
* File service API tests. The actual API calls are mocked using MSW.
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
*/
describe("ConversationService File API", () => {
it("should get a list of files", async () => {
await expect(
ConversationService.getFiles("test-conversation-id"),
).resolves.toEqual(FILE_VARIANTS_1);
await expect(
ConversationService.getFiles("test-conversation-id-2"),
).resolves.toEqual(FILE_VARIANTS_2);
});
it("should get content of a file", async () => {
await expect(
ConversationService.getFile("test-conversation-id", "file1.txt"),
).resolves.toEqual("Content of file1.txt");
});
});
@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { buildWebSocketUrl } from "#/utils/websocket-url";
describe("buildWebSocketUrl", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Basic URL construction", () => {
it("should build WebSocket URL with conversation ID and URL", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
});
it("should use wss:// protocol when window.location.protocol is https:", () => {
vi.stubGlobal("location", {
protocol: "https:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-123",
"https://example.com:8080/api/conversations/conv-123",
);
expect(result).toBe("wss://example.com:8080/sockets/events/conv-123");
});
it("should extract host and port from conversation URL", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-456",
"http://agent-server.com:9000/api/conversations/conv-456",
);
expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456");
});
});
describe("Query parameters handling", () => {
beforeEach(() => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
});
it("should not include query parameters in the URL (handled by useWebSocket hook)", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
expect(result).not.toContain("?");
expect(result).not.toContain("session_api_key");
});
});
describe("Fallback to window.location.host", () => {
it("should use window.location.host when conversation URL is null", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", null);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is undefined", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", undefined);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is relative path", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl(
"conv-123",
"/api/conversations/conv-123",
);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is invalid", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", "not-a-valid-url");
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
});
describe("Edge cases", () => {
beforeEach(() => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
});
it("should return null when conversationId is undefined", () => {
const result = buildWebSocketUrl(
undefined,
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBeNull();
});
it("should return null when conversationId is empty string", () => {
const result = buildWebSocketUrl(
"",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBeNull();
});
it("should handle conversation URLs with non-standard ports", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://example.com:12345/api/conversations/conv-123",
);
expect(result).toBe("ws://example.com:12345/sockets/events/conv-123");
});
it("should handle conversation URLs without port (default port)", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://example.com/api/conversations/conv-123",
);
expect(result).toBe("ws://example.com/sockets/events/conv-123");
});
it("should handle conversation IDs with special characters", () => {
const result = buildWebSocketUrl(
"conv-123-abc_def",
"http://localhost:8080/api/conversations/conv-123-abc_def",
);
expect(result).toBe(
"ws://localhost:8080/sockets/events/conv-123-abc_def",
);
});
it("should build URL without query parameters", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
expect(result).not.toContain("?");
});
});
});
@@ -21,7 +21,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useConfig } from "#/hooks/query/use-config";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { OpenHandsAction } from "#/types/core/actions";
import { useEventStore } from "#/stores/use-event-store";
@@ -31,7 +31,7 @@ vi.mock("#/stores/error-message-store");
vi.mock("#/stores/optimistic-user-message-store");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-upload-files");
vi.mock("#/hooks/mutation/use-unified-upload-files");
// Mock React Router hooks at the top level
vi.mock("react-router", async () => {
@@ -128,7 +128,7 @@ describe("ChatInterface - Chat Suggestions", () => {
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
@@ -267,7 +267,7 @@ describe("ChatInterface - Empty state", () => {
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
@@ -8,6 +8,14 @@ import { ConversationPanel } from "#/components/features/conversation-panel/conv
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
// Mock the unified stop conversation hook
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedPauseConversationSandbox: () => ({
mutate: mockStopConversationMutate,
}),
}));
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
const RouterStub = createRoutesStub([
@@ -73,7 +81,7 @@ describe("ConversationPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
mockStopConversationMutate.mockClear();
// Setup default mock for getUserConversations
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
results: [...mockConversations],
@@ -430,19 +438,6 @@ describe("ConversationPanel", () => {
next_page_id: null,
}));
const stopConversationSpy = vi.spyOn(
ConversationService,
"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");
@@ -465,9 +460,12 @@ describe("ConversationPanel", () => {
screen.queryByRole("button", { name: /confirm/i }),
).not.toBeInTheDocument();
// Verify the API was called
expect(stopConversationSpy).toHaveBeenCalledWith("1");
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
// Verify the mutation was called
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "1",
version: undefined,
});
expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
});
it("should only show stop button for STARTING or RUNNING conversations", async () => {
@@ -6,25 +6,25 @@ import { ServerStatus } from "#/components/features/controls/server-status";
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the custom hooks
const mockStartConversationMutate = vi.fn();
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
useStartConversation: () => ({
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
useUnifiedStartConversation: () => ({
mutate: mockStartConversationMutate,
}),
}));
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
useStopConversation: () => ({
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedStopConversation: () => ({
mutate: mockStopConversationMutate,
}),
}));
@@ -41,6 +41,19 @@ vi.mock("#/hooks/use-user-providers", () => ({
}),
}));
vi.mock("#/hooks/query/use-task-polling", () => ({
useTaskPolling: () => ({
isTask: false,
taskId: null,
conversationId: "test-conversation-id",
task: null,
taskStatus: null,
taskDetail: null,
taskError: null,
isLoadingTask: false,
}),
}));
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@@ -66,12 +79,14 @@ vi.mock("react-i18next", async () => {
});
describe("ServerStatus", () => {
// Helper function to mock agent store with specific state
// Mock functions for handlers
const mockHandleStop = vi.fn();
const mockHandleResumeAgent = vi.fn();
// Helper function to mock agent state with specific state
const mockAgentStore = (agentState: AgentState) => {
vi.mocked(useAgentStore).mockReturnValue({
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
};
@@ -85,20 +100,42 @@ describe("ServerStatus", () => {
// Test RUNNING status
const { rerender } = renderWithProviders(
<ServerStatus conversationStatus="RUNNING" />,
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test STOPPED status
rerender(<ServerStatus conversationStatus="STOPPED" />);
rerender(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
// Test STARTING status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus="STARTING" />);
rerender(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test null status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus={null} />);
rerender(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
});
@@ -108,7 +145,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
@@ -128,7 +171,13 @@ describe("ServerStatus", () => {
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
expect(statusContainer).toBeInTheDocument();
@@ -148,7 +197,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
renderWithProviders(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
@@ -165,12 +220,18 @@ describe("ServerStatus", () => {
const user = userEvent.setup();
// Clear previous calls
mockStopConversationMutate.mockClear();
mockHandleStop.mockClear();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
@@ -178,21 +239,25 @@ describe("ServerStatus", () => {
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
});
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should call start conversation mutation when start server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockStartConversationMutate.mockClear();
mockHandleResumeAgent.mockClear();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
@@ -200,10 +265,7 @@ describe("ServerStatus", () => {
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(mockStartConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
providers: [],
});
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
});
it("should close context menu after stop server action", async () => {
@@ -212,7 +274,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
@@ -221,9 +289,7 @@ describe("ServerStatus", () => {
await user.click(stopButton);
// Context menu should be closed (handled by the component)
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
});
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should close context menu after start server action", async () => {
@@ -232,7 +298,13 @@ describe("ServerStatus", () => {
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
@@ -250,7 +322,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus={null} />);
renderWithProviders(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusText = screen.getByText("Running");
expect(statusText).toBeInTheDocument();
@@ -5,12 +5,12 @@ import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useConversationStore } from "#/state/conversation-store";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the conversation store
@@ -57,14 +57,11 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
const onStopMock = vi.fn();
// Helper function to mock stores
const mockStores = (agentState: AgentState = AgentState.INIT) => {
vi.mocked(useAgentStore).mockReturnValue({
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
vi.mocked(useConversationStore).mockReturnValue({
@@ -103,14 +100,13 @@ describe("InteractiveChatBox", () => {
};
// Helper function to render with Router context
const renderInteractiveChatBox = (props: any, options: any = {}) => {
return renderWithProviders(
const renderInteractiveChatBox = (props: any, options: any = {}) =>
renderWithProviders(
<MemoryRouter>
<InteractiveChatBox {...props} />
</MemoryRouter>,
options,
);
};
beforeAll(() => {
global.URL.createObjectURL = vi
@@ -127,7 +123,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const chatBox = screen.getByTestId("interactive-chat-box");
@@ -140,7 +135,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const textbox = screen.getByTestId("chat-input");
@@ -157,7 +151,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
// Create a larger file to ensure it passes validation
@@ -184,7 +177,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
@@ -209,7 +201,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const textarea = screen.getByTestId("chat-input");
@@ -240,7 +231,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const button = screen.getByTestId("submit-button");
@@ -250,33 +240,14 @@ describe("InteractiveChatBox", () => {
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should display the stop button when agent is running and call onStop when clicked", async () => {
const user = userEvent.setup();
mockStores(AgentState.RUNNING);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
// The stop button should be available when agent is running
const stopButton = screen.getByTestId("stop-button");
expect(stopButton).toBeInTheDocument();
await user.click(stopButton);
expect(onStopMock).toHaveBeenCalledOnce();
});
it("should handle image upload and message submission correctly", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onStop = vi.fn();
mockStores(AgentState.AWAITING_USER_INPUT);
const { rerender } = renderInteractiveChatBox({
onSubmit: onSubmit,
onStop: onStop,
onSubmit,
});
// Verify text input has the initial value
@@ -296,7 +267,7 @@ describe("InteractiveChatBox", () => {
// Simulate parent component updating the value prop
rerender(
<MemoryRouter>
<InteractiveChatBox onSubmit={onSubmit} onStop={onStop} />
<InteractiveChatBox onSubmit={onSubmit} />
</MemoryRouter>,
);
@@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useJupyterStore } from "#/state/jupyter-store";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock react-i18next
@@ -30,11 +30,9 @@ describe("JupyterEditor", () => {
});
it("should have a scrollable container", () => {
// Mock agent store to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
vi.mocked(useAgentStore).mockReturnValue({
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.RUNNING,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
render(
@@ -5,11 +5,11 @@ import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the conversation ID hook
@@ -50,11 +50,9 @@ describe("MicroagentsModal - Refresh Button", () => {
microagents: mockMicroagents,
});
// Mock the agent store to return a ready state
vi.mocked(useAgentStore).mockReturnValue({
// Mock the agent state to return a ready state
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.AWAITING_USER_INPUT,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import { screen, waitFor, render } from "@testing-library/react";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import {
@@ -19,16 +19,34 @@ import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
// MSW WebSocket mock setup
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
beforeAll(() => mswServer.listen());
beforeAll(() => {
// The global MSW server from vitest.setup.ts is already running
// We just need to start our WebSocket-specific server
mswServer.listen({ onUnhandledRequest: "bypass" });
});
afterEach(() => {
mswServer.resetHandlers();
// Clean up any React components
cleanup();
});
afterAll(async () => {
// Close the WebSocket MSW server
mswServer.close();
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
});
afterAll(() => mswServer.close());
// Helper function to render components with ConversationWebSocketProvider
function renderWithWebSocketContext(
children: React.ReactNode,
conversationId = "test-conversation-default",
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-default",
sessionApiKey: string | null = null,
) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -39,7 +57,11 @@ function renderWithWebSocketContext(
return render(
<QueryClientProvider client={queryClient}>
<ConversationWebSocketProvider conversationId={conversationId}>
<ConversationWebSocketProvider
conversationId={conversationId}
conversationUrl={conversationUrl}
sessionApiKey={sessionApiKey}
>
{children}
</ConversationWebSocketProvider>
</QueryClientProvider>,
@@ -394,4 +416,98 @@ describe("Conversation WebSocket Handler", () => {
it.todo("should send user actions through WebSocket when connected");
it.todo("should handle send attempts when disconnected");
});
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
describe("Terminal I/O Integration", () => {
it("should append command to store when ExecuteBashAction event is received", async () => {
const { createMockExecuteBashActionEvent } = await import(
"#/mocks/mock-ws-helpers"
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
useCommandStore.getState().clearTerminal();
// Create a mock ExecuteBashAction event
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBashActionEvent));
}),
);
// Render with WebSocket context (we don't need a component, just need the provider to be active)
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the command to be added to the store
await waitFor(() => {
const { commands } = useCommandStore.getState();
expect(commands.length).toBe(1);
});
// Verify the command was added with correct type and content
const { commands } = useCommandStore.getState();
expect(commands[0].type).toBe("input");
expect(commands[0].content).toBe("npm test");
});
it("should append output to store when ExecuteBashObservation event is received", async () => {
const { createMockExecuteBashObservationEvent } = await import(
"#/mocks/mock-ws-helpers"
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
useCommandStore.getState().clearTerminal();
// Create a mock ExecuteBashObservation event
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
"PASS tests/example.test.js\n ✓ should work (2 ms)",
"npm test",
);
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBashObservationEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the output to be added to the store
await waitFor(() => {
const { commands } = useCommandStore.getState();
expect(commands.length).toBe(1);
});
// Verify the output was added with correct type and content
const { commands } = useCommandStore.getState();
expect(commands[0].type).toBe("output");
expect(commands[0].content).toBe(
"PASS tests/example.test.js\n ✓ should work (2 ms)",
);
});
});
});
@@ -37,6 +37,9 @@ export const createWebSocketTestSetup = (
/**
* Standard WebSocket test setup for conversation WebSocket handler tests
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
*/
export const conversationWebSocketTestSetup = () =>
createWebSocketTestSetup("ws://localhost/events/socket");
createWebSocketTestSetup(
"ws://localhost:3000/sockets/events/test-conversation-default",
);
@@ -10,11 +10,13 @@ import { OpenHandsEvent } from "#/types/v1/core";
* Test component to access and display WebSocket connection state
*/
export function ConnectionStatusComponent() {
const { connectionState } = useConversationWebSocket();
const context = useConversationWebSocket();
return (
<div>
<div data-testid="connection-state">{connectionState}</div>
<div data-testid="connection-state">
{context?.connectionState || "NOT_AVAILABLE"}
</div>
</div>
);
}
@@ -13,6 +13,22 @@ vi.mock("#/context/ws-client-provider", () => ({
}),
}));
// Mock useActiveConversation
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: {
id: "test-conversation-id",
conversation_version: "V0",
},
isFetched: true,
}),
}));
// Mock useConversationWebSocket (returns null for V0 conversations)
vi.mock("#/contexts/conversation-websocket-context", () => ({
useConversationWebSocket: () => null,
}));
function TestTerminalComponent() {
const ref = useTerminal();
return <div ref={ref} />;
@@ -12,7 +12,7 @@ import { ws } from "msw";
import { setupServer } from "msw/node";
import { useWebSocket } from "#/hooks/use-websocket";
describe.skip("useWebSocket", () => {
describe("useWebSocket", () => {
// MSW WebSocket mock setup
const wsLink = ws.link("ws://acme.com/ws");
+35 -20
View File
@@ -25,6 +25,12 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => mockUseIsAuthed(),
}));
// Mock useIsAllHandsSaaSEnvironment hook
const mockUseIsAllHandsSaaSEnvironment = vi.fn();
vi.mock("#/hooks/use-is-all-hands-saas-environment", () => ({
useIsAllHandsSaaSEnvironment: () => mockUseIsAllHandsSaaSEnvironment(),
}));
const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
@@ -48,6 +54,9 @@ beforeEach(() => {
// Default mock for useIsAuthed - returns authenticated by default
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
// Default mock for useIsAllHandsSaaSEnvironment - returns true for SaaS environment
mockUseIsAllHandsSaaSEnvironment.mockReturnValue(true);
});
describe("Content", () => {
@@ -104,11 +113,17 @@ describe("Content", () => {
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
});
});
});
describe("Advanced form", () => {
it("should conditionally show security analyzer based on confirmation mode", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Enable advanced mode first
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
@@ -135,9 +150,7 @@ describe("Content", () => {
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
});
});
describe("Advanced form", () => {
it("should render the advanced form if the switch is toggled", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
@@ -615,7 +628,7 @@ describe("Form submission", () => {
expect.objectContaining({
llm_model: "openhands/claude-sonnet-4-20250514",
llm_base_url: "",
confirmation_mode: true, // Confirmation mode is now a basic setting, should be preserved
confirmation_mode: false, // Confirmation mode is now an advanced setting, should be cleared when saving basic settings
}),
);
});
@@ -776,9 +789,6 @@ describe("SaaS mode", () => {
const modelInput = screen.getByTestId("llm-model-input");
const apiKeyInput = screen.getByTestId("llm-api-key-input");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const confirmationModeSwitch = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const submitButton = screen.getByTestId("submit-button");
// Inputs should be disabled
@@ -786,9 +796,13 @@ describe("SaaS mode", () => {
expect(modelInput).toBeDisabled();
expect(apiKeyInput).toBeDisabled();
expect(advancedSwitch).toBeDisabled();
expect(confirmationModeSwitch).toBeDisabled();
expect(submitButton).toBeDisabled();
// Confirmation mode switch is in advanced view, so it's not visible in basic view
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
// Try to interact with inputs - they should not respond
await userEvent.click(providerInput);
await userEvent.type(apiKeyInput, "test-key");
@@ -935,19 +949,17 @@ describe("SaaS mode", () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Verify that form elements are disabled for unsubscribed users
const confirmationModeSwitch = screen.getByTestId(
"enable-confirmation-mode-switch",
);
// Verify that basic form elements are disabled for unsubscribed users
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const submitButton = screen.getByTestId("submit-button");
expect(confirmationModeSwitch).not.toBeChecked();
expect(confirmationModeSwitch).toBeDisabled();
expect(advancedSwitch).toBeDisabled();
expect(submitButton).toBeDisabled();
// Try to click the disabled confirmation mode switch - it should not change state
await userEvent.click(confirmationModeSwitch);
expect(confirmationModeSwitch).not.toBeChecked(); // Should remain unchecked
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
// Try to submit the form - button should remain disabled
await userEvent.click(submitButton);
@@ -1107,14 +1119,17 @@ describe("SaaS mode", () => {
const providerInput = screen.getByTestId("llm-provider-input");
const modelInput = screen.getByTestId("llm-model-input");
const apiKeyInput = screen.getByTestId("llm-api-key-input");
const confirmationModeSwitch = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(providerInput).toBeDisabled();
expect(modelInput).toBeDisabled();
expect(apiKeyInput).toBeDisabled();
expect(confirmationModeSwitch).toBeDisabled();
expect(advancedSwitch).toBeDisabled();
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
});
});
});
@@ -60,7 +60,7 @@ describe("Check for hardcoded English strings", () => {
test("InteractiveChatBox should not have hardcoded English strings", () => {
const { container } = renderWithProviders(
<MemoryRouter>
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />
<InteractiveChatBox onSubmit={() => {}} />
</MemoryRouter>,
);
@@ -24,4 +24,5 @@ test("mapProvider", () => {
expect(mapProvider("replicate")).toBe("Replicate");
expect(mapProvider("voyage")).toBe("Voyage AI");
expect(mapProvider("openrouter")).toBe("OpenRouter");
expect(mapProvider("clarifai")).toBe("Clarifai");
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.58.0",
"version": "0.59.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.58.0",
"version": "0.59.0",
"dependencies": {
"@heroui/react": "^2.8.4",
"@heroui/use-infinite-scroll": "^2.2.11",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.58.0",
"version": "0.59.0",
"private": true,
"type": "module",
"engines": {
@@ -11,7 +11,6 @@ import {
CreateMicroagent,
FileUploadSuccessResponse,
GetFilesResponse,
GetFileResponse,
} from "../open-hands.types";
import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
@@ -121,7 +120,7 @@ class ConversationService {
reason?: string;
}>(url);
return data;
} catch (error) {
} catch {
// Error checking if feedback exists
return { exists: false };
}
@@ -159,19 +158,6 @@ class ConversationService {
return data;
}
/**
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
*/
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
const url = `${this.getConversationUrl(conversationId)}/zip-directory`;
const response = await openHands.get(url, {
responseType: "blob",
headers: this.getConversationHeaders(),
});
return response.data;
}
/**
* Get the web hosts
* @returns Array of web hosts
@@ -201,7 +187,7 @@ class ConversationService {
static async getRuntimeId(
conversationId: string,
): Promise<{ runtime_id: string }> {
const url = `${this.getConversationUrl(conversationId)}/config`;
const url = `/api/conversations/${conversationId}/config`;
const { data } = await openHands.get<{ runtime_id: string }>(url, {
headers: this.getConversationHeaders(),
});
@@ -379,22 +365,6 @@ class ConversationService {
return data;
}
/**
* Retrieve the content of a file
* @param conversationId ID of the conversation
* @param path Full path of the file to retrieve
* @returns Code content of the file
*/
static async getFile(conversationId: string, path: string): Promise<string> {
const url = `${this.getConversationUrl(conversationId)}/select-file`;
const { data } = await openHands.get<GetFileResponse>(url, {
params: { file: path },
headers: this.getConversationHeaders(),
});
return data.code;
}
/**
* Upload multiple files to the workspace
* @param conversationId ID of the conversation
@@ -0,0 +1,296 @@
import axios from "axios";
import { openHands } from "../open-hands-axios";
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
import { Provider } from "#/types/settings";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import type {
V1SendMessageRequest,
V1SendMessageResponse,
V1AppConversationStartRequest,
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
} from "./v1-conversation-service.types";
class V1ConversationService {
/**
* Build headers for V1 API requests that require session authentication
* @param sessionApiKey Session API key for authentication
* @returns Headers object with X-Session-API-Key if provided
*/
private static buildSessionHeaders(
sessionApiKey?: string | null,
): Record<string, string> {
const headers: Record<string, string> = {};
if (sessionApiKey) {
headers["X-Session-API-Key"] = sessionApiKey;
}
return headers;
}
/**
* Build the full URL for V1 runtime-specific endpoints
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param path The API path (e.g., "/api/vscode/url")
* @returns Full URL to the runtime endpoint
*/
private static buildRuntimeUrl(
conversationUrl: string | null | undefined,
path: string,
): string {
const baseUrl = buildHttpBaseUrl(conversationUrl);
return `${baseUrl}${path}`;
}
/**
* Send a message to a V1 conversation
* @param conversationId The conversation ID
* @param message The message to send
* @returns The sent message response
*/
static async sendMessage(
conversationId: string,
message: V1SendMessageRequest,
): Promise<V1SendMessageResponse> {
const { data } = await openHands.post<V1SendMessageResponse>(
`/api/conversations/${conversationId}/events`,
message,
);
return data;
}
/**
* Create a new V1 conversation using the app-conversations API
* Returns the start task immediately with app_conversation_id as null.
* You must poll getStartTask() until status is READY to get the conversation ID.
*
* @returns AppConversationStartTask with task ID
*/
static async createConversation(
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
selected_branch?: string,
conversationInstructions?: string,
trigger?: ConversationTrigger,
): Promise<V1AppConversationStartTask> {
const body: V1AppConversationStartRequest = {
selected_repository: selectedRepository,
git_provider,
selected_branch,
title: conversationInstructions,
trigger,
};
// Add initial message if provided
if (initialUserMsg) {
body.initial_message = {
role: "user",
content: [
{
type: "text",
text: initialUserMsg,
},
],
};
}
const { data } = await openHands.post<V1AppConversationStartTask>(
"/api/v1/app-conversations",
body,
);
return data;
}
/**
* Get a start task by ID
* Poll this endpoint until status is READY to get the app_conversation_id
*
* @param taskId The task UUID
* @returns AppConversationStartTask or null
*/
static async getStartTask(
taskId: string,
): Promise<V1AppConversationStartTask | null> {
const { data } = await openHands.get<(V1AppConversationStartTask | null)[]>(
`/api/v1/app-conversations/start-tasks?ids=${taskId}`,
);
return data[0] || null;
}
/**
* Search for start tasks (ongoing tasks that haven't completed yet)
* Use this to find tasks that were started but the user navigated away
*
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
* filter the results client-side after fetching.
*
* @param limit Maximum number of tasks to return (max 100)
* @returns Array of start tasks
*/
static async searchStartTasks(
limit: number = 100,
): Promise<V1AppConversationStartTask[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
);
return data.items;
}
/**
* Get the VSCode URL for a V1 conversation
* Uses the custom runtime URL from the conversation
* Note: V1 endpoint doesn't require conversationId in the URL path - it's identified via session API key header
*
* @param _conversationId The conversation ID (not used in V1, kept for interface compatibility)
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @returns VSCode URL response
*/
static async getVSCodeUrl(
_conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<GetVSCodeUrlResponse> {
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
const headers = this.buildSessionHeaders(sessionApiKey);
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
// Map it to match the expected interface
const { data } = await axios.get<{ url: string | null }>(url, { headers });
return {
vscode_url: data.url,
};
}
/**
* Pause a V1 conversation
* Uses the custom runtime URL from the conversation
*
* @param conversationId The conversation ID
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @returns Success response
*/
static async pauseConversation(
conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<{ success: boolean }> {
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/conversations/${conversationId}/pause`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
const { data } = await axios.post<{ success: boolean }>(
url,
{},
{ headers },
);
return data;
}
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*
* @param sandboxId The sandbox ID to pause
* @returns Success response
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*
* @param sandboxId The sandbox ID to resume
* @returns Success response
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 app conversations by their IDs
* Returns null for any missing conversations
*
* @param ids Array of conversation IDs (max 100)
* @returns Array of conversations or null for missing ones
*/
static async batchGetAppConversations(
ids: string[],
): Promise<(V1AppConversation | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 conversations at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("ids", id));
const { data } = await openHands.get<(V1AppConversation | null)[]>(
`/api/v1/app-conversations?${params.toString()}`,
);
return data;
}
/**
* Upload a single file to the V1 conversation workspace
* V1 API endpoint: POST /api/file/upload/{path}
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @param file The file to upload
* @param path The absolute path where the file should be uploaded (defaults to /workspace/{file.name})
* @returns void on success, throws on error
*/
static async uploadFile(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
file: File,
path?: string,
): Promise<void> {
// Default to /workspace/{filename} if no path provided (must be absolute)
const uploadPath = path || `/workspace/${file.name}`;
const encodedPath = encodeURIComponent(uploadPath);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/file/upload/${encodedPath}`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
// Create FormData with the file
const formData = new FormData();
formData.append("file", file);
// Upload file
await axios.post(url, formData, {
headers: {
...headers,
"Content-Type": "multipart/form-data",
},
});
}
}
export default V1ConversationService;
@@ -0,0 +1,100 @@
import { ConversationTrigger } from "../open-hands.types";
import { Provider } from "#/types/settings";
// V1 API Types for requests
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
export interface V1MessageContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
type V1Role = "user" | "system" | "assistant" | "tool";
export interface V1SendMessageRequest {
role: V1Role;
content: V1MessageContent[];
}
export interface V1AppConversationStartRequest {
sandbox_id?: string | null;
initial_message?: V1SendMessageRequest | null;
processors?: unknown[]; // EventCallbackProcessor - keeping as unknown for now
llm_model?: string | null;
selected_repository?: string | null;
selected_branch?: string | null;
git_provider?: Provider | null;
title?: string | null;
trigger?: ConversationTrigger | null;
pr_number?: number[];
}
export type V1AppConversationStartTaskStatus =
| "WORKING"
| "WAITING_FOR_SANDBOX"
| "PREPARING_REPOSITORY"
| "RUNNING_SETUP_SCRIPT"
| "SETTING_UP_GIT_HOOKS"
| "STARTING_CONVERSATION"
| "READY"
| "ERROR";
export interface V1AppConversationStartTask {
id: string;
created_by_user_id: string | null;
status: V1AppConversationStartTaskStatus;
detail: string | null;
app_conversation_id: string | null;
sandbox_id: string | null;
agent_server_url: string | null;
request: V1AppConversationStartRequest;
created_at: string;
updated_at: string;
}
export interface V1SendMessageResponse {
role: "user" | "system" | "assistant" | "tool";
content: V1MessageContent[];
}
export interface V1AppConversationStartTaskPage {
items: V1AppConversationStartTask[];
next_page_id: string | null;
}
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
export type V1AgentExecutionStatus =
| "RUNNING"
| "AWAITING_USER_INPUT"
| "AWAITING_USER_CONFIRMATION"
| "FINISHED"
| "PAUSED"
| "STOPPED";
export interface V1AppConversation {
id: string;
created_by_user_id: string | null;
sandbox_id: string;
selected_repository: string | null;
selected_branch: string | null;
git_provider: Provider | null;
title: string | null;
trigger: ConversationTrigger | null;
pr_number: number[];
llm_model: string | null;
metrics: unknown | null;
created_at: string;
updated_at: string;
sandbox_status: V1SandboxStatus;
agent_status: V1AgentExecutionStatus | null;
conversation_url: string | null;
session_api_key: string | null;
}
+1
View File
@@ -76,6 +76,7 @@ export interface Conversation {
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
conversation_version?: "V0" | "V1";
}
export interface ResultSet<T> {

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -8,16 +8,16 @@ import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { AgentState } from "#/types/agent-state";
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { Messages as V0Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ScrollProvider } from "#/context/scroll-context";
import { useInitialQueryStore } from "#/stores/initial-query-store";
import { useAgentStore } from "#/stores/agent-store";
import { useSendMessage } from "#/hooks/use-send-message";
import { useAgentState } from "#/hooks/use-agent-state";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -30,12 +30,23 @@ import {
hasUserEvent,
shouldRenderEvent,
} from "./event-content-helpers/should-render-event";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import {
Messages as V1Messages,
hasUserEvent as hasV1UserEvent,
shouldRenderEvent as shouldRenderV1Event,
} from "#/components/v1/chat";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/state/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
import { isV0Event } from "#/types/v1/type-guards";
import {
isV0Event,
isV1Event,
isSystemPromptEvent,
isConversationStateUpdateEvent,
} from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
function getEntryPoint(
hasRepository: boolean | null,
@@ -48,8 +59,10 @@ function getEntryPoint(
export function ChatInterface() {
const { setMessageToSend } = useConversationStore();
const { data: conversation } = useActiveConversation();
const { errorMessage } = useErrorMessageStore();
const { send, isLoadingMessages } = useWsClient();
const { isLoadingMessages } = useWsClient();
const { send } = useSendMessage();
const storeEvents = useEventStore((state) => state.events);
const { setOptimisticUserMessage, getOptimisticUserMessage } =
useOptimisticUserMessageStore();
@@ -65,7 +78,7 @@ export function ChatInterface() {
} = useScrollToBottom(scrollRef);
const { data: config } = useConfig();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
@@ -73,15 +86,24 @@ export function ChatInterface() {
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const { selectedRepository, replayJson } = useInitialQueryStore();
const params = useParams();
const { mutateAsync: uploadFiles } = useUploadFiles();
const { mutateAsync: uploadFiles } = useUnifiedUploadFiles();
const optimisticUserMessage = getOptimisticUserMessage();
const events = storeEvents
const isV1Conversation = conversation?.conversation_version === "V1";
// Filter V0 events
const v0Events = storeEvents
.filter(isV0Event)
.filter(isActionOrObservation)
.filter(shouldRenderEvent);
// Filter V1 events
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
// Combined events count for tracking
const totalEvents = v0Events.length || v1Events.length;
// Check if there are any substantive agent actions (not just system messages)
const hasSubstantiveAgentActions = React.useMemo(
() =>
@@ -93,6 +115,14 @@ export function ChatInterface() {
isOpenHandsAction(event) &&
event.source === "agent" &&
event.action !== "system",
) ||
storeEvents
.filter(isV1Event)
.some(
(event) =>
event.source === "agent" &&
!isSystemPromptEvent(event) &&
!isConversationStateUpdateEvent(event),
),
[storeEvents],
);
@@ -105,7 +135,7 @@ export function ChatInterface() {
// Create mutable copies of the arrays
const images = [...originalImages];
const files = [...originalFiles];
if (events.length === 0) {
if (totalEvents === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(
selectedRepository !== null,
@@ -116,7 +146,7 @@ export function ChatInterface() {
});
} else {
posthog.capture("user_message_sent", {
session_message_count: events.length,
session_message_count: totalEvents,
current_message_length: content.length,
});
}
@@ -151,11 +181,6 @@ export function ChatInterface() {
setMessageToSend("");
};
const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
@@ -174,7 +199,9 @@ export function ChatInterface() {
onChatBodyScroll,
};
const userEventsExist = hasUserEvent(events);
const v0UserEventsExist = hasUserEvent(v0Events);
const v1UserEventsExist = hasV1UserEvent(v1Events);
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
return (
<ScrollProvider value={scrollProviderValue}>
@@ -193,15 +220,24 @@ export function ChatInterface() {
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
>
{isLoadingMessages && (
{isLoadingMessages && !isV1Conversation && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{!isLoadingMessages && userEventsExist && (
<Messages
messages={events}
{!isLoadingMessages && v0UserEventsExist && (
<V0Messages
messages={v0Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
)}
{v1UserEventsExist && (
<V1Messages
messages={v1Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
@@ -213,7 +249,7 @@ export function ChatInterface() {
<div className="flex justify-between relative">
<div className="flex items-center gap-1">
<ConfirmationModeEnabled />
{events.length > 0 && (
{totalEvents > 0 && !isV1Conversation && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
@@ -235,13 +271,10 @@ export function ChatInterface() {
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
<InteractiveChatBox
onSubmit={handleSendMessage}
onStop={handleStop}
/>
<InteractiveChatBox onSubmit={handleSendMessage} />
</div>
{config?.APP_MODE !== "saas" && (
{config?.APP_MODE !== "saas" && !isV1Conversation && (
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
@@ -2,33 +2,73 @@ import { ConversationStatus } from "#/types/conversation-status";
import { ServerStatus } from "#/components/features/controls/server-status";
import { AgentStatus } from "#/components/features/controls/agent-status";
import { Tools } from "../../controls/tools";
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useSendMessage } from "#/hooks/use-send-message";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { AgentState } from "#/types/agent-state";
interface ChatInputActionsProps {
conversationStatus: ConversationStatus | null;
disabled: boolean;
handleStop: (onStop?: () => void) => void;
handleResumeAgent: () => void;
onStop?: () => void;
}
export function ChatInputActions({
conversationStatus,
disabled,
handleStop,
handleResumeAgent,
onStop,
}: ChatInputActionsProps) {
const { data: conversation } = useActiveConversation();
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
const resumeConversationSandboxMutation =
useUnifiedResumeConversationSandbox();
const { conversationId } = useConversationId();
const { providers } = useUserProviders();
const { send } = useSendMessage();
const isV1Conversation = conversation?.conversation_version === "V1";
const handleStopClick = () => {
pauseConversationSandboxMutation.mutate({ conversationId });
};
const handlePauseAgent = () => {
if (isV1Conversation) {
// V1: Empty function for now
return;
}
// V0: Send agent state change event to stop the agent
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const handleStartClick = () => {
resumeConversationSandboxMutation.mutate({ conversationId, providers });
};
const isPausing = pauseConversationSandboxMutation.isPending;
return (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<ServerStatus conversationStatus={conversationStatus} />
<ServerStatus
conversationStatus={conversationStatus}
isPausing={isPausing}
handleStop={handleStopClick}
handleResumeAgent={handleStartClick}
/>
</div>
<AgentStatus
className="ml-2 md:ml-3"
handleStop={() => handleStop(onStop)}
handleStop={handlePauseAgent}
handleResumeAgent={handleResumeAgent}
disabled={disabled}
isPausing={isPausing}
/>
</div>
);
@@ -15,7 +15,6 @@ interface ChatInputContainerProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
handleFileIconClick: (isDisabled: boolean) => void;
handleSubmit: () => void;
handleStop: (onStop?: () => void) => void;
handleResumeAgent: () => void;
onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
@@ -25,7 +24,6 @@ interface ChatInputContainerProps {
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
onStop?: () => void;
}
export function ChatInputContainer({
@@ -38,7 +36,6 @@ export function ChatInputContainer({
chatInputRef,
handleFileIconClick,
handleSubmit,
handleStop,
handleResumeAgent,
onDragOver,
onDragLeave,
@@ -48,7 +45,6 @@ export function ChatInputContainer({
onKeyDown,
onFocus,
onBlur,
onStop,
}: ChatInputContainerProps) {
return (
<div
@@ -80,9 +76,7 @@ export function ChatInputContainer({
<ChatInputActions
conversationStatus={conversationStatus}
disabled={disabled}
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
onStop={onStop}
/>
</div>
);
@@ -15,7 +15,6 @@ export interface CustomChatInputProps {
showButton?: boolean;
conversationStatus?: ConversationStatus | null;
onSubmit: (message: string) => void;
onStop?: () => void;
onFocus?: () => void;
onBlur?: () => void;
onFilesPaste?: (files: File[]) => void;
@@ -28,7 +27,6 @@ export function CustomChatInput({
showButton = true,
conversationStatus = null,
onSubmit,
onStop,
onFocus,
onBlur,
onFilesPaste,
@@ -88,7 +86,7 @@ export function CustomChatInput({
messageToSend,
);
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
const { handleSubmit, handleResumeAgent } = useChatSubmission(
chatInputRef as React.RefObject<HTMLDivElement | null>,
fileInputRef as React.RefObject<HTMLInputElement | null>,
smartResize,
@@ -143,7 +141,6 @@ export function CustomChatInput({
chatInputRef={chatInputRef}
handleFileIconClick={handleFileIconClick}
handleSubmit={handleSubmit}
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -153,7 +150,6 @@ export function CustomChatInput({
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
onFocus={handleFocus}
onBlur={handleBlur}
onStop={onStop}
/>
</div>
</div>
@@ -5,6 +5,7 @@ import { GitControlBarPullButton } from "./git-control-bar-pull-button";
import { GitControlBarPushButton } from "./git-control-bar-push-button";
import { GitControlBarPrButton } from "./git-control-bar-pr-button";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { Provider } from "#/types/settings";
import { I18nKey } from "#/i18n/declaration";
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
@@ -17,10 +18,16 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const { repositoryInfo } = useTaskPolling();
const selectedRepository = conversation?.selected_repository;
const gitProvider = conversation?.git_provider as Provider;
const selectedBranch = conversation?.selected_branch;
// Priority: conversation data > task data
// This ensures we show repository info immediately from task, then transition to conversation data
const selectedRepository =
conversation?.selected_repository || repositoryInfo?.selectedRepository;
const gitProvider = (conversation?.git_provider ||
repositoryInfo?.gitProvider) as Provider;
const selectedBranch =
conversation?.selected_branch || repositoryInfo?.selectedBranch;
const hasRepository = !!selectedRepository;
@@ -6,18 +6,14 @@ import { AgentState } from "#/types/agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { GitControlBar } from "./git-control-bar";
import { useConversationStore } from "#/state/conversation-store";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { processFiles, processImages } from "#/utils/file-processing";
interface InteractiveChatBoxProps {
onSubmit: (message: string, images: File[], files: File[]) => void;
onStop: () => void;
}
export function InteractiveChatBox({
onSubmit,
onStop,
}: InteractiveChatBoxProps) {
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
const {
images,
files,
@@ -29,7 +25,7 @@ export function InteractiveChatBox({
addImageLoading,
removeImageLoading,
} = useConversationStore();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const { data: conversation } = useActiveConversation();
// Helper function to validate and filter files
@@ -120,7 +116,7 @@ export function InteractiveChatBox({
// Step 5: Handle failed results
handleFailedFiles(fileResults, imageResults);
} catch (error) {
} catch {
// Clear loading states and show error
clearLoadingStates(validFiles, validImages);
displayErrorToast("An unexpected error occurred while processing files");
@@ -145,7 +141,6 @@ export function InteractiveChatBox({
<CustomChatInput
disabled={isDisabled}
onSubmit={handleSubmit}
onStop={onStop}
onFilesPaste={handleUpload}
conversationStatus={conversation?.status || null}
/>
@@ -24,6 +24,7 @@ import {
import { AgentState } from "#/types/agent-state";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import MemoryIcon from "#/icons/memory_icon.svg?react";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
@@ -51,6 +52,11 @@ export const Messages: React.FC<MessagesProps> = React.memo(
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId);
const { data: activeConversation } = useActiveConversation();
// TODO: Hide microagent actions for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = activeConversation?.conversation_version === "V1";
const optimisticUserMessage = getOptimisticUserMessage();
@@ -236,7 +242,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
)}
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
actions={
conversation?.selected_repository
conversation?.selected_repository && !isV1Conversation
? [
{
icon: (
@@ -259,6 +265,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
<ChatMessage type="user" message={optimisticUserMessage} />
)}
{conversation?.selected_repository &&
!isV1Conversation &&
showLaunchMicroagentModal &&
selectedEventId &&
createPortal(
@@ -14,6 +14,7 @@ import { LoadingMicroagentBody } from "./loading-microagent-body";
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
import { Typography } from "#/ui/typography";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface LaunchMicroagentModalProps {
onClose: () => void;
@@ -32,6 +33,7 @@ export function LaunchMicroagentModal({
}: LaunchMicroagentModalProps) {
const { t } = useTranslation();
const { runtimeActive } = useHandleRuntimeActive();
const { data: conversation } = useActiveConversation();
const { data: prompt, isLoading: promptIsLoading } =
useMicroagentPrompt(eventId);
@@ -40,6 +42,15 @@ export function LaunchMicroagentModal({
const [triggers, setTriggers] = React.useState<string[]>([]);
// TODO: Hide LaunchMicroagentModal for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
const formAction = (formData: FormData) => {
const query = formData.get("query-input")?.toString();
const target = formData.get("target-input")?.toString();
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useStatusStore } from "#/state/status-store";
import { useWsClient } from "#/context/ws-client-provider";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { getStatusCode } from "#/utils/status";
import { ChatStopButton } from "../chat/chat-stop-button";
@@ -12,13 +11,15 @@ import { cn } from "#/utils/utils";
import { AgentLoading } from "./agent-loading";
import { useConversationStore } from "#/state/conversation-store";
import CircleErrorIcon from "#/icons/circle-error.svg?react";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
export interface AgentStatusProps {
className?: string;
handleStop: () => void;
handleResumeAgent: () => void;
disabled?: boolean;
isPausing?: boolean;
}
export function AgentStatus({
@@ -26,12 +27,13 @@ export function AgentStatus({
handleStop,
handleResumeAgent,
disabled = false,
isPausing = false,
}: AgentStatusProps) {
const { t } = useTranslation();
const { setShouldShownAgentLoading } = useConversationStore();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const { curStatusMessage } = useStatusStore();
const { webSocketStatus } = useWsClient();
const webSocketStatus = useUnifiedWebSocketStatus();
const { data: conversation } = useActiveConversation();
const statusCode = getStatusCode(
@@ -43,6 +45,7 @@ export function AgentStatus({
);
const shouldShownAgentLoading =
isPausing ||
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING ||
webSocketStatus === "CONNECTING";
@@ -5,31 +5,29 @@ import { I18nKey } from "#/i18n/declaration";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { ServerStatusContextMenu } from "./server-status-context-menu";
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
export interface ServerStatusProps {
className?: string;
conversationStatus: ConversationStatus | null;
isPausing?: boolean;
handleStop: () => void;
handleResumeAgent: () => void;
}
export function ServerStatus({
className = "",
conversationStatus,
isPausing = false,
handleStop,
handleResumeAgent,
}: ServerStatusProps) {
const [showContextMenu, setShowContextMenu] = useState(false);
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const { t } = useTranslation();
const { conversationId } = useConversationId();
// Mutation hooks
const stopConversationMutation = useStopConversation();
const startConversationMutation = useStartConversation();
const { providers } = useUserProviders();
const { isTask, taskStatus, taskDetail } = useTaskPolling();
const isStartingStatus =
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
@@ -38,6 +36,19 @@ export function ServerStatus({
// Get the appropriate color based on agent status
const getStatusColor = (): string => {
// Show pausing status
if (isPausing) {
return "#FFD600";
}
// Show task status if we're polling a task
if (isTask && taskStatus) {
if (taskStatus === "ERROR") {
return "#FF684E";
}
return "#FFD600";
}
if (isStartingStatus) {
return "#FFD600";
}
@@ -52,6 +63,31 @@ export function ServerStatus({
// Get the appropriate status text based on agent status
const getStatusText = (): string => {
// Show pausing status
if (isPausing) {
return t(I18nKey.COMMON$STOPPING);
}
// Show task status if we're polling a task
if (isTask && taskStatus) {
if (taskStatus === "ERROR") {
return (
taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION)
);
}
if (taskStatus === "READY") {
return t(I18nKey.CONVERSATION$READY);
}
// Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox"
return (
taskDetail ||
taskStatus
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())
);
}
if (isStartingStatus) {
return t(I18nKey.COMMON$STARTING);
}
@@ -76,16 +112,13 @@ export function ServerStatus({
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
stopConversationMutation.mutate({ conversationId });
handleStop();
setShowContextMenu(false);
};
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
startConversationMutation.mutate({
conversationId,
providers,
});
handleResumeAgent();
setShowContextMenu(false);
};
@@ -28,17 +28,23 @@ interface ToolsContextMenuProps {
onClose: () => void;
onShowMicroagents: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools: (event: React.MouseEvent<HTMLButtonElement>) => void;
shouldShowAgentTools?: boolean;
}
export function ToolsContextMenu({
onClose,
onShowMicroagents,
onShowAgentTools,
shouldShowAgentTools = true,
}: ToolsContextMenuProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const { providers } = useUserProviders();
// TODO: Hide microagent menu items for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
const [activeSubmenu, setActiveSubmenu] = useState<"git" | "macros" | null>(
null,
);
@@ -64,7 +70,7 @@ export function ToolsContextMenu({
testId="tools-context-menu"
position="top"
alignment="left"
className="left-[-16px] mb-2 bottom-full overflow-visible"
className="left-[-16px] mb-2 bottom-full overflow-visible min-w-[200px]"
>
{/* Git Tools */}
{showGitTools && (
@@ -122,33 +128,37 @@ export function ToolsContextMenu({
</div>
</div>
<Divider />
{(!isV1Conversation || shouldShowAgentTools) && <Divider />}
{/* Show Available Microagents */}
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<RobotIcon width={16} height={16} />}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
{/* Show Available Microagents - Hidden for V1 conversations */}
{!isV1Conversation && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<RobotIcon width={16} height={16} />}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
)}
{/* Show Agent Tools and Metadata */}
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<ToolsIcon width={16} height={16} />}
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
{/* Show Agent Tools and Metadata - Only show if system message is available */}
{shouldShowAgentTools && (
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<ToolsIcon width={16} height={16} />}
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
)}
</ContextMenu>
);
}
@@ -23,6 +23,7 @@ export function Tools() {
microagentsModalVisible,
setMicroagentsModalVisible,
systemMessage,
shouldShowAgentTools,
} = useConversationNameContextMenu({
conversationId,
conversationStatus: conversation?.status,
@@ -52,6 +53,7 @@ export function Tools() {
onClose={() => setContextMenuOpen(false)}
onShowMicroagents={handleShowMicroagents}
onShowAgentTools={handleShowAgentTools}
shouldShowAgentTools={shouldShowAgentTools}
/>
)}

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