Compare commits

..

72 Commits

Author SHA1 Message Date
mamoodi
c97d66131d Release 1.2.0 2026-01-15 10:08:32 -05:00
Graham Neubig
9af3ee8298 fix: Add WORKER_1 and WORKER_2 env vars to remote sandbox service (#12424)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-15 08:53:04 -05:00
Saurya Velagapudi
169ca5aae9 UV Migration Steps 1.3-1.6: Add project dependencies and generate uv.lock (#12416)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Saurya <saurya@openhands.dev>
2026-01-14 19:32:31 -07:00
Pegasus
3c6edfe14b fix(frontend): Respect HIDE_LLM_SETTINGS flag in settings modal (#12400) 2026-01-14 17:14:32 -06:00
dependabot[bot]
633552a731 chore(deps-dev): bump eslint-plugin-prettier from 5.5.4 to 5.5.5 in /frontend in the eslint group (#12408)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 01:09:36 +04:00
Saurya Velagapudi
3da24da4a0 UV Migration Step 1.1: Add PEP 621 [project] section for UV compatibility (#12414)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-14 20:59:15 +00:00
Tim O'Farrell
f28ab56cc3 fix: require reCAPTCHA token when reCAPTCHA is enabled (#12409)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-14 12:34:09 -07:00
Tim O'Farrell
6ccd42bb29 [APP-369] Fix: Allow public access to shared conversations and events (#12411)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-14 16:47:47 +00:00
Hiep Le
1146ea2274 refactor(frontend): create a feature flag for google recaptcha (#12402) 2026-01-14 19:22:38 +07:00
sp.wack
ff28e13698 hotfix(frontend): Fix auth refetch loading spinner flash (#12396) 2026-01-13 19:08:38 +00:00
Xingyao Wang
9171986dde Consolidate repo guidance into AGENTS.md (#12375)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-13 15:53:14 +00:00
Jatoth Adithya Naik
9d405243b8 Fix tab pin/unpin by aligning localStorage key per conversation- #12287 (#12292)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-13 15:25:08 +00:00
dependabot[bot]
d7218925c4 chore(deps): bump the version-all group in /frontend with 3 updates (#12390)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 19:10:17 +04:00
sp.wack
27c16d6691 chore(frontend): Cross-domain PostHog tracking (#12166) 2026-01-13 18:07:56 +04:00
dependabot[bot]
eabba5c160 chore(deps): bump the version-all group in /frontend with 10 updates (#12379)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 17:54:20 +04:00
Hiep Le
ece7e2dd39 refactor(frontend): update tooltip styling (#12371) 2026-01-13 11:59:40 +07:00
Tim O'Farrell
13762eba7c Add optional sandbox_id parameter to start_sandbox method (#12382)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-12 15:43:24 -07:00
Tim O'Farrell
9cf7d64bfe Guard User Creation with Redis based Lock (#12381)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-12 22:03:42 +00:00
Xingyao Wang
92baebc4bd Remove prod/ prefix from litellm proxy model path (#12200)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-13 04:46:09 +08:00
Hiep Le
3d0aa50450 refactor(frontend): make memory condenser max history size input styling consistent (#12365) 2026-01-12 21:43:06 +07:00
Hiep Le
0e3332d974 fix(frontend): make secrets table responsive when descriptions are long (#12363) 2026-01-12 21:40:09 +07:00
Saurya Velagapudi
3c6d2ff1d6 Add conversation API deprecation notices (#12303)
Co-authored-by: Saurya <saurya@openhands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-01-12 08:26:33 -05:00
Hiep Le
b7b76c7a30 refactor: update the pull request template to add optional demo screenshots and videos (#12367) 2026-01-12 20:20:24 +07:00
dependabot[bot]
17b1c04aa0 chore(deps): bump the version-all group in /frontend with 2 updates (#12340)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-12 15:53:33 +04:00
Hiep Le
624a241bbf refactor: Dockerfile (enterprise) (#12368) 2026-01-12 02:27:44 +07:00
Hiep Le
7862e10f03 chore: update enterprise/Dockerfile (#12355) 2026-01-10 11:00:44 -07:00
Tim O'Farrell
7380039bf6 feat(frontend): improve public share menu behavior (#12345)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-10 08:53:22 -07:00
Hiep Le
d773dd6514 feat: implement reCAPTCHA enterprise risk-based non-interactive (#12288) 2026-01-10 22:04:35 +07:00
Xingyao Wang
175117e8b5 Fix: Prevent Enter key from submitting during IME composition (#12252)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-10 11:34:25 +08:00
Tim O'Farrell
778a1cf609 Fix for critical regression where conversations will not start in OSS (#12347) 2026-01-09 19:29:56 +00:00
OpenHands Bot
c08adc87b4 Bump SDK packages to v1.8.1 (#12343)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-10 02:41:01 +08:00
Graham Neubig
434647e4e4 fix: replace deprecated Pydantic .dict() with .model_dump() (#12339)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-09 12:37:58 -05:00
Tim O'Farrell
849ae13118 Fix regression in EventService search (#12342)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-09 10:00:35 -07:00
Tim O'Farrell
180df8ea20 Remove runtime unit tests (#12331) 2026-01-09 09:06:02 -07:00
sp.wack
17791e5e62 fix: restore agenthub import for agent registration (#12341) 2026-01-09 16:00:06 +00:00
Mengxin Zhu
f3aaebdc33 fix(frontend): preserve path prefix in WebSocket URL for proxy deployments (#12284)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2026-01-09 17:39:02 +04:00
Tim O'Farrell
0e4f0c25d7 Fix merge conflict in DB migrations (#12336) 2026-01-09 05:46:43 +00:00
Hiep Le
d4cf1d4590 fix(frontend): tool titles are not displayed for v1 conversations (#12317) 2026-01-09 10:45:42 +07:00
Graham Neubig
9b50d0cb7d chore(deps): update jinja2, tornado, urllib3 (#12330)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-08 22:05:26 -05:00
Tim O'Farrell
5c411e7fc1 Fix circular import in gitlab_sync.py (#12334)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-08 22:44:18 +00:00
Saurya Velagapudi
6442f772a0 Fix: Parse SANDBOX_VOLUMES env var for agent-server volume mounts (#12327)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-08 20:31:27 +00:00
Rohit Malhotra
5fb431bcc5 feat: Implement Slack V1 integration following GitHub V1 pattern (#11825)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-01-08 13:08:11 -07:00
mamoodi
adfabe7659 docs/styles: Minor updates to some docs and some formatting (#12320) 2026-01-08 12:20:57 -05:00
mamoodi
0ddac3879e Update PR template with note on draft PRs (#12315) 2026-01-08 16:55:16 +00:00
Hiep Le
7398737b06 refactor(frontend): align conversation metrics title to the left in the modal (#12310) 2026-01-08 23:09:26 +07:00
Hiep Le
50d9cbac04 refactor(frontend): reduce gap between icon and text in chat status indicator (#12313) 2026-01-08 23:09:16 +07:00
Sarvatarshan Sankar
a40f7bda21 Fix: Prevent Search API Key from resetting when saving other settings (#12243)
Co-authored-by: Sarvatarshan Sankar <sarvatarshansankar20@Sarvatarshans-MacBook-Air.local>
2026-01-08 19:30:06 +04:00
Pranjal Gupta
39f0e6ed94 perf: eliminate slow chown operations in Docker builds (~41min → seconds) (#12256)
Co-authored-by: Pranjal Gupta <19pran@gmail.com>
2026-01-08 09:29:43 -06:00
Hiep Le
6475aa3487 refactor(frontend): remove auth modal and related tests (#12307) 2026-01-08 21:02:02 +07:00
dependabot[bot]
5dea0d22b4 chore(deps): bump the version-all group across 1 directory with 8 updates (#12298)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 16:34:30 +04:00
sp.wack
a6e8b819ad refactor(frontend): Remove dead 402 error handling code (#12305) 2026-01-08 11:56:27 +00:00
Tim O'Farrell
c97e7082f7 Making sure verify_repo_provider is_optional so log is debug not error (#12302)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-08 05:15:00 +00:00
Tim O'Farrell
cb9e6fde24 Fix Python deprecation warning: use auth=Auth.Token() instead of login_or_token (#12299)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 21:39:36 -07:00
Tim O'Farrell
828837a969 APP-319: No longer logging error when idle sandboxes are stopped (#12296) 2026-01-07 22:55:57 +00:00
Tim O'Farrell
bbdedf8641 Fix unbound variable and read_text() bugs in event services (#12297)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 22:07:23 +00:00
Graham Neubig
11d1e79506 refactor(enterprise): Remove custom Prometheus metrics app (#12253)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 14:49:50 -05:00
Tim O'Farrell
e485c93119 APP-318 Increase LiteLLM HTTP timeout from 5s to 15s (#12290)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 17:40:52 +00:00
Tim O'Farrell
cddf01b4e9 Fix AttributeError in GoogleCloudSharedEventService: use self.bucket instead of erroneous import (#12289)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 17:03:28 +00:00
Abhay Mishra
6086c0b09d feat(frontend): convert AuthModal to dedicated /login page (#12143) (#12181)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-07 16:46:24 +00:00
Jatoth Adithya Naik
15836c4d4b Replace conversation loading spinner with skeleton cards (#12230)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-07 15:57:10 +00:00
Tim O'Farrell
20eb9bd3c5 Use the version from the release tag rather than from main (#12286) 2026-01-07 15:50:57 +00:00
Neha Prasad
ba1770ad89 fix: remove query invalidation to prevent stale (#12238) 2026-01-07 19:20:40 +04:00
dependabot[bot]
4b7ce82f71 chore(deps): bump the version-all group in /frontend with 2 updates (#12277)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-07 13:21:46 +00:00
Hiep Le
5c20724845 fix: db migration (#12282) 2026-01-07 17:29:31 +07:00
Hiep Le
8ddb815a89 refactor(backend): enhance storage and retrieval of blocked domains (#12273) 2026-01-07 13:41:43 +07:00
Engel Nyst
08df955ba7 Clarify OpenHands naming (replace “OSS” wording in docs and backend) (#12235)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 07:24:27 +01:00
Tim O'Farrell
b816d0448b Fix GoogleCloudSharedEventServiceInjector missing bucket_name field and add tests (#12280)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-06 22:27:00 -07:00
Tim O'Farrell
fa974f8106 APP-307 Add Google Cloud Storage-based EventService implementation (#12264)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-06 15:52:07 -07:00
Xingyao Wang
af5c22700c Bump condenser defaults: max_size 120->240 (#12267)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-07 05:43:23 +08:00
Ryanakml
1907ebeaa8 feat: add chat message skeletons and improve routing stability (#12223)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2026-01-07 00:29:08 +04:00
Rohit Malhotra
9686ee02f3 V1 GitHub resolver fixes (#12199)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-06 19:33:54 +00:00
HeyItsChloe
d053a3d363 feat(frontend): adding status indicator and unit test (#12111)
Co-authored-by: Chloe <chloe@openhands.com>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2026-01-06 13:01:27 +00:00
198 changed files with 20426 additions and 2849 deletions

View File

@@ -1,10 +1,16 @@
<!-- Ideally you should open a PR when it is ready for review. Draft PRs will not be reviewed -->
## Summary of PR
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
<!-- Summarize what the PR does -->
## Demo Screenshots/Videos
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
## Change Type
<!-- Choose the types that apply to your PR and remove the rest. -->
<!-- Choose the types that apply to your PR -->
- [ ] Bug fix
- [ ] New feature

View File

@@ -252,151 +252,15 @@ jobs:
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
needs: [ghcr_build_runtime, define-matrix]
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Download runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v6
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
# Forked repos can't push to GHCR, so we need to rebuild using cache
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
with:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Run docker runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flakey tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
# Setting RUN_AS_OPENHANDS to false means use root.
# That should mean SANDBOX_USER_ID is ignored but some tests do not check for RUN_AS_OPENHANDS.
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
name: RT Unit Tests (openhands)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [ghcr_build_runtime, define-matrix]
strategy:
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Download runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v6
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
# Forked repos can't push to GHCR, so we need to rebuild using cache
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
with:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
- name: Run runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge
# Due to this bug: https://github.com/actions/runner/issues/2566, we want to create a job that runs when the
# prerequisites have been cancelled or failed so merging is disallowed, otherwise Github considers "skipped" as "success"
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
runtime_tests_check_fail:
name: All Runtime Tests Passed
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: Some tests failed
run: |
echo "Some runtime tests failed or were cancelled"
exit 1
update_pr_description:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'

View File

@@ -45,7 +45,7 @@ jobs:
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
"Feel free to join our developer community on [Slack](https://all-hands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
"Feel free to join our developer community on [Slack](https://openhands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
"🙌 Happy hacking! 🙌\n\n" +
"<!-- auto-comment:good-first-issue -->"
});

View File

@@ -150,9 +150,9 @@ Each integration follows a consistent pattern with service classes, storage mode
**Important Notes:**
- Enterprise code is licensed under Polyform Free Trial License (30-day limit)
- The enterprise server extends the OSS server through dynamic imports
- The enterprise server extends the OpenHands server through dynamic imports
- Database changes require careful migration planning in `enterprise/migrations/`
- Always test changes in both OSS and enterprise contexts
- Always test changes in both OpenHands and enterprise contexts
- Use the enterprise-specific Makefile commands for development
**Enterprise Testing Best Practices:**
@@ -166,7 +166,7 @@ Each integration follows a consistent pattern with service classes, storage mode
**Import Patterns:**
- Use relative imports without `enterprise.` prefix in enterprise code
- Example: `from storage.database import session_maker` not `from enterprise.storage.database import session_maker`
- This ensures code works in both OSS and enterprise contexts
- This ensures code works in both OpenHands and enterprise contexts
**Test Structure:**
- Place tests in `enterprise/tests/unit/` following the same structure as the source code

View File

@@ -61,7 +61,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
contact@all-hands.dev.
contact@openhands.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
@@ -115,7 +115,9 @@ community.
### Slack Etiquettes
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Lets work together to build a supportive and welcoming community!
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all
community members. By following these best practices, we ensure effective communication and collaboration while
minimizing disruptions. Lets work together to build a supportive and welcoming community!
- Communicate respectfully and professionally, avoiding sarcasm or harsh language, and remember that tone can be difficult to interpret in text.
- Use threads for specific discussions to keep channels organized and easier to follow.
@@ -125,7 +127,10 @@ These Slack etiquette guidelines are designed to foster an inclusive, respectful
- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
- Always adhere to [our standards](https://github.com/OpenHands/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to alert you only when “LLMs” appears in messages.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged.
For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned.
For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to
alert you only when “LLMs” appears in messages.
## Attribution

View File

@@ -1,36 +1,44 @@
# The OpenHands Community
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered world.
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered
world.
## Mission
Its very clear that AI is changing software development. We want the developer community to drive that change organically, through open source.
Its very clear that AI is changing software development. We want the developer community to drive that change
organically, through open source.
So were not just building friendly interfaces for AI-driven development. Were publishing _building blocks_ that empower developers to create new experiences, tailored to your own habits, needs, and imagination.
So were not just building friendly interfaces for AI-driven development. Were publishing _building blocks_ that
empower developers to create new experiences, tailored to your own habits, needs, and imagination.
## Ethos
We have two core values: **high openness** and **high agency**. While we dont expect everyone in the community to embody these values, we want to establish them as norms.
We have two core values: **high openness** and **high agency**. While we dont expect everyone in the community to
embody these values, we want to establish them as norms.
### High Openness
We welcome anyone and everyone into our community by default. You dont have to be a software developer to help us build. You dont have to be pro-AI to help us learn.
We welcome anyone and everyone into our community by default. You dont have to be a software developer to help us
build. You dont have to be pro-AI to help us learn.
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the fruits of our work, but the whole process of growing it.
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the
fruits of our work, but the whole process of growing it.
We welcome thoughtful criticism, whether its a comment on a PR or feedback on the community as a whole.
### High Agency
Everyone should feel empowered to contribute to OpenHands. Whether its by making a PR, hosting an event, sharing feedback, or just asking a question, dont hold back!
Everyone should feel empowered to contribute to OpenHands. Whether its by making a PR, hosting an event, sharing
feedback, or just asking a question, dont hold back!
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly and love building new things.
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly
and love building new things.
Coding, development practices, and communities are changing rapidly. We wont hesitate to change direction and make big bets.
## Relationship to All Hands
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.all-hands.dev/).
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.openhands.dev/).
All Hands was founded by three of the first major contributors to OpenHands:
@@ -38,8 +46,13 @@ All Hands was founded by three of the first major contributors to OpenHands:
- Graham Neubig, a CMU Professor who rallied the academic community around OpenHands
- Robert Brennan, a software engineer who architected the user-facing features of OpenHands
All Hands is an important part of the OpenHands ecosystem. Weve raised over $20M--mainly to hire developers and researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
All Hands is an important part of the OpenHands ecosystem. Weve raised over $20M--mainly to hire developers and
researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we promise to navigate that conflict thoughtfully and transparently.
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility
to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we
promise to navigate that conflict thoughtfully and transparently.
At some point, we may transfer custody of OpenHands to an open source foundation. But for now, the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the “benevolent” part, please: fork us.
At some point, we may transfer custody of OpenHands to an open source foundation. But for now,
the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the
“benevolent” part, please: fork us.

View File

@@ -13,20 +13,23 @@ To understand the codebase, please refer to the README in each module:
## Setting up Your Development Environment
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells you how to set up a development workflow.
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
you how to set up a development workflow.
## How Can I Contribute?
There are many ways that you can contribute:
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.all-hands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
## What Can I Build?
Here are a few ways you can help improve the codebase.
#### UI/UX
We're always looking to improve the look and feel of the application. If you've got a small fix
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
@@ -35,6 +38,7 @@ of the application, please open an issue first, or better, join the #dev-ui-ux c
to gather consensus from our design team first.
#### Improving the agent
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
@@ -46,10 +50,12 @@ We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. Y
channel in Slack to learn more.
#### Adding a new agent
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
to help expand the capabilities of OpenHands.
#### Adding a new runtime
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
to do this by default. But there are other ways of creating a sandbox for the agent.
@@ -57,8 +63,11 @@ If you work for a company that provides a cloud-based runtime, you could help us
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
#### Testing
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing test suites.
At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
quality of the project.
## Sending Pull Requests to OpenHands
@@ -66,7 +75,8 @@ You'll need to fork our repository to send us a Pull Request. You can learn more
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
### Pull Request title
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
- `feat`: A new feature
- `fix`: A bug fix
@@ -87,6 +97,7 @@ For example, a PR title could be:
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
### Pull Request description
- If your PR is small (such as a typo fix), you can go brief.
- If it contains a lot of changes, it's better to write more details.
@@ -97,7 +108,9 @@ please include a short message that we can add to our changelog.
### Opening Issues
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that the community has interest/effort for.
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
the community has interest/effort for.
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
@@ -108,11 +121,13 @@ We're generally happy to consider all pull requests with the evaluation process
#### For Small Improvements
Small improvements with few downsides are typically reviewed and approved quickly.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check before getting a review.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
before getting a review.
#### For Core Agent Changes
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are evaluated based on three key metrics:
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
evaluated based on three key metrics:
1. **Accuracy**
2. **Efficiency**

View File

@@ -2,11 +2,13 @@
## Contributors
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have helped make OpenHands possible. We greatly appreciate your dedication and hard work.
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have
helped make OpenHands possible. We greatly appreciate your dedication and hard work.
## Open Source Projects
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the open source community:
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the
open source community:
#### [SWE Agent](https://github.com/princeton-nlp/swe-agent)
- License: MIT License
@@ -20,8 +22,8 @@ OpenHands includes and adapts the following open source projects. We are gratefu
- License: Apache License 2.0
- Description: Adapted in implementing the browsing agent
### Reference Implementations for Evaluation Benchmarks
OpenHands integrates code of the reference implementations for the following agent evaluation benchmarks:
#### [HumanEval](https://github.com/openai/human-eval)
@@ -52,28 +54,44 @@ OpenHands integrates code of the reference implementations for the following age
#### [ProntoQA](https://github.com/asaparov/prontoqa)
- License: Apache License 2.0
## Open Source licenses
### MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
### BSD 3-Clause License
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
### Apache License 2.0
@@ -268,8 +286,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
Copyright [yyyy] [name of copyright owner]
### Non-Open Source Reference Implementations:
#### [MultiPL-E](https://github.com/nuprl/MultiPL-E)

View File

@@ -76,7 +76,7 @@ variables in your terminal. The final configurations are set from highest to low
Environment variables > config.toml variables > default variables
**Note on Alternative Models:**
See [our documentation](https://docs.all-hands.dev/usage/llms) for recommended models.
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
### 4. Running the application
@@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.1-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
## Develop inside Docker container
@@ -195,7 +195,7 @@ Here's a guide to the important documentation files in the repository:
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
- [DOC_STYLE_GUIDE.md](https://github.com/All-Hands-AI/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment

View File

@@ -3,14 +3,14 @@ These are the procedures and guidelines on how issues are triaged in this repo b
## General
* All issues must be tagged with **enhancement**, **bug** or **troubleshooting/help**.
* Issues may be tagged with what it relates to (**agent quality**, **resolver**, **CLI**, etc.).
* Issues may be tagged with what it relates to (**llm**, **app tab**, **UI/UX**, etc.).
## Severity
* **High**: High visibility issues or affecting many users.
* **Critical**: Affecting all users or potential security issues.
## Difficulty
* Issues with low implementation difficulty may be tagged with **good first issue**.
* Issues good for newcomers may be tagged with **good first issue**.
## Not Enough Information
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
@@ -22,6 +22,6 @@ the issue may be closed as **not planned** (Usually after a week).
* Issues may be broken down into multiple issues if required.
## Stale and Auto Closures
* In order to keep a maintainable backlog, issues that have no activity within 30 days are automatically marked as **Stale**.
* If issues marked as **Stale** continue to have no activity for 7 more days, they will automatically be closed as not planned.
* In order to keep a maintainable backlog, issues that have no activity within 40 days are automatically marked as **Stale**.
* If issues marked as **Stale** continue to have no activity for 10 more days, they will automatically be closed as not planned.
* Issues may be reopened by maintainers if deemed important.

View File

@@ -54,7 +54,7 @@ The experience will be familiar to anyone who has used Devin or Jules.
### OpenHands Cloud
This is a deployment of OpenHands GUI, running on hosted infrastructure.
You can try it with a free $10 credit by [signing in with your GitHub account](https://app.all-hands.dev).
You can try it with a free $10 credit by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
OpenHands Cloud comes with source-available features and integrations:
- Integrations with Slack, Jira, and Linear

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.1-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.2-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -10,6 +10,15 @@ repos:
args: ["--allow-multiple-documents"]
- id: debug-statements
- repo: local
hooks:
- id: warn-appmode-oss
name: "Warn on AppMode.OSS in backend (use AppMode.OPENHANDS)"
language: system
entry: bash -lc 'if rg -n "\\bAppMode\\.OSS\\b" openhands tests/unit; then echo "Found AppMode.OSS usage. Prefer AppMode.OPENHANDS."; exit 1; fi'
pass_filenames: false
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.5.1
hooks:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.1-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.2-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:

View File

@@ -24,9 +24,9 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
# Install Python packages with security fixes
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
RUN /app/.venv/bin/pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy google-cloud-recaptcha-enterprise && \
# Update packages with known CVE fixes
pip install --upgrade \
/app/.venv/bin/pip install --upgrade \
"mcp>=1.10.0" \
"pillow>=11.3.0"

View File

@@ -1,6 +1,6 @@
# OpenHands Enterprise Server
> [!WARNING]
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.all-hands.dev/contact).
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.openhands.dev/contact).
> [!WARNING]
> This is a work in progress and may contain bugs, incomplete features, or breaking changes.
@@ -10,13 +10,13 @@ This directory contains the enterprise server used by [OpenHands Cloud](https://
You may also want to check out the MIT-licensed [OpenHands](https://github.com/OpenHands/OpenHands)
## Extension of OpenHands (OSS)
## Extension of OpenHands
The code in `/enterprise` directory builds on top of open source (OSS) code, extending its functionality. The enterprise code is entangled with the OSS code in two ways
The code in `/enterprise` builds on top of OpenHands (MIT-licensed), extending its functionality. The enterprise code is entangled with OpenHands in two ways:
- Enterprise stacks on top of OSS. For example, the middleware in enterprise is stacked right on top of the middlewares in OSS. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
- Enterprise stacks on top of OpenHands. For example, the middleware in enterprise is stacked right on top of the middlewares in OpenHands. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
- Enterprise overrides the implementation in OSS (only one is present at a time). For example, the server config SaasServerConfig which overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) on OSS. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
- Enterprise overrides the implementation in OpenHands (only one is present at a time). For example, the server config SaasServerConfig overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) in OpenHands. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
Key areas that change on `SAAS` are
@@ -26,11 +26,11 @@ Key areas that change on `SAAS` are
### Authentication
| Aspect | OSS | Enterprise |
| Aspect | OpenHands | Enterprise |
| ------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The Github app provides a short-lived access token and refresh token |
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The GitHub app provides a short-lived access token and refresh token |
| **Token Storage** | PAT is stored in **Settings** | Token is stored in **GithubTokenManager** (a file store in our backend) |
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during oauth, so subsequent requests with the cookie can be considered authenticated |
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during OAuth, so subsequent requests with the cookie can be considered authenticated |
Note that in the future, authentication will happen via keycloak. All modifications for authentication will happen in enterprise.
@@ -38,7 +38,7 @@ Note that in the future, authentication will happen via keycloak. All modificati
The github service is responsible for interacting with Github APIs. As a consequence, it uses the user's token and refreshes it if need be
| Aspect | OSS | Enterprise |
| Aspect | OpenHands | Enterprise |
| ------------------------- | -------------------------------------- | ---------------------------------------------- |
| **Class used** | `GitHubService` | `SaaSGitHubService` |
| **Token used** | User's PAT fetched from `Settings` | User's token fetched from `GitHubTokenManager` |
@@ -50,7 +50,7 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
## User ID vs User Token
- On OSS, the entire APP revolves around the Github token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
Note that introducing Github User ID on OSS, for instance, will cause large breakages.
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.

View File

@@ -2,7 +2,7 @@
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 simple local development setup, with live reloading for both OpenHands and this repo
- A more complex setup that includes Redis
- An even more complex setup that includes GitHub events
@@ -26,7 +26,7 @@ Before starting, make sure you have the following tools installed:
## Option 1: Simple local development
This option will allow you to modify the both the OSS code and the code in this repo,
This option will allow you to modify both the OpenHands 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
@@ -105,9 +105,9 @@ export REDIS_PORT=6379
(see above)
### 2. Build OSS Openhands
### 2. Build OpenHands
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
Develop on [Openhands](https://github.com/OpenHands/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
```
docker build -f containers/app/Dockerfile -t openhands .
@@ -155,7 +155,7 @@ Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-
### 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.
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 OpenHands running.
#### Redis
@@ -201,8 +201,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL 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",
@@ -235,8 +235,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL 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",

View File

@@ -310,7 +310,7 @@ class GithubManager(Manager):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
if not github_view.v1:
if not github_view.v1_enabled:
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,

View File

@@ -1,11 +1,12 @@
import logging
import os
from typing import Any
from uuid import UUID
import httpx
from github import Auth, Github, GithubIntegration
from integrations.utils import CONVERSATION_URL, get_summary_instruction
from pydantic import Field
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
@@ -20,8 +21,6 @@ from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
get_conversation_url,
get_prompt_template,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
@@ -34,7 +33,6 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
github_view_data: dict[str, Any] = Field(default_factory=dict)
should_request_summary: bool = Field(default=True)
should_extract: bool = Field(default=True)
inline_pr_comment: bool = Field(default=False)
async def __call__(
@@ -64,7 +62,12 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
self.should_request_summary = False
try:
_logger.info(f'[GitHub V1] Requesting summary {conversation_id}')
summary = await self._request_summary(conversation_id)
_logger.info(
f'[GitHub V1] Posting summary {conversation_id}',
extra={'summary': summary},
)
await self._post_summary_to_github(summary)
return EventCallbackResult(
@@ -82,12 +85,12 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
# Check if we have installation ID and credentials before posting
if (
self.github_view_data.get('installation_id')
and os.getenv('GITHUB_APP_CLIENT_ID')
and os.getenv('GITHUB_APP_PRIVATE_KEY')
and GITHUB_APP_CLIENT_ID
and GITHUB_APP_PRIVATE_KEY
):
await self._post_summary_to_github(
f'OpenHands encountered an error: **{str(e)}**.\n\n'
f'[See the conversation]({get_conversation_url().format(conversation_id)})'
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
'for more information.'
)
except Exception as post_error:
@@ -115,16 +118,11 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
f'Missing installation ID for GitHub payload: {self.github_view_data}'
)
github_app_client_id = os.getenv('GITHUB_APP_CLIENT_ID', '').strip()
github_app_private_key = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace(
'\\n', '\n'
)
if not github_app_client_id or not github_app_private_key:
if not GITHUB_APP_CLIENT_ID or not GITHUB_APP_PRIVATE_KEY:
raise ValueError('GitHub App credentials are not configured')
github_integration = GithubIntegration(
auth=Auth.AppAuth(github_app_client_id, github_app_private_key),
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY),
)
token_data = github_integration.get_access_token(installation_id)
return token_data.token
@@ -140,7 +138,7 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
issue_number = self.github_view_data['issue_number']
if self.inline_pr_comment:
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(full_repo_name)
pr = repo.get_pull(issue_number)
pr.create_review_comment_reply(
@@ -148,7 +146,7 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
)
return
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(full_repo_name)
issue = repo.get_issue(number=issue_number)
issue.create_comment(summary)
@@ -274,16 +272,16 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
app_conversation_info.sandbox_id,
)
assert sandbox.session_api_key is not None, (
f'No session API key for sandbox: {sandbox.id}'
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_prompt_template('summary_prompt.j2')
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(

View File

@@ -17,6 +17,7 @@ from integrations.utils import (
HOST,
HOST_URL,
get_oh_labels,
get_user_v1_enabled_setting,
has_exact_mention,
)
from jinja2 import Environment
@@ -55,6 +56,10 @@ from openhands.utils.async_utils import call_sync_from_async
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
async def is_v1_enabled_for_github_resolver(user_id: str) -> bool:
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITHUB_RESOLVER
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
"""Get the user's proactive conversation setting.
@@ -88,38 +93,6 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
return settings.enable_proactive_conversation_starters
async def get_user_v1_enabled_setting(user_id: str) -> bool:
"""Get the user's V1 conversation API setting.
Args:
user_id: The keycloak user ID
Returns:
True if V1 conversations are enabled for this user, False otherwise
Note:
This function checks both the global environment variable kill switch AND
the user's individual setting. Both must be true for the function to return true.
"""
# Check the global environment variable first
if not ENABLE_V1_GITHUB_RESOLVER:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.v1_enabled is None:
return False
return settings.v1_enabled
# =================================================
# SECTION: Github view types
# =================================================
@@ -140,7 +113,10 @@ class GithubIssue(ResolverViewInterface):
title: str
description: str
previous_comments: list[Comment]
v1: bool
v1_enabled: bool
def _get_branch_name(self) -> str | None:
return getattr(self, 'branch_name', None)
async def _load_resolver_context(self):
github_service = GithubServiceImpl(
@@ -188,23 +164,28 @@ class GithubIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
self.v1_enabled = await is_v1_enabled_for_github_resolver(
self.user_info.keycloak_user_id
)
if v1_enabled:
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
if self.v1_enabled:
# Create dummy conversationm metadata
# Don't save to conversation store
# V1 conversations are stored in a separate table
self.conversation_id = uuid4().hex
return ConversationMetadata(
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
conversation_id=self.conversation_id,
selected_repository=self.full_repo_name,
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=None,
selected_branch=self._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
@@ -218,25 +199,18 @@ class GithubIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
try:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
return
except Exception as e:
logger.warning(f'Error checking V1 settings, falling back to V0: {e}')
# Use existing V0 conversation service
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
else:
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
@@ -294,6 +268,7 @@ class GithubIssue(ResolverViewInterface):
system_message_suffix=conversation_instructions,
initial_message=initial_message,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITHUB,
title=f'GitHub Issue #{self.issue_number}: {self.title}',
trigger=ConversationTrigger.RESOLVER,
@@ -318,11 +293,9 @@ class GithubIssue(ResolverViewInterface):
f'Failed to start V1 conversation: {task.detail}'
)
self.v1 = True
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
from integrations.github.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
@@ -390,31 +363,6 @@ class GithubPRComment(GithubIssueComment):
return user_instructions, conversation_instructions
async def initialize_new_conversation(self) -> ConversationMetadata:
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
# Create dummy conversationm metadata
# Don't save to conversation store
# V1 conversations are stored in a separate table
return ConversationMetadata(
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self.branch_name,
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
@dataclass
class GithubInlinePRComment(GithubPRComment):
@@ -464,7 +412,7 @@ class GithubInlinePRComment(GithubPRComment):
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
from integrations.github.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
@@ -830,7 +778,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
v1_enabled=False,
)
elif GithubFactory.is_issue_comment(message):
@@ -856,7 +804,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
v1_enabled=False,
)
elif GithubFactory.is_pr_comment(message):
@@ -898,7 +846,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
v1_enabled=False,
)
elif GithubFactory.is_inline_pr_comment(message):
@@ -932,7 +880,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
v1_enabled=False,
)
else:

View File

@@ -16,9 +16,8 @@ from integrations.utils import (
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_session_expired_message,
)
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.constants import SLACK_CLIENT_ID
from server.utils.conversation_callback_utils import register_callback_processor
from slack_sdk.oauth import AuthorizeUrlGenerator
@@ -59,17 +58,6 @@ class SlackManager(Manager):
if message.source != SourceType.SLACK:
raise ValueError(f'Unexpected message source {message.source}')
async def _get_user_auth(self, keycloak_user_id: str) -> UserAuth:
offline_token = await self.token_manager.load_offline_token(keycloak_user_id)
if offline_token is None:
logger.info('no_offline_token_found')
user_auth = SaasUserAuth(
user_id=keycloak_user_id,
refresh_token=SecretStr(offline_token),
)
return user_auth
async def authenticate_user(
self, slack_user_id: str
) -> tuple[SlackUser | None, UserAuth | None]:
@@ -86,7 +74,9 @@ class SlackManager(Manager):
saas_user_auth = None
if slack_user:
saas_user_auth = await self._get_user_auth(slack_user.keycloak_user_id)
saas_user_auth = await get_saas_user_auth(
slack_user.keycloak_user_id, self.token_manager
)
# slack_view.saas_user_auth = await self._get_user_auth(slack_view.slack_to_openhands_user.keycloak_user_id)
return slack_user, saas_user_auth
@@ -249,13 +239,11 @@ class SlackManager(Manager):
async def is_job_requested(
self, message: Message, slack_view: SlackViewInterface
) -> bool:
"""
A job is always request we only receive webhooks for events associated with the slack bot
"""A job is always request we only receive webhooks for events associated with the slack bot
This method really just checks
1. Is the user is authenticated
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
"""
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
if isinstance(slack_view, SlackUpdateExistingConversationView):
return True
@@ -322,10 +310,15 @@ class SlackManager(Manager):
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
)
if not isinstance(slack_view, SlackUpdateExistingConversationView):
# Only add SlackCallbackProcessor for new conversations (not updates) and non-v1 conversations
if (
not isinstance(slack_view, SlackUpdateExistingConversationView)
and not slack_view.v1_enabled
):
# We don't re-subscribe for follow up messages from slack.
# Summaries are generated for every messages anyways, we only need to do
# this subscription once for the event which kicked off the job.
processor = SlackCallbackProcessor(
slack_user_id=slack_view.slack_user_id,
channel_id=slack_view.channel_id,
@@ -340,6 +333,14 @@ class SlackManager(Manager):
logger.info(
f'[Slack] Created callback processor for conversation {conversation_id}'
)
elif isinstance(slack_view, SlackUpdateExistingConversationView):
logger.info(
f'[Slack] Skipping callback processor for existing conversation update {conversation_id}'
)
elif slack_view.v1_enabled:
logger.info(
f'[Slack] Skipping callback processor for v1 conversation {conversation_id}'
)
msg_info = slack_view.get_response_msg()

View File

@@ -21,20 +21,16 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"Instructions passed when conversation is first initialized"
"""Instructions passed when conversation is first initialized"""
pass
@abstractmethod
async def create_or_update_conversation(self, jinja_env: Environment):
"Create a new conversation"
pass
@abstractmethod
def get_callback_id(self) -> str:
"Unique callback id for subscribription made to EventStream for fetching agent summary"
"""Create a new conversation"""
pass
@abstractmethod
@@ -43,6 +39,4 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
class StartingConvoException(Exception):
"""
Raised when trying to send message to a conversation that's is still starting up
"""
"""Raised when trying to send message to a conversation that's is still starting up"""

View File

@@ -0,0 +1,273 @@
import logging
from uuid import UUID
import httpx
from integrations.utils import CONVERSATION_URL, get_summary_instruction
from pydantic import Field
from slack_sdk import WebClient
from storage.slack_team_store import SlackTeamStore
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
_logger = logging.getLogger(__name__)
class SlackV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Slack V1 integrations."""
slack_view_data: dict[str, str | None] = Field(default_factory=dict)
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Slack V1 integration."""
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
return None
_logger.info('[Slack V1] Callback agent state was %s', event)
try:
summary = await self._request_summary(conversation_id)
await self._post_summary_to_slack(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception('[Slack V1] Error processing callback: %s', e)
# Only try to post error to Slack if we have basic requirements
try:
await self._post_summary_to_slack(
f'OpenHands encountered an error: **{str(e)}**.\n\n'
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
'for more information.'
)
except Exception as post_error:
_logger.warning(
'[Slack V1] Failed to post error message to Slack: %s', post_error
)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
# -------------------------------------------------------------------------
# Slack helpers
# -------------------------------------------------------------------------
def _get_bot_access_token(self):
slack_team_store = SlackTeamStore.get_instance()
bot_access_token = slack_team_store.get_team_bot_token(
self.slack_view_data['team_id']
)
return bot_access_token
async def _post_summary_to_slack(self, summary: str) -> None:
"""Post a summary message to the configured Slack channel."""
bot_access_token = self._get_bot_access_token()
if not bot_access_token:
raise RuntimeError('Missing Slack bot access token')
channel_id = self.slack_view_data['channel_id']
thread_ts = self.slack_view_data.get('thread_ts') or self.slack_view_data.get(
'message_ts'
)
client = WebClient(token=bot_access_token)
try:
# Post the summary as a threaded reply
response = client.chat_postMessage(
channel=channel_id,
text=summary,
thread_ts=thread_ts,
unfurl_links=False,
unfurl_media=False,
)
if not response['ok']:
raise RuntimeError(
f"Slack API error: {response.get('error', 'Unknown error')}"
)
_logger.info(
'[Slack V1] Successfully posted summary to channel %s', channel_id
)
except Exception as e:
_logger.error('[Slack V1] Failed to post message to Slack: %s', e)
raise
# -------------------------------------------------------------------------
# Agent / sandbox helpers
# -------------------------------------------------------------------------
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f'{agent_server_url.rstrip("/")}'
f'/api/conversations/{conversation_id}/ask_agent'
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception: # noqa: BLE001
pass
_logger.error(
'[Slack V1] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
exc_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.error(
'[Slack V1] %s. Request payload: %s',
error_detail,
payload,
exc_info=True,
)
raise Exception(error_detail)
except httpx.RequestError as e:
error_detail = f'Request error to {url}: {str(e)}'
_logger.error(
'[Slack V1] %s. Request payload: %s',
error_detail,
payload,
exc_info=True,
)
raise Exception(error_detail)
# -------------------------------------------------------------------------
# Summary orchestration
# -------------------------------------------------------------------------
async def _request_summary(self, conversation_id: UUID) -> str:
"""
Ask the agent to produce a summary of its work and return the agent response.
NOTE: This method now returns a string (the agent server's response text)
and raises exceptions on errors. The wrapping into EventCallbackResult
is handled by __call__.
"""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)

View File

@@ -1,8 +1,16 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.slack.slack_types import SlackViewInterface, StartingConvoException
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
from integrations.utils import (
CONVERSATION_URL,
ENABLE_V1_SLACK_RESOLVER,
get_final_agent_observation,
get_user_v1_enabled_setting,
)
from jinja2 import Environment
from slack_sdk import WebClient
from storage.slack_conversation import SlackConversation
@@ -10,22 +18,34 @@ from storage.slack_conversation_store import SlackConversationStore
from storage.slack_team_store import SlackTeamStore
from storage.slack_user import SlackUser
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
SendMessageRequest,
)
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
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.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
)
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
# =================================================
# SECTION: Github view types
# SECTION: Slack view types
# =================================================
@@ -34,6 +54,10 @@ slack_conversation_store = SlackConversationStore.get_instance()
slack_team_store = SlackTeamStore.get_instance()
async def is_v1_enabled_for_slack_resolver(user_id: str) -> bool:
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_SLACK_RESOLVER
@dataclass
class SlackUnkownUserView(SlackViewInterface):
bot_access_token: str
@@ -49,6 +73,7 @@ class SlackUnkownUserView(SlackViewInterface):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
raise NotImplementedError
@@ -56,9 +81,6 @@ class SlackUnkownUserView(SlackViewInterface):
async def create_or_update_conversation(self, jinja_env: Environment):
raise NotImplementedError
def get_callback_id(self) -> str:
raise NotImplementedError
def get_response_msg(self) -> str:
raise NotImplementedError
@@ -78,6 +100,7 @@ class SlackNewConversationView(SlackViewInterface):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
def _get_initial_prompt(self, text: str, blocks: list[dict]):
bot_id = self._get_bot_id(blocks)
@@ -96,8 +119,7 @@ class SlackNewConversationView(SlackViewInterface):
return ''
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"Instructions passed when conversation is first initialized"
"""Instructions passed when conversation is first initialized"""
user_info: SlackUser = self.slack_to_openhands_user
messages = []
@@ -157,7 +179,7 @@ class SlackNewConversationView(SlackViewInterface):
'Attempting to start conversation without confirming selected repo from user'
)
async def save_slack_convo(self):
async def save_slack_convo(self, v1_enabled: bool = False):
if self.slack_to_openhands_user:
user_info: SlackUser = self.slack_to_openhands_user
@@ -168,6 +190,7 @@ class SlackNewConversationView(SlackViewInterface):
'conversation_id': self.conversation_id,
'keycloak_user_id': user_info.keycloak_user_id,
'parent_id': self.thread_ts or self.message_ts,
'v1_enabled': v1_enabled,
},
)
slack_conversation = SlackConversation(
@@ -176,17 +199,47 @@ class SlackNewConversationView(SlackViewInterface):
keycloak_user_id=user_info.keycloak_user_id,
parent_id=self.thread_ts
or self.message_ts, # conversations can start in a thread reply as well; we should always references the parent's (root level msg's) message ID
v1_enabled=v1_enabled,
)
await slack_conversation_store.create_slack_conversation(slack_conversation)
def _create_slack_v1_callback_processor(self) -> SlackV1CallbackProcessor:
"""Create a SlackV1CallbackProcessor for V1 conversation handling."""
return SlackV1CallbackProcessor(
slack_view_data={
'channel_id': self.channel_id,
'message_ts': self.message_ts,
'thread_ts': self.thread_ts,
'team_id': self.team_id,
'slack_user_id': self.slack_user_id,
}
)
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""
Only creates a new conversation
"""
"""Only creates a new conversation"""
self._verify_necessary_values_are_set()
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Check if V1 conversations are enabled for this user
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
self.slack_to_openhands_user.keycloak_user_id
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(jinja)
return self.conversation_id
else:
# Use existing V0 conversation service
await self._create_v0_conversation(jinja, provider_tokens, user_secrets)
return self.conversation_id
async def _create_v0_conversation(
self, jinja: Environment, provider_tokens, user_secrets
) -> None:
"""Create conversation using the legacy V0 system."""
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Determine git provider from repository
@@ -213,11 +266,65 @@ class SlackNewConversationView(SlackViewInterface):
)
self.conversation_id = agent_loop_info.conversation_id
await self.save_slack_convo()
return self.conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
def get_callback_id(self) -> str:
return f'slack_{self.channel_id}_{self.message_ts}'
async def _create_v1_conversation(self, jinja: Environment) -> None:
"""Create conversation using the new V1 app conversation system."""
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=user_instructions)]
)
# Create the Slack V1 callback processor
slack_callback_processor = self._create_slack_v1_callback_processor()
# Determine git provider from repository
git_provider = None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = ProviderType(repository.git_provider.value)
# Get the app conversation service and start the conversation
injector_state = InjectorState()
# Create the V1 conversation start request with the callback processor
self.conversation_id = uuid4().hex
start_request = AppConversationStartRequest(
conversation_id=UUID(self.conversation_id),
system_message_suffix=conversation_instructions,
initial_message=initial_message,
selected_repository=self.selected_repo,
git_provider=git_provider,
title=f'Slack conversation from {self.slack_to_openhands_user.slack_display_name}',
trigger=ConversationTrigger.SLACK,
processors=[
slack_callback_processor
], # Pass the callback processor directly
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
logger.info(f'[Slack V1]: Created new conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=True)
def get_response_msg(self) -> str:
user_info: SlackUser = self.slack_to_openhands_user
@@ -254,32 +361,20 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
return user_message, ''
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""
Send new user message to converation
"""
async def send_message_to_v0_conversation(self, jinja: Environment):
user_info: SlackUser = self.slack_to_openhands_user
saas_user_auth: UserAuth = self.saas_user_auth
user_id = user_info.keycloak_user_id
# Org management in the future will get rid of this
# For now, only user that created the conversation can send follow up messages to it
if user_id != self.slack_conversation.keycloak_user_id:
raise StartingConvoException(
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
)
# Check if conversation has been deleted
# Update logic when soft delete is implemented
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
saas_user_auth: UserAuth = self.saas_user_auth
provider_tokens = await saas_user_auth.get_provider_tokens()
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
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()
# Should we raise here if there are no provider tokens?
providers_set = list(provider_tokens.keys()) if provider_tokens else []
@@ -310,6 +405,117 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
self.conversation_id, event_to_dict(user_msg_action)
)
async def send_message_to_v1_conversation(self, jinja: Environment):
"""Send a message to a v1 conversation using the agent server API."""
# Import services within the method to avoid circular imports
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
get_agent_server_url_from_sandbox,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
UUID(self.conversation_id)
),
UUID(self.conversation_id),
)
# 2. Sandbox lookup + validation
sandbox = await sandbox_service.get_sandbox(
app_conversation_info.sandbox_id
)
if sandbox and sandbox.status == SandboxStatus.PAUSED:
# Resume paused sandbox and wait for it to be running
logger.info('[Slack V1]: Attempting to resume paused sandbox')
await sandbox_service.resume_sandbox(app_conversation_info.sandbox_id)
# Wait for sandbox to be running (handles both fresh start and resume)
running_sandbox = await sandbox_service.wait_for_sandbox_running(
app_conversation_info.sandbox_id,
timeout=120,
poll_interval=2,
httpx_client=httpx_client,
)
assert (
running_sandbox.session_api_key is not None
), f'No session API key for sandbox: {running_sandbox.id}'
# 3. Get the agent server URL
agent_server_url = get_agent_server_url_from_sandbox(running_sandbox)
# 4. Prepare the message content
user_msg, _ = self._get_instructions(jinja)
# 5. Create the message request
send_message_request = SendMessageRequest(
role='user', content=[TextContent(text=user_msg)], run=True
)
# 6. Send the message to the agent server
url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events'
headers = {'X-Session-API-Key': running_sandbox.session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
except Exception as e:
logger.error(
'[Slack V1] Failed to send message to conversation %s: %s',
self.conversation_id,
str(e),
exc_info=True,
)
raise Exception(f'Failed to send message to v1 conversation: {str(e)}')
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""Send new user message to converation"""
user_info: SlackUser = self.slack_to_openhands_user
user_id = user_info.keycloak_user_id
# Org management in the future will get rid of this
# For now, only user that created the conversation can send follow up messages to it
if user_id != self.slack_conversation.keycloak_user_id:
raise StartingConvoException(
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
)
if self.slack_conversation.v1_enabled:
await self.send_message_to_v1_conversation(jinja)
else:
await self.send_message_to_v0_conversation(jinja)
return self.conversation_id
def get_response_msg(self):
@@ -361,7 +567,7 @@ class SlackFactory:
'channel_id': channel_id,
},
)
raise Exception('Did not slack team')
raise Exception('Did not find slack team')
# Determine if this is a known slack user by openhands
if not slack_user or not saas_user_auth or not channel_id:
@@ -379,6 +585,7 @@ class SlackFactory:
send_summary_instruction=False,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)
conversation: SlackConversation | None = call_async_from_sync(
@@ -409,6 +616,7 @@ class SlackFactory:
conversation_id=conversation.conversation_id,
slack_conversation=conversation,
team_id=team_id,
v1_enabled=False,
)
elif SlackFactory.did_user_select_repo_from_form(message):
@@ -426,6 +634,7 @@ class SlackFactory:
send_summary_instruction=True,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)
else:
@@ -443,4 +652,5 @@ class SlackFactory:
send_summary_instruction=True,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)

View File

@@ -45,7 +45,3 @@ class ResolverViewInterface(SummaryExtractionTracker):
async def create_new_conversation(self, jinja_env: Environment, token: str):
"Create a new conversation"
raise NotImplementedError()
def get_callback_id(self) -> str:
"Unique callback id for subscribription made to EventStream for fetching agent summary"
raise NotImplementedError()

View File

@@ -6,7 +6,9 @@ import re
from typing import TYPE_CHECKING
from jinja2 import Environment, FileSystemLoader
from server.config import get_config
from server.constants import WEB_HOST
from storage.database import session_maker
from storage.repository_store import RepositoryStore
from storage.stored_repository import StoredRepository
from storage.user_repo_map import UserRepositoryMap
@@ -25,6 +27,7 @@ from openhands.events.event_store_abc import EventStoreABC
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.integrations.service_types import Repository
from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.utils.async_utils import call_sync_from_async
if TYPE_CHECKING:
from openhands.server.conversation_manager.conversation_manager import (
@@ -36,7 +39,7 @@ if TYPE_CHECKING:
HOST = WEB_HOST
# ---- DO NOT REMOVE ----
HOST_URL = f'https://{HOST}'
HOST_URL = f'https://{HOST}' if 'localhost' not in HOST else f'http://{HOST}'
GITHUB_WEBHOOK_URL = f'{HOST_URL}/integration/github/events'
GITLAB_WEBHOOK_URL = f'{HOST_URL}/integration/gitlab/events'
conversation_prefix = 'conversations/{}'
@@ -78,8 +81,14 @@ ENABLE_V1_GITHUB_RESOLVER = (
os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true'
)
ENABLE_V1_SLACK_RESOLVER = (
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
)
OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/'
OPENHANDS_RESOLVER_TEMPLATES_DIR = (
os.getenv('OPENHANDS_RESOLVER_TEMPLATES_DIR')
or 'openhands/integrations/templates/resolver/'
)
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
@@ -107,6 +116,37 @@ def get_summary_instruction():
return summary_instruction
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
"""Get the user's V1 conversation API setting.
Args:
user_id: The keycloak user ID
Returns:
True if V1 conversations are enabled for this user, False otherwise
"""
# If no user ID is provided, we can't check user settings
if not user_id:
return False
from storage.saas_settings_store import SaasSettingsStore
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.v1_enabled is None:
return False
return settings.v1_enabled
def has_exact_mention(text: str, mention: str) -> bool:
"""Check if the text contains an exact mention (not part of a larger word).

View File

@@ -0,0 +1,30 @@
"""add v1 column to slack conversation table
Revision ID: 086
Revises: 085
Create Date: 2025-12-02 15:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '086'
down_revision: Union[str, None] = '085'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add v1 column
op.add_column(
'slack_conversation', sa.Column('v1_enabled', sa.Boolean(), nullable=True)
)
def downgrade() -> None:
# Drop v1 column
op.drop_column('slack_conversation', 'v1_enabled')

View File

@@ -0,0 +1,61 @@
"""bump condenser defaults: max_size 120->240
Revision ID: 087
Revises: 086
Create Date: 2026-01-05
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = '087'
down_revision: Union[str, None] = '086'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema.
Update existing users with condenser_max_size=120 or NULL to 240.
This covers both users who had the old default (120) explicitly set
and users who had NULL (which defaulted to 120 in the application code).
The SDK default for keep_first will be used automatically.
"""
user_settings_table = table(
'user_settings',
column('condenser_max_size', sa.Integer),
)
# Update users with explicit 120 value
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size == 120)
.values(condenser_max_size=240)
)
# Update users with NULL value (which defaulted to 120 in application code)
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size.is_(None))
.values(condenser_max_size=240)
)
def downgrade() -> None:
"""Downgrade schema.
Note: This sets all 240 values back to NULL (not 120) since we can't
distinguish between users who had 120 vs NULL before the upgrade.
"""
user_settings_table = table(
'user_settings', column('condenser_max_size', sa.Integer)
)
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size == 240)
.values(condenser_max_size=None)
)

View File

@@ -0,0 +1,54 @@
"""create blocked_email_domains table
Revision ID: 088
Revises: 087
Create Date: 2025-01-27 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '088'
down_revision: Union[str, None] = '087'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create blocked_email_domains table for storing blocked email domain patterns."""
op.create_table(
'blocked_email_domains',
sa.Column('id', sa.Integer(), sa.Identity(), nullable=False, primary_key=True),
sa.Column('domain', sa.String(), nullable=False),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column(
'updated_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.PrimaryKeyConstraint('id'),
)
# Create unique index on domain column
op.create_index(
'ix_blocked_email_domains_domain',
'blocked_email_domains',
['domain'],
unique=True,
)
def downgrade() -> None:
"""Drop blocked_email_domains table."""
op.drop_index('ix_blocked_email_domains_domain', table_name='blocked_email_domains')
op.drop_table('blocked_email_domains')

84
enterprise/poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "aiofiles"
@@ -2884,6 +2884,28 @@ google-auth = ">=1.25.0,<3.0dev"
[package.extras]
grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"]
[[package]]
name = "google-cloud-recaptcha-enterprise"
version = "1.29.0"
description = "Google Cloud Recaptcha Enterprise API client library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "google_cloud_recaptcha_enterprise-1.29.0-py3-none-any.whl", hash = "sha256:d3332f3ab9c586404c187d111326670bc745b4cbb64cad0a1c16259356c43c6d"},
{file = "google_cloud_recaptcha_enterprise-1.29.0.tar.gz", hash = "sha256:60cb3e8fb5860733c3c2c0b69d3a0aca6cf1ece2738ad7b5952183f1fb77e3e7"},
]
[package.dependencies]
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpcio = ">=1.33.2,<2.0.0"
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
[[package]]
name = "google-cloud-resource-manager"
version = "1.14.2"
@@ -5836,14 +5858,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.7.4"
version = "1.8.1"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.7.4-py3-none-any.whl", hash = "sha256:997b3dc5243a1ba105f5bd9b0b5bc0cd590c5aa79cd609f23f841218e5f77393"},
{file = "openhands_agent_server-1.7.4.tar.gz", hash = "sha256:0491cf2a5d596610364cbbe9360412bc10a66ae71c0466ab64fd264826e6f1d8"},
{file = "openhands_agent_server-1.8.1-py3-none-any.whl", hash = "sha256:c0dfe620184633a173094ffaa77b0d13124ea7bf84e7b534b1641e5fc5fd0256"},
{file = "openhands_agent_server-1.8.1.tar.gz", hash = "sha256:08adfe26d867ff0cb0c1e87bb0ad6e058c9a97374964ba6a9860ea35d32764a0"},
]
[package.dependencies]
@@ -5860,7 +5882,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "0.0.0-post.5803+a8098505c"
version = "1.1.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5890,7 +5912,7 @@ google-genai = "*"
html2text = "*"
httpx-aiohttp = "^0.1.8"
ipywidgets = "^8.1.5"
jinja2 = "^3.1.3"
jinja2 = "^3.1.6"
joblib = "*"
json-repair = "*"
jupyter_kernel_gateway = "*"
@@ -5902,9 +5924,9 @@ memory-profiler = "^0.61.0"
numpy = "*"
openai = "2.8.0"
openhands-aci = "0.3.2"
openhands-agent-server = "1.7.4"
openhands-sdk = "1.7.4"
openhands-tools = "1.7.4"
openhands-agent-server = "1.8.1"
openhands-sdk = "1.8.1"
openhands-tools = "1.8.1"
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5943,9 +5965,9 @@ starlette = "^0.48.0"
tenacity = ">=8.5,<10.0"
termcolor = "*"
toml = "*"
tornado = "*"
tornado = ">=6.5"
types-toml = "*"
urllib3 = "^2.5.0"
urllib3 = "^2.6.3"
uvicorn = "*"
whatthepatch = "^1.0.6"
zope-interface = "7.2"
@@ -5959,14 +5981,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.7.4"
version = "1.8.1"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.7.4-py3-none-any.whl", hash = "sha256:b57511a0467bd3fa64e8cccb7e8026f95e10ee7c5b148335eaa762a32aad8369"},
{file = "openhands_sdk-1.7.4.tar.gz", hash = "sha256:f8e63f996a13d2ea41447384b77a4ffebeb9e85aa54fafcf584f97f7cdc2cd9b"},
{file = "openhands_sdk-1.8.1-py3-none-any.whl", hash = "sha256:133275f56321585c016b4718d56c8fc7bb834f4ef7cab1ef66b0c71c49d47d1d"},
{file = "openhands_sdk-1.8.1.tar.gz", hash = "sha256:9e2baa6c512ac4c2bc1c2c0bf8b1dbdb0267d794a8b86b7306a4656fc0cb8b0b"},
]
[package.dependencies]
@@ -5986,14 +6008,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.7.4"
version = "1.8.1"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.7.4-py3-none-any.whl", hash = "sha256:b6a9b04bc59610087d6df789054c966df176c16371fc9c0b0f333ba09f5710d1"},
{file = "openhands_tools-1.7.4.tar.gz", hash = "sha256:776b570da0e86ae48c7815e9adb3839e953e2f4cab7295184ce15849348c52e7"},
{file = "openhands_tools-1.8.1-py3-none-any.whl", hash = "sha256:9404b17edb8960d4af3a4439e6f68e37c92c59d0705f13096e4a8ff9b6ffc472"},
{file = "openhands_tools-1.8.1.tar.gz", hash = "sha256:e59fcd9ca3baa6266e92020646c4c5f5266f57761f434770cf0cd458b1a33cb0"},
]
[package.dependencies]
@@ -6858,22 +6880,6 @@ files = [
[package.extras]
twisted = ["twisted"]
[[package]]
name = "prometheus-fastapi-instrumentator"
version = "7.1.0"
description = "Instrument your FastAPI app with Prometheus metrics"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9"},
{file = "prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e"},
]
[package.dependencies]
prometheus-client = ">=0.8.0,<1.0.0"
starlette = ">=0.30.0,<1.0.0"
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
@@ -13700,21 +13706,21 @@ files = [
[[package]]
name = "urllib3"
version = "2.5.0"
version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
[package.extras]
brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "uuid7"
@@ -14508,4 +14514,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "fac67a8991a3e2c840a23702dc90f99e98d381f3537ad50b4c4739cdbde941ca"
content-hash = "b5cbb1e25176845ac9f95650a802667e2f8be1a536e3e55a9269b5af5a42e3fc"

View File

@@ -29,7 +29,6 @@ cloud-sql-python-connector = "^1.16.0"
psycopg2-binary = "^2.9.10"
pg8000 = "^1.31.2"
stripe = "^11.5.0"
prometheus-fastapi-instrumentator = "^7.0.2"
python-json-logger = "^3.2.1"
python-keycloak = "^5.3.1"
asyncpg = "^0.30.0"
@@ -44,6 +43,7 @@ coredis = "^4.22.0"
httpx = "*"
scikit-learn = "^1.7.0"
shap = "^0.48.0"
google-cloud-recaptcha-enterprise = "^1.24.0"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.3"

View File

@@ -18,7 +18,6 @@ from server.auth.constants import ( # noqa: E402
)
from server.constants import PERMITTED_CORS_ORIGINS # noqa: E402
from server.logger import logger # noqa: E402
from server.metrics import metrics_app # noqa: E402
from server.middleware import SetAuthCookieMiddleware # noqa: E402
from server.rate_limit import setup_rate_limit_handler # noqa: E402
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
@@ -61,9 +60,6 @@ def is_saas():
return {'saas': True}
# This requires a trailing slash to access, like /api/metrics/
base_app.mount('/internal/metrics', metrics_app())
base_app.include_router(readiness_router) # Add routes for readiness checks
base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback

View File

@@ -38,8 +38,16 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
'y',
'on',
)
BLOCKED_EMAIL_DOMAINS = [
domain.strip().lower()
for domain in os.getenv('BLOCKED_EMAIL_DOMAINS', '').split(',')
if domain.strip()
]
# reCAPTCHA Enterprise
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
# Account Defender labels that indicate suspicious activity
SUSPICIOUS_LABELS = {
'SUSPICIOUS_LOGIN_ACTIVITY',
'SUSPICIOUS_ACCOUNT_CREATION',
'RELATED_ACCOUNTS_NUMBER_HIGH',
}

View File

@@ -1,20 +1,13 @@
from server.auth.constants import BLOCKED_EMAIL_DOMAINS
from storage.blocked_email_domain_store import BlockedEmailDomainStore
from storage.database import session_maker
from openhands.core.logger import openhands_logger as logger
class DomainBlocker:
def __init__(self) -> None:
def __init__(self, store: BlockedEmailDomainStore) -> None:
logger.debug('Initializing DomainBlocker')
self.blocked_domains: list[str] = BLOCKED_EMAIL_DOMAINS
if self.blocked_domains:
logger.info(
f'Successfully loaded {len(self.blocked_domains)} blocked email domains: {self.blocked_domains}'
)
def is_active(self) -> bool:
"""Check if domain blocking is enabled"""
return bool(self.blocked_domains)
self.store = store
def _extract_domain(self, email: str) -> str | None:
"""Extract and normalize email domain from email address"""
@@ -31,16 +24,16 @@ class DomainBlocker:
return None
def is_domain_blocked(self, email: str) -> bool:
"""Check if email domain is blocked
"""Check if email domain is blocked by querying the database directly via SQL.
Supports blocking:
- Exact domains: 'example.com' blocks 'user@example.com'
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
"""
if not self.is_active():
return False
The blocking logic is handled efficiently in SQL, avoiding the need to load
all blocked domains into memory.
"""
if not email:
logger.debug('No email provided for domain check')
return False
@@ -50,26 +43,25 @@ class DomainBlocker:
logger.debug(f'Could not extract domain from email: {email}')
return False
# Check if domain matches any blocked pattern
for blocked_pattern in self.blocked_domains:
if blocked_pattern.startswith('.'):
# TLD pattern (e.g., '.us') - check if domain ends with it
if domain.endswith(blocked_pattern):
logger.warning(
f'Email domain {domain} is blocked by TLD pattern {blocked_pattern} for email: {email}'
)
return True
try:
# Query database directly via SQL to check if domain is blocked
is_blocked = self.store.is_domain_blocked(domain)
if is_blocked:
logger.warning(f'Email domain {domain} is blocked for email: {email}')
else:
# Full domain pattern (e.g., 'example.com')
# Block exact match or subdomains
if domain == blocked_pattern or domain.endswith(f'.{blocked_pattern}'):
logger.warning(
f'Email domain {domain} is blocked by domain pattern {blocked_pattern} for email: {email}'
)
return True
logger.debug(f'Email domain {domain} is not blocked')
logger.debug(f'Email domain {domain} is not blocked')
return False
return is_blocked
except Exception as e:
logger.error(
f'Error checking if domain is blocked for email {email}: {e}',
exc_info=True,
)
# Fail-safe: if database query fails, don't block (allow auth to proceed)
return False
domain_blocker = DomainBlocker()
# Initialize store and domain blocker
_store = BlockedEmailDomainStore(session_maker=session_maker)
domain_blocker = DomainBlocker(store=_store)

View File

@@ -1,6 +1,5 @@
import asyncio
from integrations.gitlab.gitlab_service import SaaSGitLabService
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
@@ -19,6 +18,12 @@ def schedule_gitlab_repo_sync(
async def _run():
try:
# Lazy import to avoid circular dependency:
# middleware -> gitlab_sync -> integrations.gitlab.gitlab_service
# -> openhands.integrations.gitlab.gitlab_service -> get_impl
# -> integrations.gitlab.gitlab_service (circular)
from integrations.gitlab.gitlab_service import SaaSGitLabService
service = SaaSGitLabService(
external_auth_id=user_id, external_auth_token=keycloak_access_token
)

View File

@@ -0,0 +1,153 @@
import hashlib
import hmac
from dataclasses import dataclass
from google.cloud import recaptchaenterprise_v1
from server.auth.constants import (
RECAPTCHA_BLOCK_THRESHOLD,
RECAPTCHA_HMAC_SECRET,
RECAPTCHA_PROJECT_ID,
RECAPTCHA_SITE_KEY,
SUSPICIOUS_LABELS,
)
from openhands.core.logger import openhands_logger as logger
@dataclass
class AssessmentResult:
"""Result of a reCAPTCHA Enterprise assessment."""
score: float
valid: bool
action_valid: bool
reason_codes: list[str]
account_defender_labels: list[str]
allowed: bool
class RecaptchaService:
"""Service for creating reCAPTCHA Enterprise assessments."""
def __init__(self):
self._client = None
self.project_id = RECAPTCHA_PROJECT_ID
self.site_key = RECAPTCHA_SITE_KEY
@property
def client(self):
"""Lazily initialize the reCAPTCHA client to avoid credential errors at import time."""
if self._client is None:
self._client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
return self._client
def hash_account_id(self, email: str) -> str:
"""Hash email using SHA256-HMAC for Account Defender.
Args:
email: The user's email address.
Returns:
Hex-encoded HMAC-SHA256 hash of the lowercase email.
"""
return hmac.new(
RECAPTCHA_HMAC_SECRET.encode(),
email.lower().encode(),
hashlib.sha256,
).hexdigest()
def create_assessment(
self,
token: str,
action: str,
user_ip: str,
user_agent: str,
email: str | None = None,
) -> AssessmentResult:
"""Create a reCAPTCHA Enterprise assessment.
Args:
token: The reCAPTCHA token from the frontend.
action: The expected action name (e.g., 'LOGIN').
user_ip: The user's IP address.
user_agent: The user's browser user agent.
email: Optional email for Account Defender hashing.
Returns:
AssessmentResult with score, validity, and allowed status.
"""
event = recaptchaenterprise_v1.Event()
event.site_key = self.site_key
event.token = token
event.user_ip_address = user_ip
event.user_agent = user_agent
event.expected_action = action
# Account Defender: use user_info.account_id (hashed_account_id is deprecated)
if email:
user_info = recaptchaenterprise_v1.UserInfo()
user_info.account_id = self.hash_account_id(email)
# Also include email as a user identifier for better fraud detection
user_info.user_ids.append(recaptchaenterprise_v1.UserId(email=email))
event.user_info = user_info
assessment = recaptchaenterprise_v1.Assessment()
assessment.event = event
request = recaptchaenterprise_v1.CreateAssessmentRequest()
request.assessment = assessment
request.parent = f'projects/{self.project_id}'
response = self.client.create_assessment(request)
token_properties = response.token_properties
risk_analysis = response.risk_analysis
score = risk_analysis.score
valid = token_properties.valid
action_valid = token_properties.action == action
reason_codes = [str(r) for r in risk_analysis.reasons]
# Extract Account Defender labels
account_defender_labels = []
if response.account_defender_assessment:
account_defender_labels = [
str(label) for label in response.account_defender_assessment.labels
]
# Check if any suspicious labels are present
has_suspicious_labels = bool(set(account_defender_labels) & SUSPICIOUS_LABELS)
# Block if: invalid token, wrong action, low score, OR suspicious Account Defender labels
allowed = (
valid
and action_valid
and score >= RECAPTCHA_BLOCK_THRESHOLD
and not has_suspicious_labels
)
logger.info(
'recaptcha_assessment',
extra={
'score': score,
'valid': valid,
'action_valid': action_valid,
'reasons': reason_codes,
'account_defender_labels': account_defender_labels,
'has_suspicious_labels': has_suspicious_labels,
'allowed': allowed,
'user_ip': user_ip,
},
)
return AssessmentResult(
score=score,
valid=valid,
action_valid=action_valid,
reason_codes=reason_codes,
account_defender_labels=account_defender_labels,
allowed=allowed,
)
recaptcha_service = RecaptchaService()

View File

@@ -317,7 +317,7 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
email_verified = access_token_payload['email_verified']
# Check if email domain is blocked
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
if email and domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for existing user with email: {email}'
)

View File

@@ -8,7 +8,6 @@ import socketio
from server.logger import logger
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.stored_conversation_metadata import StoredConversationMetadata
from openhands.core.config import LLMConfig
@@ -743,6 +742,8 @@ class ClusteredConversationManager(StandaloneConversationManager):
return
# Restart the agent loop
from storage.saas_settings_store import SaasSettingsStore
config = load_openhands_config()
settings_store = await SaasSettingsStore.get_instance(config, user_id)
settings = await settings_store.load()

View File

@@ -17,6 +17,7 @@ from server.auth.constants import (
GITHUB_APP_PRIVATE_KEY,
GITHUB_APP_WEBHOOK_SECRET,
GITLAB_APP_CLIENT_ID,
RECAPTCHA_SITE_KEY,
)
from openhands.core.config.utils import load_openhands_config
@@ -187,4 +188,7 @@ class SaaSServerConfig(ServerConfig):
if self.auth_url:
config['AUTH_URL'] = self.auth_url
if RECAPTCHA_SITE_KEY:
config['RECAPTCHA_SITE_KEY'] = RECAPTCHA_SITE_KEY
return config

View File

@@ -73,36 +73,22 @@ PERMITTED_CORS_ORIGINS = [
def build_litellm_proxy_model_path(model_name: str) -> str:
"""
Build the LiteLLM proxy model path based on environment and model name.
This utility constructs the full model path for LiteLLM proxy based on:
- Environment type (staging vs prod)
- The provided model name
"""Build the LiteLLM proxy model path based on model name.
Args:
model_name: The base model name (e.g., 'claude-3-7-sonnet-20250219')
Returns:
The full LiteLLM proxy model path (e.g., 'litellm_proxy/prod/claude-3-7-sonnet-20250219')
The full LiteLLM proxy model path (e.g., 'litellm_proxy/claude-3-7-sonnet-20250219')
"""
if 'prod' in model_name or 'litellm' in model_name or 'proxy' in model_name:
if 'litellm' in model_name:
raise ValueError("Only include model name, don't include prefix")
prefix = 'litellm_proxy/'
if not IS_STAGING_ENV and not IS_LOCAL_ENV:
prefix += 'prod/'
return prefix + model_name
return 'litellm_proxy/' + model_name
def get_default_litellm_model():
"""
Construct proxy for litellm model based on user settings and environment type (staging vs prod)
if not set explicitly
"""
"""Construct proxy for litellm model based on user settings if not set explicitly."""
if LITELLM_DEFAULT_MODEL:
return LITELLM_DEFAULT_MODEL
model = USER_SETTINGS_VERSION_TO_MODEL[CURRENT_USER_SETTINGS_VERSION]

View File

@@ -1,43 +0,0 @@
from typing import Callable
from prometheus_client import Gauge, make_asgi_app
from server.clustered_conversation_manager import ClusteredConversationManager
from openhands.server.shared import (
conversation_manager,
)
RUNNING_AGENT_LOOPS_GAUGE = Gauge(
'saas_running_agent_loops',
'Count of running agent loops, aggregate by session_id to dedupe',
['session_id'],
)
async def _update_metrics():
"""Update any prometheus metrics that are not updated during normal operation."""
if isinstance(conversation_manager, ClusteredConversationManager):
running_agent_loops = (
await conversation_manager.get_running_agent_loops_locally()
)
# Clear so we don't keep counting old sessions.
# This is theoretically a race condition but this is scraped on a regular interval.
RUNNING_AGENT_LOOPS_GAUGE.clear()
# running_agent_loops shouldn't be None, but can be.
if running_agent_loops is not None:
for sid in running_agent_loops:
RUNNING_AGENT_LOOPS_GAUGE.labels(session_id=sid).set(1)
def metrics_app() -> Callable:
metrics_callable = make_asgi_app()
async def wrapped_handler(scope, receive, send):
"""
Call _update_metrics before serving Prometheus metrics endpoint.
Not wrapped in a `try`, failing would make metrics endpoint unavailable.
"""
await _update_metrics()
await metrics_callable(scope, receive, send)
return wrapped_handler

View File

@@ -166,6 +166,12 @@ class SetAuthCookieMiddleware:
if path in ignore_paths:
return False
# Allow public access to shared conversations and events
if path.startswith('/api/shared-conversations') or path.startswith(
'/api/shared-events'
):
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp

View File

@@ -1,3 +1,5 @@
import base64
import json
import warnings
from datetime import datetime, timezone
from typing import Annotated, Literal, Optional
@@ -12,10 +14,12 @@ from server.auth.constants import (
KEYCLOAK_CLIENT_ID,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL_EXT,
RECAPTCHA_SITE_KEY,
ROLE_CHECK_ENABLED,
)
from server.auth.domain_blocker import domain_blocker
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.recaptcha_service import recaptcha_service
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.config import get_config, sign_token
@@ -98,6 +102,24 @@ def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
)
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
"""Extract redirect URL and reCAPTCHA token from OAuth state.
Returns:
Tuple of (redirect_url, recaptcha_token). Token may be None.
"""
if not state:
return '', None
try:
# Try to decode as JSON (new format with reCAPTCHA)
state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
return state_data.get('redirect_url', ''), state_data.get('recaptcha_token')
except Exception:
# Old format - state is just the redirect URL
return state, None
@oauth_router.get('/keycloak/callback')
async def keycloak_callback(
request: Request,
@@ -106,7 +128,11 @@ async def keycloak_callback(
error: Optional[str] = None,
error_description: Optional[str] = None,
):
redirect_url: str = state if state else str(request.base_url)
# Extract redirect URL and reCAPTCHA token from state
redirect_url, recaptcha_token = _extract_recaptcha_state(state)
if not redirect_url:
redirect_url = str(request.base_url)
if not code:
# check if this is a forward from the account linking page
if (
@@ -149,9 +175,55 @@ async def keycloak_callback(
email = user_info.get('email')
user_id = user_info['sub']
# reCAPTCHA verification with Account Defender
if RECAPTCHA_SITE_KEY:
if not recaptcha_token:
logger.warning(
'recaptcha_token_missing',
extra={
'user_id': user_id,
'email': email,
},
)
error_url = f'{request.base_url}login?recaptcha_blocked=true'
return RedirectResponse(error_url, status_code=302)
user_ip = request.client.host if request.client else 'unknown'
user_agent = request.headers.get('User-Agent', '')
# Handle X-Forwarded-For for proxied requests
forwarded_for = request.headers.get('X-Forwarded-For')
if forwarded_for:
user_ip = forwarded_for.split(',')[0].strip()
try:
result = recaptcha_service.create_assessment(
token=recaptcha_token,
action='LOGIN',
user_ip=user_ip,
user_agent=user_agent,
email=email,
)
if not result.allowed:
logger.warning(
'recaptcha_blocked_at_callback',
extra={
'user_ip': user_ip,
'score': result.score,
'user_id': user_id,
},
)
# Redirect to home with error parameter
error_url = f'{request.base_url}login?recaptcha_blocked=true'
return RedirectResponse(error_url, status_code=302)
except Exception as e:
logger.exception(f'reCAPTCHA verification error at callback: {e}')
# Fail open - continue with login if reCAPTCHA service unavailable
# Check if email domain is blocked
email = user_info.get('email')
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
if email and domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'
)
@@ -193,7 +265,7 @@ async def keycloak_callback(
)
# Redirect to home page with query parameter indicating the issue
home_url = f'{request.base_url}?duplicated_email=true'
home_url = f'{request.base_url}/login?duplicated_email=true'
return RedirectResponse(home_url, status_code=302)
except Exception as e:
# Log error but allow signup to proceed (fail open)
@@ -210,9 +282,7 @@ async def keycloak_callback(
from server.routes.email import verify_email
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
redirect_url = (
f'{request.base_url}?email_verification_required=true&user_id={user_id}'
)
redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
response = RedirectResponse(redirect_url, status_code=302)
return response

View File

@@ -112,7 +112,9 @@ async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse
if not stripe_service.STRIPE_API_KEY:
return GetCreditsResponse()
try:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=15.0
) as client:
user_json = await _get_litellm_user(client, user_id)
credits = calculate_credits(user_json['user_info'])
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))

View File

@@ -166,7 +166,7 @@ async def verify_email(request: Request, user_id: str, is_auth_flow: bool = Fals
keycloak_admin = get_keycloak_admin()
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
if is_auth_flow:
redirect_uri = f'{scheme}://{request.url.netloc}?email_verified=true'
redirect_uri = f'{scheme}://{request.url.netloc}/login?email_verified=true'
else:
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
logger.info(f'Redirect URI: {redirect_uri}')

View File

@@ -107,7 +107,7 @@ async def install_callback(
# Redirect into keycloak
scope = quote('openid email profile offline_access')
redirect_uri = quote(f'{HOST_URL}/slack/keycloak-callback')
redirect_uri = f'{HOST_URL}/slack/keycloak-callback'
auth_url = (
f'{KEYCLOAK_SERVER_URL_EXT}/realms/{KEYCLOAK_REALM_NAME}/protocol/openid-connect/auth'
f'?client_id={KEYCLOAK_CLIENT_ID}&response_type=code'
@@ -158,7 +158,7 @@ async def keycloak_callback(
team_id = payload['team_id']
# Retrieve the keycloak_user_id
redirect_uri = f'https://{request.url.netloc}{request.url.path}'
redirect_uri = f'{HOST_URL}{request.url.path}'
(
keycloak_access_token,
keycloak_refresh_token,

View File

@@ -1,4 +1,3 @@
from prometheus_client import Counter, Histogram
from server.logger import logger
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -9,45 +8,27 @@ from openhands.events.observation import (
)
from openhands.server.monitoring import MonitoringListener
AGENT_STATUS_ERROR_COUNT = Counter(
'saas_agent_status_errors', 'Agent Status change events to status error'
)
CREATE_CONVERSATION_COUNT = Counter(
'saas_create_conversation', 'Create conversation attempts'
)
AGENT_SESSION_START_HISTOGRAM = Histogram(
'saas_agent_session_start',
'AgentSession starts with success and duration',
labelnames=['success'],
)
class SaaSMonitoringListener(MonitoringListener):
"""
Forward app signals to Prometheus.
"""
"""Forward app signals to structured logging for GCP native monitoring."""
def on_session_event(self, event: Event) -> None:
"""
Track metrics about events being added to a Session's EventStream.
"""
"""Track metrics about events being added to a Session's EventStream."""
if (
isinstance(event, AgentStateChangedObservation)
and event.agent_state == AgentState.ERROR
):
AGENT_STATUS_ERROR_COUNT.inc()
logger.info(
'Tracking agent status error',
extra={'signal': 'saas_agent_status_errors'},
)
def on_agent_session_start(self, success: bool, duration: float) -> None:
"""
Track an agent session start.
"""Track an agent session start.
Success is true if startup completed without error.
Duration is start time in seconds observed by AgentSession.
"""
AGENT_SESSION_START_HISTOGRAM.labels(success=success).observe(duration)
logger.info(
'Tracking agent session start',
extra={
@@ -58,11 +39,10 @@ class SaaSMonitoringListener(MonitoringListener):
)
def on_create_conversation(self) -> None:
"""
Track the beginning of conversation creation.
"""Track the beginning of conversation creation.
Does not currently capture whether it succeed.
"""
CREATE_CONVERSATION_COUNT.inc()
logger.info(
'Tracking create conversation', extra={'signal': 'saas_create_conversation'}
)

View File

@@ -9,12 +9,18 @@ This implementation provides read-only access to events from shared conversation
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from google.cloud import storage
from google.cloud.storage.bucket import Bucket
from google.cloud.storage.client import Client
from pydantic import Field
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
@@ -28,6 +34,9 @@ from server.sharing.sql_shared_conversation_info_service import (
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event.google_cloud_event_service import (
GoogleCloudEventService,
)
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
@@ -36,17 +45,13 @@ logger = logging.getLogger(__name__)
@dataclass
class SharedEventServiceImpl(SharedEventService):
class GoogleCloudSharedEventService(SharedEventService):
"""Implementation of SharedEventService that validates shared access."""
shared_conversation_info_service: SharedConversationInfoService
event_service: EventService
bucket: Bucket
async def get_shared_event(
self, conversation_id: UUID, event_id: str
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
@@ -55,8 +60,25 @@ class SharedEventServiceImpl(SharedEventService):
if shared_conversation_info is None:
return None
return GoogleCloudEventService(
bucket=self.bucket,
prefix=Path('users'),
user_id=shared_conversation_info.created_by_user_id,
app_conversation_info_service=None,
app_conversation_info_load_tasks={},
)
async def get_shared_event(
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
event_service = await self.get_event_service(conversation_id)
if event_service is None:
return None
# If conversation is shared, get the event
return await self.event_service.get_event(event_id)
return await event_service.get_event(conversation_id, event_id)
async def search_shared_events(
self,
@@ -70,18 +92,14 @@ class SharedEventServiceImpl(SharedEventService):
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await self.event_service.search_events(
conversation_id__eq=conversation_id,
return await event_service.search_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
@@ -96,47 +114,45 @@ class SharedEventServiceImpl(SharedEventService):
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
event_service = await self.get_event_service(conversation_id)
if event_service is None:
# Return empty page if conversation is not shared
return 0
# If conversation is shared, count events for this conversation
return await self.event_service.count_events(
conversation_id__eq=conversation_id,
return await event_service.count_events(
conversation_id=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
)
class SharedEventServiceImplInjector(SharedEventServiceInjector):
class GoogleCloudSharedEventServiceInjector(SharedEventServiceInjector):
bucket_name: str | None = Field(
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import (
get_db_session,
get_event_service,
)
from openhands.app_server.config import get_db_session
async with (
get_db_session(state, request) as db_session,
get_event_service(state, request) as event_service,
):
async with get_db_session(state, request) as db_session:
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
service = SharedEventServiceImpl(
bucket_name = self.bucket_name
storage_client: Client = storage.Client()
bucket: Bucket = storage_client.bucket(bucket_name)
service = GoogleCloudSharedEventService(
shared_conversation_info_service=shared_conversation_info_service,
event_service=event_service,
bucket=bucket,
)
yield service

View File

@@ -5,8 +5,8 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from server.sharing.filesystem_shared_event_service import (
SharedEventServiceImplInjector,
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventServiceInjector,
)
from server.sharing.shared_event_service import SharedEventService
@@ -15,7 +15,9 @@ from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.sdk import Event
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
shared_event_service_dependency = Depends(SharedEventServiceImplInjector().depends)
shared_event_service_dependency = Depends(
GoogleCloudSharedEventServiceInjector().depends
)
# Read methods
@@ -85,10 +87,6 @@ async def count_shared_events(
datetime | None,
Query(title='Optional filter by timestamp less than'),
] = None,
sort_order: Annotated[
EventSortOrder,
Query(title='Sort order for results'),
] = EventSortOrder.TIMESTAMP,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> int:
"""Count events for a shared conversation matching the given filters."""
@@ -97,14 +95,13 @@ async def count_shared_events(
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
)
@router.get('')
async def batch_get_shared_events(
conversation_id: Annotated[
UUID,
str,
Query(title='Conversation ID to get events for'),
],
id: Annotated[list[str], Query()],
@@ -112,15 +109,20 @@ async def batch_get_shared_events(
) -> list[Event | None]:
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
assert len(id) <= 100
events = await shared_event_service.batch_get_shared_events(conversation_id, id)
event_ids = [UUID(id_) for id_ in id]
events = await shared_event_service.batch_get_shared_events(
UUID(conversation_id), event_ids
)
return events
@router.get('/{conversation_id}/{event_id}')
async def get_shared_event(
conversation_id: UUID,
conversation_id: str,
event_id: str,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> Event | None:
"""Get a single event from a shared conversation by conversation_id and event_id."""
return await shared_event_service.get_shared_event(conversation_id, event_id)
return await shared_event_service.get_shared_event(
UUID(conversation_id), UUID(event_id)
)

View File

@@ -18,7 +18,7 @@ class SharedEventService(ABC):
@abstractmethod
async def get_shared_event(
self, conversation_id: UUID, event_id: str
self, conversation_id: UUID, event_id: UUID
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
@@ -42,12 +42,11 @@ class SharedEventService(ABC):
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
) -> int:
"""Count events for a specific shared conversation."""
async def batch_get_shared_events(
self, conversation_id: UUID, event_ids: list[str]
self, conversation_id: UUID, event_ids: list[UUID]
) -> list[Event | None]:
"""Given a conversation_id and list of event_ids, get events if the conversation is shared."""
return await asyncio.gather(

View File

@@ -0,0 +1,30 @@
from datetime import UTC, datetime
from sqlalchemy import Column, DateTime, Identity, Integer, String
from storage.base import Base
class BlockedEmailDomain(Base): # type: ignore
"""Stores blocked email domain patterns.
Supports blocking:
- Exact domains: 'example.com' blocks 'user@example.com'
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
"""
__tablename__ = 'blocked_email_domains'
id = Column(Integer, Identity(), primary_key=True)
domain = Column(String, nullable=False, unique=True)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)

View File

@@ -0,0 +1,45 @@
from dataclasses import dataclass
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
@dataclass
class BlockedEmailDomainStore:
session_maker: sessionmaker
def is_domain_blocked(self, domain: str) -> bool:
"""Check if a domain is blocked by querying the database directly.
This method uses SQL to efficiently check if the domain matches any blocked pattern:
- TLD patterns (e.g., '.us'): checks if domain ends with the pattern
- Full domain patterns (e.g., 'example.com'): checks for exact match or subdomain match
Args:
domain: The extracted domain from the email (e.g., 'example.com' or 'subdomain.example.com')
Returns:
True if the domain is blocked, False otherwise
"""
with self.session_maker() as session:
# SQL query that handles both TLD patterns and full domain patterns
# TLD patterns (starting with '.'): check if domain ends with the pattern
# Full domain patterns: check for exact match or subdomain match
# All comparisons are case-insensitive using LOWER() to ensure consistent matching
query = text("""
SELECT EXISTS(
SELECT 1
FROM blocked_email_domains
WHERE
-- TLD pattern (e.g., '.us') - check if domain ends with it (case-insensitive)
(LOWER(domain) LIKE '.%' AND LOWER(:domain) LIKE '%' || LOWER(domain)) OR
-- Full domain pattern (e.g., 'example.com')
-- Block exact match or subdomains (case-insensitive)
(LOWER(domain) NOT LIKE '.%' AND (
LOWER(:domain) = LOWER(domain) OR
LOWER(:domain) LIKE '%.' || LOWER(domain)
))
)
""")
result = session.execute(query, {'domain': domain}).scalar()
return bool(result)

View File

@@ -2,7 +2,6 @@ from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import and_, desc
from sqlalchemy.exc import ProgrammingError
from sqlalchemy.orm import sessionmaker
from storage.database import session_maker
from storage.openhands_pr import OpenhandsPR
@@ -136,29 +135,22 @@ class OpenhandsPRStore:
Returns:
List of OpenhandsPR objects that need processing
"""
try:
with self.session_maker() as session:
unprocessed_prs = (
session.query(OpenhandsPR)
.filter(
and_(
~OpenhandsPR.processed,
OpenhandsPR.process_attempts < max_retries,
OpenhandsPR.provider == ProviderType.GITHUB.value,
)
with self.session_maker() as session:
unprocessed_prs = (
session.query(OpenhandsPR)
.filter(
and_(
~OpenhandsPR.processed,
OpenhandsPR.process_attempts < max_retries,
OpenhandsPR.provider == ProviderType.GITHUB.value,
)
.order_by(desc(OpenhandsPR.updated_at))
.limit(limit)
.all()
)
return unprocessed_prs
except ProgrammingError as e:
logger.warning(
f'Could not query openhands_prs table - it may not exist yet. '
f'Run database migrations first. Error: {e}'
.order_by(desc(OpenhandsPR.updated_at))
.limit(limit)
.all()
)
return []
return unprocessed_prs
@classmethod
def get_instance(cls):

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import binascii
import hashlib
import json
@@ -34,6 +35,13 @@ from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.http_session import httpx_verify_option
# The max possible time to wait for another process to finish creating a user before retrying
_REDIS_CREATE_TIMEOUT_SECONDS = 30
# The delay to wait for another process to finish creating a user before trying to load again
_RETRY_LOAD_DELAY_SECONDS = 2
# Redis key prefix for user creation locks
_REDIS_USER_CREATION_KEY_PREFIX = 'create_user:'
@dataclass
class SaasSettingsStore(SettingsStore):
@@ -131,6 +139,32 @@ class SaasSettingsStore(SettingsStore):
session.add(settings)
session.commit()
def _get_redis_client(self):
"""Get the Redis client from the Socket.IO manager."""
from openhands.server.shared import sio
return getattr(sio.manager, 'redis', None)
async def _acquire_user_creation_lock(self) -> bool:
"""Attempt to acquire a distributed lock for user creation.
Returns True if the lock was acquired or if Redis is unavailable (fallback to no locking).
Returns False if another process holds the lock.
"""
redis_client = self._get_redis_client()
if redis_client is None:
logger.warning(
'saas_settings_store:_acquire_user_creation_lock:no_redis_client',
extra={'user_id': self.user_id},
)
return True # Proceed without locking if Redis is unavailable
user_key = f'{_REDIS_USER_CREATION_KEY_PREFIX}{self.user_id}'
lock_acquired = await redis_client.set(
user_key, 1, nx=True, ex=_REDIS_CREATE_TIMEOUT_SECONDS
)
return bool(lock_acquired)
async def create_default_settings(self, user_settings: UserSettings | None):
logger.info(
'saas_settings_store:create_default_settings:start',
@@ -140,6 +174,16 @@ class SaasSettingsStore(SettingsStore):
if not self.user_id:
return None
# Prevent duplicate settings creation using distributed lock
if not await self._acquire_user_creation_lock():
# The user is already being created in another thread / process
logger.info(
'saas_settings_store:create_default_settings:waiting_for_lock',
extra={'user_id': self.user_id},
)
await asyncio.sleep(_RETRY_LOAD_DELAY_SECONDS)
return await self.load()
# Only users that have specified a payment method get default settings
if REQUIRE_PAYMENT and not await stripe_service.has_payment_method(
self.user_id

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Identity, Integer, String
from sqlalchemy import Boolean, Column, Identity, Integer, String
from storage.base import Base
@@ -9,3 +9,4 @@ class SlackConversation(Base): # type: ignore
channel_id = Column(String, nullable=False)
keycloak_user_id = Column(String, nullable=False)
parent_id = Column(String, nullable=True, index=True)
v1_enabled = Column(Boolean, nullable=True)

View File

@@ -14,8 +14,7 @@ class SlackConversationStore:
async def get_slack_conversation(
self, channel_id: str, parent_id: str
) -> SlackConversation | None:
"""
Get a slack conversation by channel_id and message_ts.
"""Get a slack conversation by channel_id and message_ts.
Both parameters are required to match for a conversation to be returned.
"""
with session_maker() as session:

View File

@@ -10,12 +10,14 @@ Covers:
- Low-level helper methods
"""
import os
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
import pytest
from integrations.github.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
@@ -24,9 +26,6 @@ from openhands.app_server.event_callback.event_callback_models import EventCallb
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
from openhands.app_server.sandbox.sandbox_models import (
ExposedUrl,
SandboxInfo,
@@ -198,30 +197,27 @@ class TestGithubV1CallbackProcessor:
# Successful paths
# ------------------------------------------------------------------ #
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'test_client_id',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'test_private_key',
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
)
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Auth')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
)
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
@patch('integrations.github.github_v1_callback_processor.Auth')
@patch('integrations.github.github_v1_callback_processor.GithubIntegration')
@patch('integrations.github.github_v1_callback_processor.Github')
async def test_successful_callback_execution(
self,
mock_github,
mock_github_integration,
mock_auth,
mock_get_prompt_template,
mock_get_summary_instruction,
mock_get_httpx_client,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
@@ -242,11 +238,13 @@ class TestGithubV1CallbackProcessor:
mock_sandbox_info,
)
mock_get_prompt_template.return_value = 'Please provide a summary'
mock_get_summary_instruction.return_value = 'Please provide a summary'
# Auth.AppAuth mock
# Auth.AppAuth and Auth.Token mock
mock_app_auth_instance = MagicMock()
mock_auth.AppAuth.return_value = mock_app_auth_instance
mock_token_auth_instance = MagicMock()
mock_auth.Token.return_value = mock_token_auth_instance
# GitHub integration
mock_token_data = MagicMock()
@@ -281,7 +279,8 @@ class TestGithubV1CallbackProcessor:
mock_github_integration.assert_called_once_with(auth=mock_app_auth_instance)
mock_integration_instance.get_access_token.assert_called_once_with(12345)
mock_github.assert_called_once_with('test_access_token')
mock_auth.Token.assert_called_once_with('test_access_token')
mock_github.assert_called_once_with(auth=mock_token_auth_instance)
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_issue.assert_called_once_with(number=42)
mock_issue.create_comment.assert_called_once_with('Test summary from agent')
@@ -293,28 +292,25 @@ class TestGithubV1CallbackProcessor:
assert kwargs['headers']['X-Session-API-Key'] == 'test_api_key'
assert kwargs['json']['question'] == 'Please provide a summary'
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'test_client_id',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'test_private_key',
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
)
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
)
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
@patch('integrations.github.github_v1_callback_processor.GithubIntegration')
@patch('integrations.github.github_v1_callback_processor.Github')
async def test_successful_inline_pr_comment(
self,
mock_github,
mock_github_integration,
mock_get_prompt_template,
mock_get_summary_instruction,
mock_get_httpx_client,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
@@ -334,7 +330,7 @@ class TestGithubV1CallbackProcessor:
mock_sandbox_info,
)
mock_get_prompt_template.return_value = 'Please provide a summary'
mock_get_summary_instruction.return_value = 'Please provide a summary'
mock_token_data = MagicMock()
mock_token_data.token = 'test_access_token'
@@ -367,6 +363,7 @@ class TestGithubV1CallbackProcessor:
# Error paths
# ------------------------------------------------------------------ #
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
@@ -375,6 +372,7 @@ class TestGithubV1CallbackProcessor:
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_get_summary_instruction,
conversation_state_update_event,
event_callback,
mock_app_conversation_info,
@@ -393,6 +391,8 @@ class TestGithubV1CallbackProcessor:
mock_sandbox_info,
)
mock_get_summary_instruction.return_value = 'Please provide a summary'
result = await processor(
conversation_id=conversation_id,
callback=event_callback,
@@ -403,7 +403,15 @@ class TestGithubV1CallbackProcessor:
assert result.status == EventCallbackResultStatus.ERROR
assert 'Missing installation ID' in result.detail
@patch.dict(os.environ, {}, clear=True)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'',
)
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
@@ -412,6 +420,7 @@ class TestGithubV1CallbackProcessor:
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_get_summary_instruction,
github_callback_processor,
conversation_state_update_event,
event_callback,
@@ -428,6 +437,8 @@ class TestGithubV1CallbackProcessor:
mock_sandbox_info,
)
mock_get_summary_instruction.return_value = 'Please provide a summary'
result = await github_callback_processor(
conversation_id=conversation_id,
callback=event_callback,
@@ -438,12 +449,13 @@ class TestGithubV1CallbackProcessor:
assert result.status == EventCallbackResultStatus.ERROR
assert 'GitHub App credentials are not configured' in result.detail
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'test_client_id',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'test_private_key',
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@@ -489,22 +501,21 @@ class TestGithubV1CallbackProcessor:
assert result.status == EventCallbackResultStatus.ERROR
assert 'Sandbox not running' in result.detail
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'test_client_id',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'test_private_key',
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
)
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
async def test_agent_server_http_error(
self,
mock_get_prompt_template,
mock_get_summary_instruction,
mock_get_httpx_client,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
@@ -525,7 +536,7 @@ class TestGithubV1CallbackProcessor:
mock_sandbox_info,
)
mock_get_prompt_template.return_value = 'Please provide a summary'
mock_get_summary_instruction.return_value = 'Please provide a summary'
mock_httpx_client = mock_get_httpx_client.return_value.__aenter__.return_value
mock_response = MagicMock()
@@ -547,22 +558,21 @@ class TestGithubV1CallbackProcessor:
assert result.status == EventCallbackResultStatus.ERROR
assert 'Failed to send message to agent server' in result.detail
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
},
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'test_client_id',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'test_private_key',
)
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
)
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
async def test_agent_server_timeout(
self,
mock_get_prompt_template,
mock_get_summary_instruction,
mock_get_httpx_client,
mock_get_sandbox_service,
mock_get_app_conversation_info_service,
@@ -582,7 +592,7 @@ class TestGithubV1CallbackProcessor:
mock_sandbox_info,
)
mock_get_prompt_template.return_value = 'Please provide a summary'
mock_get_summary_instruction.return_value = 'Please provide a summary'
mock_httpx_client = mock_get_httpx_client.return_value.__aenter__.return_value
mock_httpx_client.post.side_effect = httpx.TimeoutException('Request timeout')
@@ -607,7 +617,14 @@ class TestGithubV1CallbackProcessor:
with pytest.raises(ValueError, match='Missing installation ID'):
processor._get_installation_access_token()
@patch.dict(os.environ, {}, clear=True)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'',
)
def test_get_installation_access_token_missing_credentials(
self, github_callback_processor
):
@@ -616,17 +633,16 @@ class TestGithubV1CallbackProcessor:
):
github_callback_processor._get_installation_access_token()
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key\\nwith_newlines',
},
)
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Auth')
@patch(
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'test_client_id',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'test_private_key\nwith_newlines',
)
@patch('integrations.github.github_v1_callback_processor.Auth')
@patch('integrations.github.github_v1_callback_processor.GithubIntegration')
def test_get_installation_access_token_success(
self, mock_github_integration, mock_auth, github_callback_processor
):
@@ -649,9 +665,10 @@ class TestGithubV1CallbackProcessor:
mock_github_integration.assert_called_once_with(auth=mock_app_auth_instance)
mock_integration_instance.get_access_token.assert_called_once_with(12345)
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
@patch('integrations.github.github_v1_callback_processor.Auth')
@patch('integrations.github.github_v1_callback_processor.Github')
async def test_post_summary_to_github_issue_comment(
self, mock_github, github_callback_processor
self, mock_github, mock_auth, github_callback_processor
):
mock_github_client = MagicMock()
mock_repo = MagicMock()
@@ -660,6 +677,9 @@ class TestGithubV1CallbackProcessor:
mock_github_client.get_repo.return_value = mock_repo
mock_github.return_value.__enter__.return_value = mock_github_client
mock_token_auth = MagicMock()
mock_auth.Token.return_value = mock_token_auth
with patch.object(
github_callback_processor,
'_get_installation_access_token',
@@ -667,14 +687,16 @@ class TestGithubV1CallbackProcessor:
):
await github_callback_processor._post_summary_to_github('Test summary')
mock_github.assert_called_once_with('test_token')
mock_auth.Token.assert_called_once_with('test_token')
mock_github.assert_called_once_with(auth=mock_token_auth)
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_issue.assert_called_once_with(number=42)
mock_issue.create_comment.assert_called_once_with('Test summary')
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
@patch('integrations.github.github_v1_callback_processor.Auth')
@patch('integrations.github.github_v1_callback_processor.Github')
async def test_post_summary_to_github_pr_comment(
self, mock_github, github_callback_processor_inline
self, mock_github, mock_auth, github_callback_processor_inline
):
mock_github_client = MagicMock()
mock_repo = MagicMock()
@@ -683,6 +705,9 @@ class TestGithubV1CallbackProcessor:
mock_github_client.get_repo.return_value = mock_repo
mock_github.return_value.__enter__.return_value = mock_github_client
mock_token_auth = MagicMock()
mock_auth.Token.return_value = mock_token_auth
with patch.object(
github_callback_processor_inline,
'_get_installation_access_token',
@@ -692,7 +717,8 @@ class TestGithubV1CallbackProcessor:
'Test summary'
)
mock_github.assert_called_once_with('test_token')
mock_auth.Token.assert_called_once_with('test_token')
mock_github.assert_called_once_with(auth=mock_token_auth)
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_pull.assert_called_once_with(42)
mock_pr.create_review_comment_reply.assert_called_once_with(
@@ -708,14 +734,15 @@ class TestGithubV1CallbackProcessor:
with pytest.raises(RuntimeError, match='Missing GitHub credentials'):
await github_callback_processor._post_summary_to_github('Test summary')
@patch.dict(
os.environ,
{
'GITHUB_APP_CLIENT_ID': 'test_client_id',
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
'WEB_HOST': 'test.example.com',
},
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
'test_client_id',
)
@patch(
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
'test_private_key',
)
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
@@ -724,6 +751,7 @@ class TestGithubV1CallbackProcessor:
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_get_summary_instruction,
github_callback_processor,
conversation_state_update_event,
event_callback,
@@ -741,13 +769,14 @@ class TestGithubV1CallbackProcessor:
mock_sandbox_info,
)
mock_httpx_client.post.side_effect = Exception('Simulated agent server error')
mock_get_summary_instruction.return_value = 'Please provide a summary'
with (
patch(
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
'integrations.github.github_v1_callback_processor.GithubIntegration'
) as mock_github_integration,
patch(
'openhands.app_server.event_callback.github_v1_callback_processor.Github'
'integrations.github.github_v1_callback_processor.Github'
) as mock_github,
):
mock_integration = MagicMock()

View File

@@ -0,0 +1,431 @@
"""Tests for the SlackV1CallbackProcessor.
Focuses on high-impact scenarios:
- Double callback processing (main requirement)
- Event filtering
- Error handling for critical failures
- Successful end-to-end flow
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
import pytest
from integrations.slack.slack_v1_callback_processor import (
SlackV1CallbackProcessor,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
from openhands.app_server.event_callback.event_callback_models import EventCallback
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResultStatus,
)
from openhands.app_server.sandbox.sandbox_models import (
ExposedUrl,
SandboxInfo,
SandboxStatus,
)
from openhands.events.action.message import MessageAction
from openhands.sdk.event import ConversationStateUpdateEvent
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def slack_callback_processor():
return SlackV1CallbackProcessor(
slack_view_data={
'channel_id': 'C1234567890',
'message_ts': '1234567890.123456',
'team_id': 'T1234567890',
}
)
@pytest.fixture
def finish_event():
return ConversationStateUpdateEvent(key='execution_status', value='finished')
@pytest.fixture
def event_callback():
return EventCallback(
id=uuid4(),
conversation_id=uuid4(),
processor=SlackV1CallbackProcessor(),
event_kind='ConversationStateUpdateEvent',
)
@pytest.fixture
def mock_app_conversation_info():
return AppConversationInfo(
id=uuid4(),
created_by_user_id='test-user-123',
sandbox_id=str(uuid4()),
title='Test Conversation',
)
@pytest.fixture
def mock_sandbox_info():
return SandboxInfo(
id=str(uuid4()),
created_by_user_id='test-user-123',
sandbox_spec_id='test-spec-123',
status=SandboxStatus.RUNNING,
session_api_key='test-session-key',
exposed_urls=[
ExposedUrl(
url='http://localhost:8000',
name='AGENT_SERVER',
port=8000,
)
],
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestSlackV1CallbackProcessor:
"""Test the SlackV1CallbackProcessor class with focus on high-impact scenarios."""
# -------------------------------------------------------------------------
# Event filtering tests (parameterized)
# -------------------------------------------------------------------------
@pytest.mark.parametrize(
'event,expected_result',
[
# Wrong event types should be ignored
(MessageAction(content='Hello world'), None),
# Wrong state values should be ignored
(
ConversationStateUpdateEvent(key='execution_status', value='running'),
None,
),
(
ConversationStateUpdateEvent(key='execution_status', value='started'),
None,
),
(ConversationStateUpdateEvent(key='other_key', value='finished'), None),
],
)
async def test_event_filtering(
self, slack_callback_processor, event_callback, event, expected_result
):
"""Test that processor correctly filters events."""
result = await slack_callback_processor(uuid4(), event_callback, event)
assert result == expected_result
# -------------------------------------------------------------------------
# Double callback processing (main requirement)
# -------------------------------------------------------------------------
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
@patch('integrations.slack.slack_v1_callback_processor.WebClient')
@patch.object(SlackV1CallbackProcessor, '_request_summary')
async def test_double_callback_processing(
self,
mock_request_summary,
mock_web_client,
mock_slack_team_store,
slack_callback_processor,
finish_event,
event_callback,
):
"""Test that processor handles double callback correctly and processes both times."""
conversation_id = uuid4()
# Mock SlackTeamStore
mock_store = MagicMock()
mock_store.get_team_bot_token.return_value = 'xoxb-test-token'
mock_slack_team_store.return_value = mock_store
# Mock successful summary generation
mock_request_summary.return_value = 'Test summary from agent'
# Mock Slack WebClient
mock_slack_client = MagicMock()
mock_slack_client.chat_postMessage.return_value = {'ok': True}
mock_web_client.return_value = mock_slack_client
# First callback
result1 = await slack_callback_processor(
conversation_id, event_callback, finish_event
)
# Second callback (should not exit, should process again)
result2 = await slack_callback_processor(
conversation_id, event_callback, finish_event
)
# Verify both callbacks succeeded
assert result1 is not None
assert result1.status == EventCallbackResultStatus.SUCCESS
assert result1.detail == 'Test summary from agent'
assert result2 is not None
assert result2.status == EventCallbackResultStatus.SUCCESS
assert result2.detail == 'Test summary from agent'
# Verify both callbacks triggered summary requests and Slack posts
assert mock_request_summary.call_count == 2
assert mock_slack_client.chat_postMessage.call_count == 2
# -------------------------------------------------------------------------
# Successful end-to-end flow
# -------------------------------------------------------------------------
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('integrations.slack.slack_v1_callback_processor.get_summary_instruction')
@patch('integrations.slack.slack_v1_callback_processor.WebClient')
async def test_successful_end_to_end_flow(
self,
mock_web_client,
mock_get_summary_instruction,
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_slack_team_store,
slack_callback_processor,
finish_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
):
"""Test successful end-to-end callback execution."""
conversation_id = uuid4()
# Mock SlackTeamStore
mock_store = MagicMock()
mock_store.get_team_bot_token.return_value = 'xoxb-test-token'
mock_slack_team_store.return_value = mock_store
# Mock summary instruction
mock_get_summary_instruction.return_value = 'Please provide a summary'
# Mock services
mock_app_conversation_info_service = AsyncMock()
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
mock_app_conversation_info
)
mock_get_app_conversation_info_service.return_value.__aenter__.return_value = (
mock_app_conversation_info_service
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox.return_value = mock_sandbox_info
mock_get_sandbox_service.return_value.__aenter__.return_value = (
mock_sandbox_service
)
mock_httpx_client = AsyncMock()
mock_response = MagicMock()
mock_response.json.return_value = {'response': 'Test summary from agent'}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
mock_get_httpx_client.return_value.__aenter__.return_value = mock_httpx_client
# Mock Slack WebClient
mock_slack_client = MagicMock()
mock_slack_client.chat_postMessage.return_value = {'ok': True}
mock_web_client.return_value = mock_slack_client
# Execute
result = await slack_callback_processor(
conversation_id, event_callback, finish_event
)
# Verify result
assert result is not None
assert result.status == EventCallbackResultStatus.SUCCESS
assert result.conversation_id == conversation_id
assert result.detail == 'Test summary from agent'
# Verify Slack posting
mock_slack_client.chat_postMessage.assert_called_once_with(
channel='C1234567890',
text='Test summary from agent',
thread_ts='1234567890.123456',
unfurl_links=False,
unfurl_media=False,
)
# -------------------------------------------------------------------------
# Error handling tests (parameterized)
# -------------------------------------------------------------------------
@pytest.mark.parametrize(
'bot_token,expected_error',
[
(None, 'Missing Slack bot access token'),
('', 'Missing Slack bot access token'),
],
)
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
@patch.object(SlackV1CallbackProcessor, '_request_summary')
async def test_missing_bot_token_scenarios(
self,
mock_request_summary,
mock_slack_team_store,
slack_callback_processor,
finish_event,
event_callback,
bot_token,
expected_error,
):
"""Test error handling when bot access token is missing or empty."""
# Mock SlackTeamStore to return the test token
mock_store = MagicMock()
mock_store.get_team_bot_token.return_value = bot_token
mock_slack_team_store.return_value = mock_store
# Mock successful summary generation
mock_request_summary.return_value = 'Test summary'
result = await slack_callback_processor(uuid4(), event_callback, finish_event)
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert expected_error in result.detail
@pytest.mark.parametrize(
'slack_response,expected_error',
[
(
{'ok': False, 'error': 'channel_not_found'},
'Slack API error: channel_not_found',
),
({'ok': False, 'error': 'invalid_auth'}, 'Slack API error: invalid_auth'),
({'ok': False}, 'Slack API error: Unknown error'),
],
)
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
@patch('integrations.slack.slack_v1_callback_processor.WebClient')
@patch.object(SlackV1CallbackProcessor, '_request_summary')
async def test_slack_api_error_scenarios(
self,
mock_request_summary,
mock_web_client,
mock_slack_team_store,
slack_callback_processor,
finish_event,
event_callback,
slack_response,
expected_error,
):
"""Test error handling for various Slack API errors."""
# Mock SlackTeamStore
mock_store = MagicMock()
mock_store.get_team_bot_token.return_value = 'xoxb-test-token'
mock_slack_team_store.return_value = mock_store
# Mock successful summary generation
mock_request_summary.return_value = 'Test summary'
# Mock Slack WebClient with error response
mock_slack_client = MagicMock()
mock_slack_client.chat_postMessage.return_value = slack_response
mock_web_client.return_value = mock_slack_client
result = await slack_callback_processor(uuid4(), event_callback, finish_event)
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert expected_error in result.detail
@pytest.mark.parametrize(
'exception,expected_error_fragment',
[
(
httpx.TimeoutException('Request timeout'),
'Request timeout after 30 seconds',
),
(
httpx.HTTPStatusError(
'Server error',
request=MagicMock(),
response=MagicMock(
status_code=500, text='Internal Server Error', headers={}
),
),
'Failed to send message to agent server',
),
(
httpx.RequestError('Connection error'),
'Request error',
),
],
)
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('integrations.slack.slack_v1_callback_processor.get_summary_instruction')
async def test_agent_server_error_scenarios(
self,
mock_get_summary_instruction,
mock_get_app_conversation_info_service,
mock_get_sandbox_service,
mock_get_httpx_client,
mock_slack_team_store,
slack_callback_processor,
finish_event,
event_callback,
mock_app_conversation_info,
mock_sandbox_info,
exception,
expected_error_fragment,
):
"""Test error handling for various agent server errors."""
conversation_id = uuid4()
# Mock SlackTeamStore
mock_store = MagicMock()
mock_store.get_team_bot_token.return_value = 'xoxb-test-token'
mock_slack_team_store.return_value = mock_store
# Mock summary instruction
mock_get_summary_instruction.return_value = 'Please provide a summary'
# Mock services
mock_app_conversation_info_service = AsyncMock()
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
mock_app_conversation_info
)
mock_get_app_conversation_info_service.return_value.__aenter__.return_value = (
mock_app_conversation_info_service
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox.return_value = mock_sandbox_info
mock_get_sandbox_service.return_value.__aenter__.return_value = (
mock_sandbox_service
)
# Mock HTTP client with the specified exception
mock_httpx_client = AsyncMock()
mock_httpx_client.post.side_effect = exception
mock_get_httpx_client.return_value.__aenter__.return_value = mock_httpx_client
# Execute
result = await slack_callback_processor(
conversation_id, event_callback, finish_event
)
# Verify error result
assert result is not None
assert result.status == EventCallbackResultStatus.ERROR
assert expected_error_fragment in result.detail

View File

@@ -0,0 +1,341 @@
"""Tests for the Slack view classes and their v1 vs v0 conversation handling.
Focuses on the 3 essential scenarios:
1. V1 vs V0 decision logic based on user setting
2. Message routing to correct method based on conversation v1 flag
3. Paused sandbox resumption for V1 conversations
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from integrations.slack.slack_view import (
SlackNewConversationView,
SlackUpdateExistingConversationView,
)
from jinja2 import DictLoader, Environment
from storage.slack_conversation import SlackConversation
from storage.slack_user import SlackUser
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.server.user_auth.user_auth import UserAuth
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_jinja_env():
"""Create a mock Jinja environment with test templates."""
templates = {
'user_message_conversation_instructions.j2': 'Previous messages: {{ messages|join(", ") }}\nUser: {{ username }}\nURL: {{ conversation_url }}'
}
return Environment(loader=DictLoader(templates))
@pytest.fixture
def mock_slack_user():
"""Create a mock SlackUser."""
user = SlackUser()
user.slack_user_id = 'U1234567890'
user.keycloak_user_id = 'test-user-123'
user.slack_display_name = 'Test User'
return user
@pytest.fixture
def mock_user_auth():
"""Create a mock UserAuth."""
auth = MagicMock(spec=UserAuth)
auth.get_provider_tokens = AsyncMock(return_value={})
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
return auth
@pytest.fixture
def slack_new_conversation_view(mock_slack_user, mock_user_auth):
"""Create a SlackNewConversationView instance."""
return SlackNewConversationView(
bot_access_token='xoxb-test-token',
user_msg='Hello OpenHands!',
slack_user_id='U1234567890',
slack_to_openhands_user=mock_slack_user,
saas_user_auth=mock_user_auth,
channel_id='C1234567890',
message_ts='1234567890.123456',
thread_ts=None,
selected_repo='owner/repo',
should_extract=True,
send_summary_instruction=True,
conversation_id='',
team_id='T1234567890',
v1_enabled=False,
)
@pytest.fixture
def slack_update_conversation_view_v0(mock_slack_user, mock_user_auth):
"""Create a SlackUpdateExistingConversationView instance for V0."""
conversation_id = '87654321-4321-8765-4321-876543218765'
mock_conversation = SlackConversation(
conversation_id=conversation_id,
channel_id='C1234567890',
keycloak_user_id='test-user-123',
parent_id='1234567890.123456',
v1_enabled=False,
)
return SlackUpdateExistingConversationView(
bot_access_token='xoxb-test-token',
user_msg='Follow up message',
slack_user_id='U1234567890',
slack_to_openhands_user=mock_slack_user,
saas_user_auth=mock_user_auth,
channel_id='C1234567890',
message_ts='1234567890.123457',
thread_ts='1234567890.123456',
selected_repo=None,
should_extract=True,
send_summary_instruction=True,
conversation_id=conversation_id,
slack_conversation=mock_conversation,
team_id='T1234567890',
v1_enabled=False,
)
@pytest.fixture
def slack_update_conversation_view_v1(mock_slack_user, mock_user_auth):
"""Create a SlackUpdateExistingConversationView instance for V1."""
conversation_id = '12345678-1234-5678-1234-567812345678'
mock_conversation = SlackConversation(
conversation_id=conversation_id,
channel_id='C1234567890',
keycloak_user_id='test-user-123',
parent_id='1234567890.123456',
v1_enabled=True,
)
return SlackUpdateExistingConversationView(
bot_access_token='xoxb-test-token',
user_msg='Follow up message',
slack_user_id='U1234567890',
slack_to_openhands_user=mock_slack_user,
saas_user_auth=mock_user_auth,
channel_id='C1234567890',
message_ts='1234567890.123457',
thread_ts='1234567890.123456',
selected_repo=None,
should_extract=True,
send_summary_instruction=True,
conversation_id=conversation_id,
slack_conversation=mock_conversation,
team_id='T1234567890',
v1_enabled=True,
)
# ---------------------------------------------------------------------------
# Test 1: V1 vs V0 Decision Logic Based on User Setting
# ---------------------------------------------------------------------------
class TestV1V0DecisionLogic:
"""Test the decision logic for choosing between V1 and V0 conversations based on user setting."""
@pytest.mark.parametrize(
'v1_enabled,expected_v1_flag',
[
(True, True), # V1 enabled, use V1
(False, False), # V1 disabled, use V0
],
)
@patch('integrations.slack.slack_view.is_v1_enabled_for_slack_resolver')
@patch.object(SlackNewConversationView, '_create_v1_conversation')
@patch.object(SlackNewConversationView, '_create_v0_conversation')
async def test_v1_v0_decision_logic(
self,
mock_create_v0,
mock_create_v1,
mock_is_v1_enabled,
slack_new_conversation_view,
mock_jinja_env,
v1_enabled,
expected_v1_flag,
):
"""Test the decision logic for V1 vs V0 conversation creation based on user setting."""
# Setup mocks
mock_is_v1_enabled.return_value = v1_enabled
mock_create_v1.return_value = None
mock_create_v0.return_value = None
# Execute
result = await slack_new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
# Verify
assert result == slack_new_conversation_view.conversation_id
assert slack_new_conversation_view.v1_enabled == expected_v1_flag
if v1_enabled:
mock_create_v1.assert_called_once()
mock_create_v0.assert_not_called()
else:
mock_create_v1.assert_not_called()
mock_create_v0.assert_called_once()
# ---------------------------------------------------------------------------
# Test 2: Message Routing Based on Conversation V1 Flag
# ---------------------------------------------------------------------------
class TestMessageRouting:
"""Test that message sending routes to correct method based on conversation v1 flag."""
@patch.object(
SlackUpdateExistingConversationView, 'send_message_to_v1_conversation'
)
@patch.object(
SlackUpdateExistingConversationView, 'send_message_to_v0_conversation'
)
async def test_message_routing_to_v1(
self,
mock_send_v0,
mock_send_v1,
slack_update_conversation_view_v1,
mock_jinja_env,
):
"""Test that V1 conversations route to V1 message sending method."""
# Setup
mock_send_v0.return_value = None
mock_send_v1.return_value = None
# Execute
result = await slack_update_conversation_view_v1.create_or_update_conversation(
mock_jinja_env
)
# Verify
assert result == slack_update_conversation_view_v1.conversation_id
mock_send_v1.assert_called_once_with(mock_jinja_env)
mock_send_v0.assert_not_called()
@patch.object(
SlackUpdateExistingConversationView, 'send_message_to_v1_conversation'
)
@patch.object(
SlackUpdateExistingConversationView, 'send_message_to_v0_conversation'
)
async def test_message_routing_to_v0(
self,
mock_send_v0,
mock_send_v1,
slack_update_conversation_view_v0,
mock_jinja_env,
):
"""Test that V0 conversations route to V0 message sending method."""
# Setup
mock_send_v0.return_value = None
mock_send_v1.return_value = None
# Execute
result = await slack_update_conversation_view_v0.create_or_update_conversation(
mock_jinja_env
)
# Verify
assert result == slack_update_conversation_view_v0.conversation_id
mock_send_v0.assert_called_once_with(mock_jinja_env)
mock_send_v1.assert_not_called()
# ---------------------------------------------------------------------------
# Test 3: Paused Sandbox Resumption for V1 Conversations
# ---------------------------------------------------------------------------
class TestPausedSandboxResumption:
"""Test that paused sandboxes are resumed when sending messages to V1 conversations."""
@patch('openhands.app_server.config.get_sandbox_service')
@patch('openhands.app_server.config.get_app_conversation_info_service')
@patch('openhands.app_server.config.get_httpx_client')
@patch('openhands.app_server.event_callback.util.ensure_running_sandbox')
@patch('openhands.app_server.event_callback.util.get_agent_server_url_from_sandbox')
@patch.object(SlackUpdateExistingConversationView, '_get_instructions')
async def test_paused_sandbox_resumption(
self,
mock_get_instructions,
mock_get_agent_server_url,
mock_ensure_running_sandbox,
mock_get_httpx_client,
mock_get_app_info_service,
mock_get_sandbox_service,
slack_update_conversation_view_v1,
mock_jinja_env,
):
"""Test that paused sandboxes are resumed when sending messages to V1 conversations."""
# Setup mocks
mock_get_instructions.return_value = ('User message', '')
# Mock app conversation info service
mock_app_info_service = AsyncMock()
mock_app_info = MagicMock()
mock_app_info.sandbox_id = 'sandbox-123'
mock_app_info_service.get_app_conversation_info.return_value = mock_app_info
mock_get_app_info_service.return_value.__aenter__.return_value = (
mock_app_info_service
)
# Mock sandbox service with paused sandbox that gets resumed
mock_sandbox_service = AsyncMock()
mock_paused_sandbox = MagicMock()
mock_paused_sandbox.status = SandboxStatus.PAUSED
mock_paused_sandbox.session_api_key = 'test-api-key'
mock_paused_sandbox.exposed_urls = [
MagicMock(name='AGENT_SERVER', url='http://localhost:8000')
]
# After resume, sandbox becomes running
mock_running_sandbox = MagicMock()
mock_running_sandbox.status = SandboxStatus.RUNNING
mock_running_sandbox.session_api_key = 'test-api-key'
mock_running_sandbox.exposed_urls = [
MagicMock(name='AGENT_SERVER', url='http://localhost:8000')
]
mock_sandbox_service.get_sandbox.side_effect = [
mock_paused_sandbox,
mock_running_sandbox,
]
mock_sandbox_service.resume_sandbox = AsyncMock()
mock_get_sandbox_service.return_value.__aenter__.return_value = (
mock_sandbox_service
)
# Mock ensure_running_sandbox to first raise RuntimeError, then return running sandbox
mock_ensure_running_sandbox.side_effect = [
RuntimeError('Sandbox not running: sandbox-123'),
mock_running_sandbox,
]
# Mock agent server URL
mock_get_agent_server_url.return_value = 'http://localhost:8000'
# Mock HTTP client
mock_httpx_client = AsyncMock()
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_httpx_client.post.return_value = mock_response
mock_get_httpx_client.return_value.__aenter__.return_value = mock_httpx_client
# Execute
await slack_update_conversation_view_v1.send_message_to_v1_conversation(
mock_jinja_env
)
# Verify sandbox was resumed
mock_sandbox_service.resume_sandbox.assert_called_once_with('sandbox-123')
mock_httpx_client.post.assert_called_once()
mock_response.raise_for_status.assert_called_once()

View File

@@ -84,7 +84,8 @@ async def test_verify_email_with_auth_flow(mock_request):
call_args = mock_keycloak_admin.a_send_verify_email.call_args
assert call_args.kwargs['user_id'] == user_id
assert (
call_args.kwargs['redirect_uri'] == 'http://localhost:8000?email_verified=true'
call_args.kwargs['redirect_uri']
== 'http://localhost:8000/login?email_verified=true'
)
assert 'client_id' in call_args.kwargs

View File

@@ -1,3 +1,5 @@
import base64
import json
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
@@ -8,6 +10,7 @@ from pydantic import SecretStr
from server.auth.auth_error import AuthError
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.auth import (
_extract_recaptcha_state,
authenticate,
keycloak_callback,
keycloak_offline_callback,
@@ -546,7 +549,6 @@ async def test_keycloak_callback_blocked_email_domain(mock_request):
)
mock_token_manager.disable_keycloak_user = AsyncMock()
mock_domain_blocker.is_active.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = True
# Act
@@ -600,7 +602,6 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_domain_blocker.is_active.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
mock_verifier.is_active.return_value = True
@@ -621,7 +622,7 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
@pytest.mark.asyncio
async def test_keycloak_callback_domain_blocking_inactive(mock_request):
"""Test keycloak_callback when domain blocking is not active."""
"""Test keycloak_callback when email domain is not blocked."""
# Arrange
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
@@ -654,7 +655,7 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_domain_blocker.is_active.return_value = False
mock_domain_blocker.is_domain_blocked.return_value = False
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -666,7 +667,7 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
# Assert
assert isinstance(result, RedirectResponse)
mock_domain_blocker.is_domain_blocked.assert_not_called()
mock_domain_blocker.is_domain_blocked.assert_called_once_with('user@colsch.us')
mock_token_manager.disable_keycloak_user.assert_not_called()
@@ -705,8 +706,6 @@ async def test_keycloak_callback_missing_email(mock_request):
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_domain_blocker.is_active.return_value = True
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -938,3 +937,764 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request):
assert result.status_code == 302
# Should not check for duplicate when email is missing
mock_token_manager.check_duplicate_base_email.assert_not_called()
class TestExtractRecaptchaState:
"""Tests for _extract_recaptcha_state() helper function."""
def test_should_extract_redirect_url_and_token_from_new_json_format(self):
"""Test extraction from new base64-encoded JSON format."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
# Act
redirect_url, token = _extract_recaptcha_state(encoded_state)
# Assert
assert redirect_url == 'https://example.com'
assert token == 'test-token'
def test_should_handle_old_format_plain_redirect_url(self):
"""Test handling of old format (plain redirect URL string)."""
# Arrange
state = 'https://example.com'
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == 'https://example.com'
assert token is None
def test_should_handle_none_state(self):
"""Test handling of None state."""
# Arrange
state = None
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == ''
assert token is None
def test_should_handle_invalid_base64_gracefully(self):
"""Test handling of invalid base64/JSON (fallback to old format)."""
# Arrange
state = 'not-valid-base64!!!'
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == state
assert token is None
def test_should_handle_missing_redirect_url_in_json(self):
"""Test handling when redirect_url is missing in JSON."""
# Arrange
state_data = {'recaptcha_token': 'test-token'}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
# Act
redirect_url, token = _extract_recaptcha_state(encoded_state)
# Assert
assert redirect_url == ''
assert token == 'test-token'
class TestKeycloakCallbackRecaptcha:
"""Tests for reCAPTCHA integration in keycloak_callback()."""
@pytest.mark.asyncio
async def test_should_verify_recaptcha_and_allow_login_when_score_is_high(
self, mock_request
):
"""Test that login proceeds when reCAPTCHA score is high."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
mock_assessment_result.score = 0.9
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
result = await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
mock_recaptcha_service.create_assessment.assert_called_once()
@pytest.mark.asyncio
async def test_should_block_login_when_recaptcha_score_is_low(self, mock_request):
"""Test that login is blocked and redirected when reCAPTCHA score is low."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = False
mock_assessment_result.score = 0.2
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
):
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
}
)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
result = await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
assert 'recaptcha_blocked=true' in result.headers['location']
@pytest.mark.asyncio
async def test_should_extract_ip_from_x_forwarded_for_header(self, mock_request):
"""Test that IP is extracted from X-Forwarded-For header when present."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_request.headers = {'X-Forwarded-For': '192.168.1.1, 10.0.0.1'}
mock_request.client = None
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
call_args = mock_recaptcha_service.create_assessment.call_args
assert call_args[1]['user_ip'] == '192.168.1.1'
@pytest.mark.asyncio
async def test_should_use_client_host_when_x_forwarded_for_missing(
self, mock_request
):
"""Test that client.host is used when X-Forwarded-For is missing."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_request.headers = {}
mock_request.client = MagicMock()
mock_request.client.host = '192.168.1.2'
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
call_args = mock_recaptcha_service.create_assessment.call_args
assert call_args[1]['user_ip'] == '192.168.1.2'
@pytest.mark.asyncio
async def test_should_use_unknown_ip_when_client_is_none(self, mock_request):
"""Test that 'unknown' IP is used when client is None."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_request.headers = {}
mock_request.client = None
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
call_args = mock_recaptcha_service.create_assessment.call_args
assert call_args[1]['user_ip'] == 'unknown'
@pytest.mark.asyncio
async def test_should_include_email_in_assessment_when_available(
self, mock_request
):
"""Test that email is included in assessment when available."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
call_args = mock_recaptcha_service.create_assessment.call_args
assert call_args[1]['email'] == 'user@example.com'
@pytest.mark.asyncio
async def test_should_skip_recaptcha_when_site_key_not_configured(
self, mock_request
):
"""Test that reCAPTCHA is skipped when RECAPTCHA_SITE_KEY is not configured."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', ''),
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
mock_recaptcha_service.create_assessment.assert_not_called()
@pytest.mark.asyncio
async def test_should_skip_recaptcha_when_token_is_missing(self, mock_request):
"""Test that reCAPTCHA is skipped when token is missing from state."""
# Arrange
state = 'https://example.com' # Old format without token
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Act
await keycloak_callback(code='test_code', state=state, request=mock_request)
# Assert
mock_recaptcha_service.create_assessment.assert_not_called()
@pytest.mark.asyncio
async def test_should_fail_open_when_recaptcha_service_throws_exception(
self, mock_request
):
"""Test that login proceeds (fail open) when reCAPTCHA service throws exception."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.auth.logger') as mock_logger,
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
mock_recaptcha_service.create_assessment.side_effect = Exception(
'Service error'
)
# Act
result = await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
assert isinstance(result, RedirectResponse)
# Check that reCAPTCHA error was logged (may be called multiple times due to other errors)
recaptcha_error_calls = [
call
for call in mock_logger.exception.call_args_list
if 'reCAPTCHA verification error' in str(call)
]
assert len(recaptcha_error_calls) > 0
@pytest.mark.asyncio
async def test_should_log_warning_when_recaptcha_blocks_user(self, mock_request):
"""Test that warning is logged when reCAPTCHA blocks user."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = False
mock_assessment_result.score = 0.2
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.logger') as mock_logger,
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
}
)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
mock_logger.warning.assert_called_once()
call_kwargs = mock_logger.warning.call_args
assert call_kwargs[0][0] == 'recaptcha_blocked_at_callback'
assert call_kwargs[1]['extra']['score'] == 0.2
assert call_kwargs[1]['extra']['user_id'] == 'test_user_id'

View File

@@ -1,33 +1,21 @@
"""Unit tests for DomainBlocker class."""
from unittest.mock import MagicMock
import pytest
from server.auth.domain_blocker import DomainBlocker
@pytest.fixture
def domain_blocker():
"""Create a DomainBlocker instance for testing."""
return DomainBlocker()
def mock_store():
"""Create a mock BlockedEmailDomainStore for testing."""
return MagicMock()
@pytest.mark.parametrize(
'blocked_domains,expected',
[
(['colsch.us', 'other-domain.com'], True),
(['example.com'], True),
([], False),
],
)
def test_is_active(domain_blocker, blocked_domains, expected):
"""Test that is_active returns correct value based on blocked domains configuration."""
# Arrange
domain_blocker.blocked_domains = blocked_domains
# Act
result = domain_blocker.is_active()
# Assert
assert result == expected
@pytest.fixture
def domain_blocker(mock_store):
"""Create a DomainBlocker instance for testing with a mocked store."""
return DomainBlocker(store=mock_store)
@pytest.mark.parametrize(
@@ -69,94 +57,104 @@ def test_extract_domain_invalid_emails(domain_blocker, email, expected):
assert result == expected
def test_is_domain_blocked_when_inactive(domain_blocker):
"""Test that is_domain_blocked returns False when blocking is not active."""
# Arrange
domain_blocker.blocked_domains = []
# Act
result = domain_blocker.is_domain_blocked('user@colsch.us')
# Assert
assert result is False
def test_is_domain_blocked_with_none_email(domain_blocker):
def test_is_domain_blocked_with_none_email(domain_blocker, mock_store):
"""Test that is_domain_blocked returns False when email is None."""
# Arrange
domain_blocker.blocked_domains = ['colsch.us']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked(None)
# Assert
assert result is False
mock_store.is_domain_blocked.assert_not_called()
def test_is_domain_blocked_with_empty_email(domain_blocker):
def test_is_domain_blocked_with_empty_email(domain_blocker, mock_store):
"""Test that is_domain_blocked returns False when email is empty."""
# Arrange
domain_blocker.blocked_domains = ['colsch.us']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('')
# Assert
assert result is False
mock_store.is_domain_blocked.assert_not_called()
def test_is_domain_blocked_with_invalid_email(domain_blocker):
def test_is_domain_blocked_with_invalid_email(domain_blocker, mock_store):
"""Test that is_domain_blocked returns False when email format is invalid."""
# Arrange
domain_blocker.blocked_domains = ['colsch.us']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('invalid-email')
# Assert
assert result is False
mock_store.is_domain_blocked.assert_not_called()
def test_is_domain_blocked_domain_not_blocked(domain_blocker):
"""Test that is_domain_blocked returns False when domain is not in blocked list."""
def test_is_domain_blocked_domain_not_blocked(domain_blocker, mock_store):
"""Test that is_domain_blocked returns False when domain is not blocked."""
# Arrange
domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com']
mock_store.is_domain_blocked.return_value = False
# Act
result = domain_blocker.is_domain_blocked('user@example.com')
# Assert
assert result is False
mock_store.is_domain_blocked.assert_called_once_with('example.com')
def test_is_domain_blocked_domain_blocked(domain_blocker):
"""Test that is_domain_blocked returns True when domain is in blocked list."""
def test_is_domain_blocked_domain_blocked(domain_blocker, mock_store):
"""Test that is_domain_blocked returns True when domain is blocked."""
# Arrange
domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('user@colsch.us')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('colsch.us')
def test_is_domain_blocked_case_insensitive(domain_blocker):
"""Test that is_domain_blocked performs case-insensitive domain matching."""
def test_is_domain_blocked_case_insensitive(domain_blocker, mock_store):
"""Test that is_domain_blocked performs case-insensitive domain extraction."""
# Arrange
domain_blocker.blocked_domains = ['colsch.us']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('user@COLSCH.US')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('colsch.us')
def test_is_domain_blocked_multiple_blocked_domains(domain_blocker):
"""Test that is_domain_blocked correctly checks against multiple blocked domains."""
def test_is_domain_blocked_with_whitespace(domain_blocker, mock_store):
"""Test that is_domain_blocked handles emails with whitespace correctly."""
# Arrange
domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com', 'blocked.org']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked(' user@colsch.us ')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('colsch.us')
def test_is_domain_blocked_multiple_blocked_domains(domain_blocker, mock_store):
"""Test that is_domain_blocked correctly checks multiple domains."""
# Arrange
mock_store.is_domain_blocked.side_effect = lambda domain: domain in [
'other-domain.com',
'blocked.org',
]
# Act
result1 = domain_blocker.is_domain_blocked('user@other-domain.com')
@@ -167,109 +165,71 @@ def test_is_domain_blocked_multiple_blocked_domains(domain_blocker):
assert result1 is True
assert result2 is True
assert result3 is False
assert mock_store.is_domain_blocked.call_count == 3
def test_is_domain_blocked_with_whitespace(domain_blocker):
"""Test that is_domain_blocked handles emails with whitespace correctly."""
# Arrange
domain_blocker.blocked_domains = ['colsch.us']
# Act
result = domain_blocker.is_domain_blocked(' user@colsch.us ')
# Assert
assert result is True
# ============================================================================
# TLD Blocking Tests (patterns starting with '.')
# ============================================================================
def test_is_domain_blocked_tld_pattern_blocks_matching_domain(domain_blocker):
def test_is_domain_blocked_tld_pattern_blocks_matching_domain(
domain_blocker, mock_store
):
"""Test that TLD pattern blocks domains ending with that TLD."""
# Arrange
domain_blocker.blocked_domains = ['.us']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('user@company.us')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('company.us')
def test_is_domain_blocked_tld_pattern_blocks_subdomain_with_tld(domain_blocker):
def test_is_domain_blocked_tld_pattern_blocks_subdomain_with_tld(
domain_blocker, mock_store
):
"""Test that TLD pattern blocks subdomains with that TLD."""
# Arrange
domain_blocker.blocked_domains = ['.us']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('user@subdomain.company.us')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('subdomain.company.us')
def test_is_domain_blocked_tld_pattern_does_not_block_different_tld(domain_blocker):
def test_is_domain_blocked_tld_pattern_does_not_block_different_tld(
domain_blocker, mock_store
):
"""Test that TLD pattern does not block domains with different TLD."""
# Arrange
domain_blocker.blocked_domains = ['.us']
mock_store.is_domain_blocked.return_value = False
# Act
result = domain_blocker.is_domain_blocked('user@company.com')
# Assert
assert result is False
mock_store.is_domain_blocked.assert_called_once_with('company.com')
def test_is_domain_blocked_tld_pattern_does_not_block_substring_match(
domain_blocker,
):
"""Test that TLD pattern does not block domains that contain but don't end with the TLD."""
# Arrange
domain_blocker.blocked_domains = ['.us']
# Act
result = domain_blocker.is_domain_blocked('user@focus.com')
# Assert
assert result is False
def test_is_domain_blocked_tld_pattern_case_insensitive(domain_blocker):
def test_is_domain_blocked_tld_pattern_case_insensitive(domain_blocker, mock_store):
"""Test that TLD pattern matching is case-insensitive."""
# Arrange
domain_blocker.blocked_domains = ['.us']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('user@COMPANY.US')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('company.us')
def test_is_domain_blocked_multiple_tld_patterns(domain_blocker):
"""Test blocking with multiple TLD patterns."""
# Arrange
domain_blocker.blocked_domains = ['.us', '.vn', '.com']
# Act
result_us = domain_blocker.is_domain_blocked('user@test.us')
result_vn = domain_blocker.is_domain_blocked('user@test.vn')
result_com = domain_blocker.is_domain_blocked('user@test.com')
result_org = domain_blocker.is_domain_blocked('user@test.org')
# Assert
assert result_us is True
assert result_vn is True
assert result_com is True
assert result_org is False
def test_is_domain_blocked_tld_pattern_with_multi_level_tld(domain_blocker):
def test_is_domain_blocked_tld_pattern_with_multi_level_tld(domain_blocker, mock_store):
"""Test that TLD pattern works with multi-level TLDs like .co.uk."""
# Arrange
domain_blocker.blocked_domains = ['.co.uk']
mock_store.is_domain_blocked.side_effect = lambda domain: domain.endswith('.co.uk')
# Act
result_match = domain_blocker.is_domain_blocked('user@example.co.uk')
@@ -282,81 +242,87 @@ def test_is_domain_blocked_tld_pattern_with_multi_level_tld(domain_blocker):
assert result_no_match is False
# ============================================================================
# Subdomain Blocking Tests (domain patterns now block subdomains)
# ============================================================================
def test_is_domain_blocked_domain_pattern_blocks_exact_match(domain_blocker):
def test_is_domain_blocked_domain_pattern_blocks_exact_match(
domain_blocker, mock_store
):
"""Test that domain pattern blocks exact domain match."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('user@example.com')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('example.com')
def test_is_domain_blocked_domain_pattern_blocks_subdomain(domain_blocker):
def test_is_domain_blocked_domain_pattern_blocks_subdomain(domain_blocker, mock_store):
"""Test that domain pattern blocks subdomains of that domain."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('user@subdomain.example.com')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('subdomain.example.com')
def test_is_domain_blocked_domain_pattern_blocks_multi_level_subdomain(
domain_blocker,
domain_blocker, mock_store
):
"""Test that domain pattern blocks multi-level subdomains."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked('user@api.v2.example.com')
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with('api.v2.example.com')
def test_is_domain_blocked_domain_pattern_does_not_block_similar_domain(
domain_blocker,
domain_blocker, mock_store
):
"""Test that domain pattern does not block domains that contain but don't match the pattern."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
mock_store.is_domain_blocked.return_value = False
# Act
result = domain_blocker.is_domain_blocked('user@notexample.com')
# Assert
assert result is False
mock_store.is_domain_blocked.assert_called_once_with('notexample.com')
def test_is_domain_blocked_domain_pattern_does_not_block_different_tld(
domain_blocker,
domain_blocker, mock_store
):
"""Test that domain pattern does not block same domain with different TLD."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
mock_store.is_domain_blocked.return_value = False
# Act
result = domain_blocker.is_domain_blocked('user@example.org')
# Assert
assert result is False
mock_store.is_domain_blocked.assert_called_once_with('example.org')
def test_is_domain_blocked_subdomain_pattern_blocks_exact_and_nested(domain_blocker):
def test_is_domain_blocked_subdomain_pattern_blocks_exact_and_nested(
domain_blocker, mock_store
):
"""Test that blocking a subdomain also blocks its nested subdomains."""
# Arrange
domain_blocker.blocked_domains = ['api.example.com']
mock_store.is_domain_blocked.side_effect = (
lambda domain: 'api.example.com' in domain
)
# Act
result_exact = domain_blocker.is_domain_blocked('user@api.example.com')
@@ -369,80 +335,10 @@ def test_is_domain_blocked_subdomain_pattern_blocks_exact_and_nested(domain_bloc
assert result_parent is False
# ============================================================================
# Mixed Pattern Tests (TLD + domain patterns together)
# ============================================================================
def test_is_domain_blocked_mixed_patterns_tld_and_domain(domain_blocker):
"""Test blocking with both TLD and domain patterns."""
# Arrange
domain_blocker.blocked_domains = ['.us', 'openhands.dev']
# Act
result_tld = domain_blocker.is_domain_blocked('user@company.us')
result_domain = domain_blocker.is_domain_blocked('user@openhands.dev')
result_subdomain = domain_blocker.is_domain_blocked('user@api.openhands.dev')
result_allowed = domain_blocker.is_domain_blocked('user@example.com')
# Assert
assert result_tld is True
assert result_domain is True
assert result_subdomain is True
assert result_allowed is False
def test_is_domain_blocked_overlapping_patterns(domain_blocker):
"""Test that overlapping patterns (TLD and specific domain) both work."""
# Arrange
domain_blocker.blocked_domains = ['.us', 'test.us']
# Act
result_specific = domain_blocker.is_domain_blocked('user@test.us')
result_other_us = domain_blocker.is_domain_blocked('user@other.us')
# Assert
assert result_specific is True
assert result_other_us is True
def test_is_domain_blocked_complex_multi_pattern_scenario(domain_blocker):
"""Test complex scenario with multiple TLD and domain patterns."""
# Arrange
domain_blocker.blocked_domains = [
'.us',
'.vn',
'test.com',
'openhands.dev',
]
# Act & Assert
# TLD patterns
assert domain_blocker.is_domain_blocked('user@anything.us') is True
assert domain_blocker.is_domain_blocked('user@company.vn') is True
# Domain patterns (exact)
assert domain_blocker.is_domain_blocked('user@test.com') is True
assert domain_blocker.is_domain_blocked('user@openhands.dev') is True
# Domain patterns (subdomains)
assert domain_blocker.is_domain_blocked('user@api.test.com') is True
assert domain_blocker.is_domain_blocked('user@staging.openhands.dev') is True
# Not blocked
assert domain_blocker.is_domain_blocked('user@allowed.com') is False
assert domain_blocker.is_domain_blocked('user@example.org') is False
# ============================================================================
# Edge Case Tests
# ============================================================================
def test_is_domain_blocked_domain_with_hyphens(domain_blocker):
def test_is_domain_blocked_domain_with_hyphens(domain_blocker, mock_store):
"""Test that domain patterns work with hyphenated domains."""
# Arrange
domain_blocker.blocked_domains = ['my-company.com']
mock_store.is_domain_blocked.return_value = True
# Act
result_exact = domain_blocker.is_domain_blocked('user@my-company.com')
@@ -451,12 +347,13 @@ def test_is_domain_blocked_domain_with_hyphens(domain_blocker):
# Assert
assert result_exact is True
assert result_subdomain is True
assert mock_store.is_domain_blocked.call_count == 2
def test_is_domain_blocked_domain_with_numbers(domain_blocker):
def test_is_domain_blocked_domain_with_numbers(domain_blocker, mock_store):
"""Test that domain patterns work with numeric domains."""
# Arrange
domain_blocker.blocked_domains = ['test123.com']
mock_store.is_domain_blocked.return_value = True
# Act
result_exact = domain_blocker.is_domain_blocked('user@test123.com')
@@ -465,24 +362,13 @@ def test_is_domain_blocked_domain_with_numbers(domain_blocker):
# Assert
assert result_exact is True
assert result_subdomain is True
assert mock_store.is_domain_blocked.call_count == 2
def test_is_domain_blocked_short_tld(domain_blocker):
"""Test that short TLD patterns work correctly."""
# Arrange
domain_blocker.blocked_domains = ['.io']
# Act
result = domain_blocker.is_domain_blocked('user@company.io')
# Assert
assert result is True
def test_is_domain_blocked_very_long_subdomain_chain(domain_blocker):
def test_is_domain_blocked_very_long_subdomain_chain(domain_blocker, mock_store):
"""Test that blocking works with very long subdomain chains."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
mock_store.is_domain_blocked.return_value = True
# Act
result = domain_blocker.is_domain_blocked(
@@ -491,3 +377,19 @@ def test_is_domain_blocked_very_long_subdomain_chain(domain_blocker):
# Assert
assert result is True
mock_store.is_domain_blocked.assert_called_once_with(
'level4.level3.level2.level1.example.com'
)
def test_is_domain_blocked_handles_store_exception(domain_blocker, mock_store):
"""Test that is_domain_blocked returns False when store raises an exception."""
# Arrange
mock_store.is_domain_blocked.side_effect = Exception('Database connection error')
# Act
result = domain_blocker.is_domain_blocked('user@example.com')
# Assert
assert result is False
mock_store.is_domain_blocked.assert_called_once_with('example.com')

View File

@@ -1,10 +1,9 @@
"""Unit tests for get_user_v1_enabled_setting function."""
import os
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from integrations.github.github_view import get_user_v1_enabled_setting
from integrations.utils import get_user_v1_enabled_setting
@pytest.fixture
@@ -16,10 +15,9 @@ def mock_user_settings():
@pytest.fixture
def mock_settings_store(mock_user_settings):
def mock_settings_store():
"""Create a mock settings store."""
store = MagicMock()
store.get_user_settings_by_keycloak_id = AsyncMock(return_value=mock_user_settings)
return store
@@ -40,15 +38,16 @@ def mock_dependencies(
mock_settings_store, mock_config, mock_session_maker, mock_user_settings
):
"""Fixture that patches all the common dependencies."""
# Patch at the source module since SaasSettingsStore is imported inside the function
with patch(
'integrations.github.github_view.SaasSettingsStore',
'storage.saas_settings_store.SaasSettingsStore',
return_value=mock_settings_store,
) as mock_store_class, patch(
'integrations.github.github_view.get_config', return_value=mock_config
'integrations.utils.get_config', return_value=mock_config
) as mock_get_config, patch(
'integrations.github.github_view.session_maker', mock_session_maker
'integrations.utils.session_maker', mock_session_maker
), patch(
'integrations.github.github_view.call_sync_from_async',
'integrations.utils.call_sync_from_async',
return_value=mock_user_settings,
) as mock_call_sync:
yield {
@@ -66,67 +65,64 @@ class TestGetUserV1EnabledSetting:
@pytest.mark.asyncio
@pytest.mark.parametrize(
'env_var_enabled,user_setting_enabled,expected_result',
'user_setting_enabled,expected_result',
[
(False, True, False), # Env var disabled, user enabled -> False
(True, False, False), # Env var enabled, user disabled -> False
(True, True, True), # Both enabled -> True
(False, False, False), # Both disabled -> False
(True, True), # User enabled -> True
(False, False), # User disabled -> False
],
)
async def test_v1_enabled_combinations(
self, mock_dependencies, env_var_enabled, user_setting_enabled, expected_result
async def test_v1_enabled_user_setting(
self, mock_dependencies, user_setting_enabled, expected_result
):
"""Test all combinations of environment variable and user setting values."""
"""Test that the function returns the user's v1_enabled setting."""
mock_dependencies['user_settings'].v1_enabled = user_setting_enabled
with patch(
'integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', env_var_enabled
):
result = await get_user_v1_enabled_setting('test_user_id')
assert result is expected_result
result = await get_user_v1_enabled_setting('test_user_id')
assert result is expected_result
@pytest.mark.asyncio
@pytest.mark.parametrize(
'env_var_value,env_var_bool,expected_result',
[
('false', False, False), # Environment variable 'false' -> False
('true', True, True), # Environment variable 'true' -> True
],
)
async def test_environment_variable_integration(
self, mock_dependencies, env_var_value, env_var_bool, expected_result
):
"""Test that the function properly reads the ENABLE_V1_GITHUB_RESOLVER environment variable."""
mock_dependencies['user_settings'].v1_enabled = True
async def test_returns_false_when_no_user_id(self):
"""Test that the function returns False when no user_id is provided."""
result = await get_user_v1_enabled_setting(None)
assert result is False
with patch.dict(
os.environ, {'ENABLE_V1_GITHUB_RESOLVER': env_var_value}
), patch('integrations.utils.os.getenv', return_value=env_var_value), patch(
'integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', env_var_bool
):
result = await get_user_v1_enabled_setting('test_user_id')
assert result is expected_result
result = await get_user_v1_enabled_setting('')
assert result is False
@pytest.mark.asyncio
async def test_returns_false_when_settings_is_none(self, mock_dependencies):
"""Test that the function returns False when settings is None."""
mock_dependencies['call_sync'].return_value = None
result = await get_user_v1_enabled_setting('test_user_id')
assert result is False
@pytest.mark.asyncio
async def test_returns_false_when_v1_enabled_is_none(self, mock_dependencies):
"""Test that the function returns False when v1_enabled is None."""
mock_dependencies['user_settings'].v1_enabled = None
result = await get_user_v1_enabled_setting('test_user_id')
assert result is False
@pytest.mark.asyncio
async def test_function_calls_correct_methods(self, mock_dependencies):
"""Test that the function calls the correct methods with correct parameters."""
mock_dependencies['user_settings'].v1_enabled = True
with patch('integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', True):
result = await get_user_v1_enabled_setting('test_user_123')
result = await get_user_v1_enabled_setting('test_user_123')
# Verify the result
assert result is True
# Verify the result
assert result is True
# Verify correct methods were called with correct parameters
mock_dependencies['get_config'].assert_called_once()
mock_dependencies['store_class'].assert_called_once_with(
user_id='test_user_123',
session_maker=mock_dependencies['session_maker'],
config=mock_dependencies['get_config'].return_value,
)
mock_dependencies['call_sync'].assert_called_once_with(
mock_dependencies['settings_store'].get_user_settings_by_keycloak_id,
'test_user_123',
)
# Verify correct methods were called with correct parameters
mock_dependencies['get_config'].assert_called_once()
mock_dependencies['store_class'].assert_called_once_with(
user_id='test_user_123',
session_maker=mock_dependencies['session_maker'],
config=mock_dependencies['get_config'].return_value,
)
mock_dependencies['call_sync'].assert_called_once_with(
mock_dependencies['settings_store'].get_user_settings_by_keycloak_id,
'test_user_123',
)

View File

@@ -86,12 +86,12 @@ class TestGithubV1ConversationRouting(TestCase):
def setUp(self):
"""Set up test fixtures."""
# Create a proper UserData instance instead of MagicMock
user_data = UserData(
self.user_data = UserData(
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
)
# Create a mock raw_payload
raw_payload = Message(
self.raw_payload = Message(
source=SourceType.GITHUB,
message={
'payload': {
@@ -101,8 +101,10 @@ class TestGithubV1ConversationRouting(TestCase):
},
)
self.github_issue = GithubIssue(
user_info=user_data,
def _create_github_issue(self):
"""Create a GithubIssue instance for testing."""
return GithubIssue(
user_info=self.user_data,
full_repo_name='test/repo',
issue_number=123,
installation_id=456,
@@ -110,35 +112,72 @@ class TestGithubV1ConversationRouting(TestCase):
should_extract=True,
send_summary_instruction=False,
is_public_repo=True,
raw_payload=raw_payload,
raw_payload=self.raw_payload,
uuid='test-uuid',
title='Test Issue',
description='Test issue description',
previous_comments=[],
v1=False,
v1_enabled=False,
)
@pytest.mark.asyncio
@patch('integrations.github.github_view.initialize_conversation')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_initialize_sets_v1_enabled_from_setting_when_false(
self, mock_get_v1_setting, mock_initialize_conversation
):
"""Test that initialize_new_conversation sets v1_enabled from get_user_v1_enabled_setting."""
mock_get_v1_setting.return_value = False
mock_initialize_conversation.return_value = MagicMock(
conversation_id='new-conversation-id'
)
github_issue = self._create_github_issue()
await github_issue.initialize_new_conversation()
# Verify get_user_v1_enabled_setting was called with correct user ID
mock_get_v1_setting.assert_called_once_with('test-keycloak-id')
# Verify v1_enabled was set to False
self.assertFalse(github_issue.v1_enabled)
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_initialize_sets_v1_enabled_from_setting_when_true(
self, mock_get_v1_setting
):
"""Test that initialize_new_conversation sets v1_enabled to True when setting returns True."""
mock_get_v1_setting.return_value = True
github_issue = self._create_github_issue()
await github_issue.initialize_new_conversation()
# Verify get_user_v1_enabled_setting was called with correct user ID
mock_get_v1_setting.assert_called_once_with('test-keycloak-id')
# Verify v1_enabled was set to True
self.assertTrue(github_issue.v1_enabled)
@pytest.mark.asyncio
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
async def test_create_new_conversation_routes_to_v0_when_disabled(
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
self, mock_create_v1, mock_create_v0
):
"""Test that conversation creation routes to V0 when v1_enabled is False."""
# Mock v1_enabled as False
mock_get_v1_setting.return_value = False
mock_create_v0.return_value = None
mock_create_v1.return_value = None
github_issue = self._create_github_issue()
github_issue.v1_enabled = False
# Mock parameters
jinja_env = MagicMock()
git_provider_tokens = MagicMock()
conversation_metadata = MagicMock()
saas_user_auth = MagicMock()
# Call the method
await self.github_issue.create_new_conversation(
jinja_env, git_provider_tokens, conversation_metadata
await github_issue.create_new_conversation(
jinja_env, git_provider_tokens, conversation_metadata, saas_user_auth
)
# Verify V0 was called and V1 was not
@@ -148,62 +187,31 @@ class TestGithubV1ConversationRouting(TestCase):
mock_create_v1.assert_not_called()
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
async def test_create_new_conversation_routes_to_v1_when_enabled(
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
self, mock_create_v1, mock_create_v0
):
"""Test that conversation creation routes to V1 when v1_enabled is True."""
# Mock v1_enabled as True
mock_get_v1_setting.return_value = True
mock_create_v0.return_value = None
mock_create_v1.return_value = None
github_issue = self._create_github_issue()
github_issue.v1_enabled = True
# Mock parameters
jinja_env = MagicMock()
git_provider_tokens = MagicMock()
conversation_metadata = MagicMock()
saas_user_auth = MagicMock()
# Call the method
await self.github_issue.create_new_conversation(
jinja_env, git_provider_tokens, conversation_metadata
await github_issue.create_new_conversation(
jinja_env, git_provider_tokens, conversation_metadata, saas_user_auth
)
# Verify V1 was called and V0 was not
mock_create_v1.assert_called_once_with(
jinja_env, git_provider_tokens, conversation_metadata
jinja_env, saas_user_auth, conversation_metadata
)
mock_create_v0.assert_not_called()
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
async def test_create_new_conversation_fallback_on_v1_setting_error(
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
):
"""Test that conversation creation falls back to V0 when _create_v1_conversation fails."""
# Mock v1_enabled as True so V1 is attempted
mock_get_v1_setting.return_value = True
# Mock _create_v1_conversation to raise an exception
mock_create_v1.side_effect = Exception('V1 conversation creation failed')
mock_create_v0.return_value = None
# Mock parameters
jinja_env = MagicMock()
git_provider_tokens = MagicMock()
conversation_metadata = MagicMock()
# Call the method
await self.github_issue.create_new_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
# Verify V1 was attempted first, then V0 was called as fallback
mock_create_v1.assert_called_once_with(
jinja_env, git_provider_tokens, conversation_metadata
)
mock_create_v0.assert_called_once_with(
jinja_env, git_provider_tokens, conversation_metadata
)

View File

@@ -0,0 +1,293 @@
"""Tests for RecaptchaService."""
import hashlib
import hmac
from unittest.mock import MagicMock, patch
import pytest
from server.auth.recaptcha_service import AssessmentResult, RecaptchaService
@pytest.fixture
def mock_gcp_client():
"""Mock GCP reCAPTCHA Enterprise client."""
with patch(
'server.auth.recaptcha_service.recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient'
) as mock_client_class:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
yield mock_client
@pytest.fixture
def recaptcha_service(mock_gcp_client):
"""Create RecaptchaService instance with mocked dependencies."""
with (
patch('server.auth.recaptcha_service.RECAPTCHA_PROJECT_ID', 'test-project'),
patch('server.auth.recaptcha_service.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.auth.recaptcha_service.RECAPTCHA_HMAC_SECRET', 'test-secret'),
patch('server.auth.recaptcha_service.RECAPTCHA_BLOCK_THRESHOLD', 0.3),
):
# Create new instance - constants are imported at module level, so we patch the imported names
return RecaptchaService()
class TestRecaptchaServiceHashAccountId:
"""Tests for RecaptchaService.hash_account_id()."""
def test_should_hash_email_with_hmac_sha256(self, recaptcha_service):
"""Test that hash_account_id produces correct HMAC-SHA256 hash."""
# Arrange
email = 'user@example.com'
# The service reads RECAPTCHA_HMAC_SECRET from the imported constants
# We need to verify it uses the constant correctly
from server.auth.recaptcha_service import RECAPTCHA_HMAC_SECRET
# Act
result = recaptcha_service.hash_account_id(email)
# Assert
# Verify the hash is correct using the actual secret from the patched constant
expected_hash = hmac.new(
RECAPTCHA_HMAC_SECRET.encode(),
email.lower().encode(),
hashlib.sha256,
).hexdigest()
assert result == expected_hash
assert len(result) == 64 # SHA256 produces 64 hex characters
def test_should_normalize_email_to_lowercase(self, recaptcha_service):
"""Test that hash_account_id normalizes email to lowercase."""
# Arrange
email1 = 'User@Example.com'
email2 = 'user@example.com'
# Act
hash1 = recaptcha_service.hash_account_id(email1)
hash2 = recaptcha_service.hash_account_id(email2)
# Assert
assert hash1 == hash2
def test_should_produce_different_hashes_for_different_emails(
self, recaptcha_service
):
"""Test that different emails produce different hashes."""
# Arrange
email1 = 'user1@example.com'
email2 = 'user2@example.com'
# Act
hash1 = recaptcha_service.hash_account_id(email1)
hash2 = recaptcha_service.hash_account_id(email2)
# Assert
assert hash1 != hash2
class TestRecaptchaServiceCreateAssessment:
"""Tests for RecaptchaService.create_assessment()."""
def test_should_create_assessment_and_allow_when_score_is_high(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment allows request when score is above threshold."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
result = recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
assert isinstance(result, AssessmentResult)
assert result.allowed is True
assert result.score == 0.9
assert result.valid is True
assert result.action_valid is True
mock_gcp_client.create_assessment.assert_called_once()
def test_should_block_when_score_is_below_threshold(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment blocks request when score is below threshold."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.2
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
result = recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
assert result.allowed is False
assert result.score == 0.2
def test_should_block_when_token_is_invalid(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment blocks request when token is invalid."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = False
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
result = recaptcha_service.create_assessment(
token='invalid-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
assert result.allowed is False
assert result.valid is False
def test_should_block_when_action_does_not_match(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment blocks request when action doesn't match."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'SIGNUP'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
result = recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
assert result.allowed is False
assert result.action_valid is False
def test_should_include_email_in_user_info_when_provided(
self, recaptcha_service, mock_gcp_client
):
"""Test that email is included in user_info when provided."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
email='user@example.com',
)
# Assert
call_args = mock_gcp_client.create_assessment.call_args
assessment = call_args[0][0].assessment
assert assessment.event.user_info is not None
assert assessment.event.user_info.account_id is not None
assert len(assessment.event.user_info.user_ids) == 1
assert assessment.event.user_info.user_ids[0].email == 'user@example.com'
def test_should_not_include_user_info_when_email_is_none(
self, recaptcha_service, mock_gcp_client
):
"""Test that user_info is not included when email is None."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
email=None,
)
# Assert
call_args = mock_gcp_client.create_assessment.call_args
assessment = call_args[0][0].assessment
# When email is None, user_info should not be set
# Check that user_info was not explicitly set (protobuf objects may have default empty values)
# The key is that account_id should not be set when email is None
if hasattr(assessment.event, 'user_info') and assessment.event.user_info:
# If user_info exists, verify account_id is empty (not set)
assert not assessment.event.user_info.account_id
def test_should_log_assessment_details(self, recaptcha_service, mock_gcp_client):
"""Test that assessment details are logged."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = ['AUTOMATION']
mock_gcp_client.create_assessment.return_value = mock_response
with patch('server.auth.recaptcha_service.logger') as mock_logger:
# Act
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
mock_logger.info.assert_called_once()
call_kwargs = mock_logger.info.call_args
assert call_kwargs[0][0] == 'recaptcha_assessment'
assert call_kwargs[1]['extra']['score'] == 0.9
assert call_kwargs[1]['extra']['valid'] is True
assert call_kwargs[1]['extra']['action_valid'] is True
assert call_kwargs[1]['extra']['user_ip'] == '192.168.1.1'
def test_should_raise_exception_when_gcp_client_fails(
self, recaptcha_service, mock_gcp_client
):
"""Test that exceptions from GCP client are propagated."""
# Arrange
mock_gcp_client.create_assessment.side_effect = Exception('GCP error')
# Act & Assert
with pytest.raises(Exception, match='GCP error'):
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)

View File

@@ -65,6 +65,15 @@ def mock_stripe():
yield
@pytest.fixture
def mock_redis_client():
"""Mock Redis client for testing create_default_settings locking."""
mock_redis = AsyncMock()
# By default, allow proceeding with create (lock acquired successfully)
mock_redis.set = AsyncMock(return_value=True)
return mock_redis
@pytest.fixture
def mock_github_user():
with patch(
@@ -200,7 +209,12 @@ async def test_store_and_load_keycloak_user(settings_store):
@pytest.mark.asyncio
async def test_load_returns_default_when_not_found(
settings_store, mock_litellm_api, mock_stripe, mock_github_user, session_maker
settings_store,
mock_litellm_api,
mock_stripe,
mock_github_user,
session_maker,
mock_redis_client,
):
file_store = MagicMock()
file_store.read.side_effect = FileNotFoundError()
@@ -211,6 +225,9 @@ async def test_load_returns_default_when_not_found(
MagicMock(return_value=file_store),
),
patch('storage.saas_settings_store.session_maker', session_maker),
patch.object(
settings_store, '_get_redis_client', return_value=mock_redis_client
),
):
loaded_settings = await settings_store.load()
assert loaded_settings is not None
@@ -263,7 +280,7 @@ async def test_create_default_settings_no_user_id():
@pytest.mark.asyncio
async def test_create_default_settings_require_payment_enabled(
settings_store, mock_stripe
settings_store, mock_stripe, mock_redis_client
):
# Mock stripe_service.has_payment_method to return False
with (
@@ -275,6 +292,9 @@ async def test_create_default_settings_require_payment_enabled(
patch(
'integrations.stripe_service.session_maker', settings_store.session_maker
),
patch.object(
settings_store, '_get_redis_client', return_value=mock_redis_client
),
):
settings = await settings_store.create_default_settings(None)
assert settings is None
@@ -282,7 +302,12 @@ async def test_create_default_settings_require_payment_enabled(
@pytest.mark.asyncio
async def test_create_default_settings_require_payment_disabled(
settings_store, mock_stripe, mock_github_user, mock_litellm_api, session_maker
settings_store,
mock_stripe,
mock_github_user,
mock_litellm_api,
session_maker,
mock_redis_client,
):
# Even without payment method, should get default settings when REQUIRE_PAYMENT is False
file_store = MagicMock()
@@ -298,12 +323,60 @@ async def test_create_default_settings_require_payment_disabled(
MagicMock(return_value=file_store),
),
patch('storage.saas_settings_store.session_maker', session_maker),
patch.object(
settings_store, '_get_redis_client', return_value=mock_redis_client
),
):
settings = await settings_store.create_default_settings(None)
assert settings is not None
assert settings.language == 'en'
@pytest.mark.asyncio
async def test_create_default_settings_waits_when_lock_held(
settings_store, mock_stripe, mock_github_user, mock_litellm_api, session_maker
):
"""Test that create_default_settings waits and retries when another process holds the lock."""
file_store = MagicMock()
file_store.read.side_effect = FileNotFoundError()
# Create a mock Redis client that fails to acquire lock on first attempt, succeeds on second
mock_redis = AsyncMock()
mock_redis.set = AsyncMock(side_effect=[False, True])
# Track if sleep was called
sleep_called = False
async def mock_sleep(delay):
nonlocal sleep_called
sleep_called = True
# Don't actually sleep - just verify it was called with correct delay
from storage.saas_settings_store import _RETRY_LOAD_DELAY_SECONDS
assert delay == _RETRY_LOAD_DELAY_SECONDS
with (
patch('storage.saas_settings_store.REQUIRE_PAYMENT', False),
patch(
'stripe.Customer.list_payment_methods_async',
AsyncMock(return_value=MagicMock(data=[])),
),
patch(
'storage.saas_settings_store.get_file_store',
MagicMock(return_value=file_store),
),
patch('storage.saas_settings_store.session_maker', session_maker),
patch.object(settings_store, '_get_redis_client', return_value=mock_redis),
patch('storage.saas_settings_store.asyncio.sleep', mock_sleep),
):
settings = await settings_store.create_default_settings(None)
# Should have called sleep while waiting for lock
assert sleep_called
# Should eventually succeed and return settings
assert settings is not None
assert settings.language == 'en'
@pytest.mark.asyncio
async def test_create_default_lite_llm_settings_no_api_config(settings_store):
with (
@@ -981,7 +1054,7 @@ async def test_has_custom_settings_matches_old_default_model(settings_store):
),
):
settings = Settings(
llm_model='litellm_proxy/prod/claude-3-5-sonnet-20241022',
llm_model='litellm_proxy/claude-3-5-sonnet-20241022',
llm_base_url='http://default.url',
)
@@ -1116,7 +1189,7 @@ async def test_update_settings_upgrades_user_from_old_defaults(
):
# Arrange: User with old version using old defaults
old_version = 1
old_model = 'litellm_proxy/prod/claude-3-5-sonnet-20241022'
old_model = 'litellm_proxy/claude-3-5-sonnet-20241022'
settings = Settings(llm_model=old_model, llm_base_url=LITE_LLM_API_URL)
# Use a consistent test URL
@@ -1137,7 +1210,7 @@ async def test_update_settings_upgrades_user_from_old_defaults(
patch('storage.saas_settings_store.LITE_LLM_API_URL', test_base_url),
patch(
'storage.saas_settings_store.get_default_litellm_model',
return_value='litellm_proxy/prod/claude-opus-4-5-20251101',
return_value='litellm_proxy/claude-opus-4-5-20251101',
),
patch(
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
@@ -1168,9 +1241,7 @@ async def test_update_settings_upgrades_user_from_old_defaults(
# Assert: Settings upgraded to new defaults
assert updated_settings is not None
assert (
updated_settings.llm_model == 'litellm_proxy/prod/claude-opus-4-5-20251101'
)
assert updated_settings.llm_model == 'litellm_proxy/claude-opus-4-5-20251101'
assert updated_settings.llm_base_url == test_base_url

View File

@@ -673,7 +673,6 @@ async def test_saas_user_auth_from_signed_token_blocked_domain(mock_config):
signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256')
with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker:
mock_domain_blocker.is_active.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = True
# Act & Assert
@@ -703,7 +702,6 @@ async def test_saas_user_auth_from_signed_token_allowed_domain(mock_config):
signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256')
with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker:
mock_domain_blocker.is_active.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Act
@@ -720,7 +718,7 @@ async def test_saas_user_auth_from_signed_token_allowed_domain(mock_config):
@pytest.mark.asyncio
async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_config):
"""Test that saas_user_auth_from_signed_token succeeds when domain blocking is not active."""
"""Test that saas_user_auth_from_signed_token succeeds when email domain is not blocked."""
# Arrange
access_payload = {
'sub': 'test_user_id',
@@ -737,7 +735,7 @@ async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_co
signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256')
with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker:
mock_domain_blocker.is_active.return_value = False
mock_domain_blocker.is_domain_blocked.return_value = False
# Act
result = await saas_user_auth_from_signed_token(signed_token)
@@ -745,4 +743,4 @@ async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_co
# Assert
assert isinstance(result, SaasUserAuth)
assert result.user_id == 'test_user_id'
mock_domain_blocker.is_domain_blocked.assert_not_called()
mock_domain_blocker.is_domain_blocked.assert_called_once_with('user@colsch.us')

View File

@@ -1,12 +1,14 @@
"""Tests for SharedEventService."""
import os
from datetime import UTC, datetime
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from server.sharing.filesystem_shared_event_service import (
SharedEventServiceImpl,
from server.sharing.google_cloud_shared_event_service import (
GoogleCloudSharedEventService,
GoogleCloudSharedEventServiceInjector,
)
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
@@ -25,18 +27,24 @@ def mock_shared_conversation_info_service():
return AsyncMock(spec=SharedConversationInfoService)
@pytest.fixture
def mock_bucket():
"""Create a mock GCS bucket."""
return MagicMock()
@pytest.fixture
def mock_event_service():
"""Create a mock EventService."""
"""Create a mock EventService for returned by get_event_service."""
return AsyncMock(spec=EventService)
@pytest.fixture
def shared_event_service(mock_shared_conversation_info_service, mock_event_service):
def shared_event_service(mock_shared_conversation_info_service, mock_bucket):
"""Create a SharedEventService for testing."""
return SharedEventServiceImpl(
return GoogleCloudSharedEventService(
shared_conversation_info_service=mock_shared_conversation_info_service,
event_service=mock_event_service,
bucket=mock_bucket,
)
@@ -79,11 +87,16 @@ class TestSharedEventService:
):
"""Test that get_shared_event returns an event for a public conversation."""
conversation_id = sample_public_conversation.id
event_id = 'test_event_id'
event_id = uuid4()
# Mock the public conversation service to return a public conversation
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
# Mock get_event_service to return our mock event service
shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return an event
mock_event_service.get_event.return_value = sample_event
@@ -92,10 +105,8 @@ class TestSharedEventService:
# Verify the result
assert result == sample_event
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
mock_event_service.get_event.assert_called_once_with(event_id)
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
mock_event_service.get_event.assert_called_once_with(conversation_id, event_id)
async def test_get_shared_event_returns_none_for_private_conversation(
self,
@@ -105,20 +116,18 @@ class TestSharedEventService:
):
"""Test that get_shared_event returns None for a private conversation."""
conversation_id = uuid4()
event_id = 'test_event_id'
event_id = uuid4()
# Mock the public conversation service to return None (private conversation)
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
# Mock get_event_service to return None (private conversation)
shared_event_service.get_event_service = AsyncMock(return_value=None)
# Call the method
result = await shared_event_service.get_shared_event(conversation_id, event_id)
# Verify the result
assert result is None
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
# Event service should not be called
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
# Event service should not be called since get_event_service returns None
mock_event_service.get_event.assert_not_called()
async def test_search_shared_events_returns_events_for_public_conversation(
@@ -132,8 +141,10 @@ class TestSharedEventService:
"""Test that search_shared_events returns events for a public conversation."""
conversation_id = sample_public_conversation.id
# Mock the public conversation service to return a public conversation
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
# Mock get_event_service to return our mock event service
shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return events
mock_event_page = EventPage(items=[], next_page_id=None)
@@ -150,11 +161,9 @@ class TestSharedEventService:
assert result == mock_event_page
assert len(result.items) == 0 # Empty list as we mocked
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
mock_event_service.search_events.assert_called_once_with(
conversation_id__eq=conversation_id,
conversation_id=conversation_id,
kind__eq='ActionEvent',
timestamp__gte=None,
timestamp__lt=None,
@@ -172,8 +181,8 @@ class TestSharedEventService:
"""Test that search_shared_events returns empty page for a private conversation."""
conversation_id = uuid4()
# Mock the public conversation service to return None (private conversation)
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
# Mock get_event_service to return None (private conversation)
shared_event_service.get_event_service = AsyncMock(return_value=None)
# Call the method
result = await shared_event_service.search_shared_events(
@@ -186,9 +195,7 @@ class TestSharedEventService:
assert len(result.items) == 0
assert result.next_page_id is None
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
# Event service should not be called
mock_event_service.search_events.assert_not_called()
@@ -202,8 +209,10 @@ class TestSharedEventService:
"""Test that count_shared_events returns count for a public conversation."""
conversation_id = sample_public_conversation.id
# Mock the public conversation service to return a public conversation
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
# Mock get_event_service to return our mock event service
shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return a count
mock_event_service.count_events.return_value = 5
@@ -217,15 +226,12 @@ class TestSharedEventService:
# Verify the result
assert result == 5
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
mock_event_service.count_events.assert_called_once_with(
conversation_id__eq=conversation_id,
conversation_id=conversation_id,
kind__eq='ActionEvent',
timestamp__gte=None,
timestamp__lt=None,
sort_order=EventSortOrder.TIMESTAMP,
)
async def test_count_shared_events_returns_zero_for_private_conversation(
@@ -237,8 +243,8 @@ class TestSharedEventService:
"""Test that count_shared_events returns 0 for a private conversation."""
conversation_id = uuid4()
# Mock the public conversation service to return None (private conversation)
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
# Mock get_event_service to return None (private conversation)
shared_event_service.get_event_service = AsyncMock(return_value=None)
# Call the method
result = await shared_event_service.count_shared_events(
@@ -248,9 +254,7 @@ class TestSharedEventService:
# Verify the result
assert result == 0
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
# Event service should not be called
mock_event_service.count_events.assert_not_called()
@@ -264,10 +268,12 @@ class TestSharedEventService:
):
"""Test that batch_get_shared_events returns events for a public conversation."""
conversation_id = sample_public_conversation.id
event_ids = ['event1', 'event2']
event_ids = [uuid4(), uuid4()]
# Mock the public conversation service to return a public conversation
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
# Mock get_event_service to return our mock event service
shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return events
mock_event_service.get_event.side_effect = [sample_event, None]
@@ -282,11 +288,8 @@ class TestSharedEventService:
assert result[0] == sample_event
assert result[1] is None
# Verify that get_shared_conversation_info was called for each event
assert (
mock_shared_conversation_info_service.get_shared_conversation_info.call_count
== 2
)
# Verify that get_event_service was called for each event
assert shared_event_service.get_event_service.call_count == 2
# Verify that get_event was called for each event
assert mock_event_service.get_event.call_count == 2
@@ -298,10 +301,10 @@ class TestSharedEventService:
):
"""Test that batch_get_shared_events returns None for a private conversation."""
conversation_id = uuid4()
event_ids = ['event1', 'event2']
event_ids = [uuid4(), uuid4()]
# Mock the public conversation service to return None (private conversation)
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
# Mock get_event_service to return None (private conversation)
shared_event_service.get_event_service = AsyncMock(return_value=None)
# Call the method
result = await shared_event_service.batch_get_shared_events(
@@ -313,11 +316,8 @@ class TestSharedEventService:
assert result[0] is None
assert result[1] is None
# Verify that get_shared_conversation_info was called for each event
assert (
mock_shared_conversation_info_service.get_shared_conversation_info.call_count
== 2
)
# Verify that get_event_service was called for each event
assert shared_event_service.get_event_service.call_count == 2
# Event service should not be called
mock_event_service.get_event.assert_not_called()
@@ -333,8 +333,10 @@ class TestSharedEventService:
timestamp_gte = datetime(2023, 1, 1, tzinfo=UTC)
timestamp_lt = datetime(2023, 12, 31, tzinfo=UTC)
# Mock the public conversation service to return a public conversation
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
# Mock get_event_service to return our mock event service
shared_event_service.get_event_service = AsyncMock(
return_value=mock_event_service
)
# Mock the event service to return events
mock_event_page = EventPage(items=[], next_page_id='next_page')
@@ -355,7 +357,7 @@ class TestSharedEventService:
assert result == mock_event_page
mock_event_service.search_events.assert_called_once_with(
conversation_id__eq=conversation_id,
conversation_id=conversation_id,
kind__eq='ObservationEvent',
timestamp__gte=timestamp_gte,
timestamp__lt=timestamp_lt,
@@ -363,3 +365,224 @@ class TestSharedEventService:
page_id='current_page',
limit=50,
)
class TestGoogleCloudSharedEventServiceGetEventService:
"""Test cases for GoogleCloudSharedEventService.get_event_service method."""
async def test_get_event_service_returns_event_service_for_shared_conversation(
self,
shared_event_service,
mock_shared_conversation_info_service,
sample_public_conversation,
):
"""Test that get_event_service returns an EventService for a shared conversation."""
conversation_id = sample_public_conversation.id
# Mock the shared conversation info service to return a shared conversation
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
# Call the method
result = await shared_event_service.get_event_service(conversation_id)
# Verify the result
assert result is not None
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
async def test_get_event_service_returns_none_for_non_shared_conversation(
self,
shared_event_service,
mock_shared_conversation_info_service,
):
"""Test that get_event_service returns None for a non-shared conversation."""
conversation_id = uuid4()
# Mock the shared conversation info service to return None
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
# Call the method
result = await shared_event_service.get_event_service(conversation_id)
# Verify the result
assert result is None
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
conversation_id
)
class TestGoogleCloudSharedEventServiceInjector:
"""Test cases for GoogleCloudSharedEventServiceInjector."""
def test_bucket_name_from_environment_variable(self):
"""Test that bucket_name is read from FILE_STORE_PATH environment variable."""
test_bucket_name = 'test-bucket-name'
with patch.dict(os.environ, {'FILE_STORE_PATH': test_bucket_name}):
# Create a new injector instance to pick up the environment variable
# Note: The class attribute is evaluated at class definition time,
# so we need to test that the attribute exists and can be overridden
injector = GoogleCloudSharedEventServiceInjector()
injector.bucket_name = os.environ.get('FILE_STORE_PATH')
assert injector.bucket_name == test_bucket_name
def test_bucket_name_default_value_when_env_not_set(self):
"""Test that bucket_name is None when FILE_STORE_PATH is not set."""
with patch.dict(os.environ, {}, clear=True):
# Remove FILE_STORE_PATH if it exists
os.environ.pop('FILE_STORE_PATH', None)
injector = GoogleCloudSharedEventServiceInjector()
# The bucket_name will be whatever was set at class definition time
# or None if FILE_STORE_PATH was not set when the class was defined
assert hasattr(injector, 'bucket_name')
async def test_injector_yields_google_cloud_shared_event_service(self):
"""Test that the injector yields a GoogleCloudSharedEventService instance."""
mock_state = MagicMock()
mock_request = MagicMock()
mock_db_session = AsyncMock()
# Create the injector
injector = GoogleCloudSharedEventServiceInjector()
injector.bucket_name = 'test-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock storage.Client and bucket
mock_storage_client = MagicMock()
mock_bucket = MagicMock()
mock_storage_client.bucket.return_value = mock_bucket
with (
patch(
'server.sharing.google_cloud_shared_event_service.storage.Client',
return_value=mock_storage_client,
),
patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
),
):
# Call the inject method
async for service in injector.inject(mock_state, mock_request):
# Verify the service is an instance of GoogleCloudSharedEventService
assert isinstance(service, GoogleCloudSharedEventService)
assert service.bucket == mock_bucket
# Verify the storage client was called with the correct bucket name
mock_storage_client.bucket.assert_called_once_with('test-bucket')
async def test_injector_uses_bucket_name_from_instance(self):
"""Test that the injector uses the bucket_name from the instance."""
mock_state = MagicMock()
mock_request = MagicMock()
mock_db_session = AsyncMock()
# Create the injector with a specific bucket name
injector = GoogleCloudSharedEventServiceInjector()
injector.bucket_name = 'my-custom-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock storage.Client and bucket
mock_storage_client = MagicMock()
mock_bucket = MagicMock()
mock_storage_client.bucket.return_value = mock_bucket
with (
patch(
'server.sharing.google_cloud_shared_event_service.storage.Client',
return_value=mock_storage_client,
),
patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
),
):
# Call the inject method
async for service in injector.inject(mock_state, mock_request):
pass
# Verify the storage client was called with the custom bucket name
mock_storage_client.bucket.assert_called_once_with('my-custom-bucket')
async def test_injector_creates_sql_shared_conversation_info_service(self):
"""Test that the injector creates SQLSharedConversationInfoService with db_session."""
mock_state = MagicMock()
mock_request = MagicMock()
mock_db_session = AsyncMock()
# Create the injector
injector = GoogleCloudSharedEventServiceInjector()
injector.bucket_name = 'test-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock storage.Client and bucket
mock_storage_client = MagicMock()
mock_bucket = MagicMock()
mock_storage_client.bucket.return_value = mock_bucket
with (
patch(
'server.sharing.google_cloud_shared_event_service.storage.Client',
return_value=mock_storage_client,
),
patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
),
patch(
'server.sharing.google_cloud_shared_event_service.SQLSharedConversationInfoService'
) as mock_sql_service_class,
):
mock_sql_service = MagicMock()
mock_sql_service_class.return_value = mock_sql_service
# Call the inject method
async for service in injector.inject(mock_state, mock_request):
# Verify the service has the correct shared_conversation_info_service
assert service.shared_conversation_info_service == mock_sql_service
# Verify SQLSharedConversationInfoService was created with db_session
mock_sql_service_class.assert_called_once_with(db_session=mock_db_session)
async def test_injector_works_without_request(self):
"""Test that the injector works when request is None."""
mock_state = MagicMock()
mock_db_session = AsyncMock()
# Create the injector
injector = GoogleCloudSharedEventServiceInjector()
injector.bucket_name = 'test-bucket'
# Mock the get_db_session context manager
mock_db_context = AsyncMock()
mock_db_context.__aenter__.return_value = mock_db_session
mock_db_context.__aexit__.return_value = None
# Mock storage.Client and bucket
mock_storage_client = MagicMock()
mock_bucket = MagicMock()
mock_storage_client.bucket.return_value = mock_bucket
with patch(
'server.sharing.google_cloud_shared_event_service.storage.Client',
return_value=mock_storage_client,
):
with patch(
'openhands.app_server.config.get_db_session',
return_value=mock_db_context,
):
# Call the inject method with request=None
async for service in injector.inject(mock_state, request=None):
assert isinstance(service, GoogleCloudSharedEventService)

View File

@@ -49,7 +49,7 @@ temperature = 0.0
### Configuring Condensers for Evaluation
For benchmarks that support condenser configuration (like SWE-Bench), you can define multiple condenser configurations in your `config.toml` file. A condenser is responsible for managing conversation history to maintain context while staying within token limits - you can learn more about how it works [here](https://www.all-hands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents):
For benchmarks that support condenser configuration (like SWE-Bench), you can define multiple condenser configurations in your `config.toml` file. A condenser is responsible for managing conversation history to maintain context while staying within token limits - you can learn more about how it works [here](https://www.openhands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents):
```toml
# LLM-based summarizing condenser for evaluation
@@ -143,7 +143,7 @@ You can start your own fork of [our huggingface evaluation outputs](https://hugg
## For Benchmark Developers
To learn more about how to integrate your benchmark into OpenHands, check out [tutorial here](https://docs.all-hands.dev/usage/how-to/evaluation-harness). Briefly,
To learn more about how to integrate your benchmark into OpenHands, check out [tutorial here](https://docs.openhands.dev/usage/how-to/evaluation-harness). Briefly,
- Each subfolder contains a specific benchmark or experiment. For example, [`evaluation/benchmarks/swe_bench`](./benchmarks/swe_bench) should contain
all the preprocessing/evaluation/analysis scripts.

View File

@@ -33,10 +33,10 @@ npm run dev:mock:saas
These commands set `VITE_MOCK_API=true` which activates the MSW Service Worker to intercept requests.
> [!NOTE]
> **OSS vs SaaS Mode**
> **OpenHands vs SaaS Mode**
>
> OpenHands runs in two modes:
> - **OSS mode**: For local/self-hosted deployments where users provide their own LLM API keys and configure git providers manually
> - **OpenHands mode**: For local/self-hosted deployments where users provide their own LLM API keys and configure git providers manually
> - **SaaS mode**: For the cloud offering with billing, managed API keys, and OAuth-based GitHub integration
>
> Use `dev:mock:saas` when working on SaaS-specific features like billing, API key management, or subscription flows.

View File

@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import ChatStatusIndicator from "#/components/features/chat/chat-status-indicator";
vi.mock("#/icons/debug-stackframe-dot.svg?react", () => ({
default: (props: any) => (
<svg data-testid="debug-stackframe-dot" {...props} />
),
}));
describe("ChatStatusIndicator", () => {
it("renders the status indicator with status text", () => {
render(
<ChatStatusIndicator
status="Waiting for sandbox"
statusColor="#FFD600"
/>
);
expect(
screen.getByTestId("chat-status-indicator"),
).toBeInTheDocument();
expect(screen.getByText("Waiting for sandbox")).toBeInTheDocument();
});
it("passes the statusColor to the DebugStackframeDot icon", () => {
render(
<ChatStatusIndicator
status="Error"
statusColor="#FF684E"
/>
);
const icon = screen.getByTestId("debug-stackframe-dot");
expect(icon).toHaveAttribute("color", "#FF684E");
});
it("renders the DebugStackframeDot icon", () => {
render(
<ChatStatusIndicator
status="Loading"
statusColor="#FFD600"
/>
);
expect(screen.getByTestId("debug-stackframe-dot")).toBeInTheDocument();
});
});

View File

@@ -10,13 +10,14 @@ import {
} from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { MemoryRouter, Route, Routes } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderWithProviders } from "test-utils";
import { renderWithProviders, useParamsMock } from "test-utils";
import type { Message } from "#/message";
import { SUGGESTIONS } from "#/utils/suggestions";
import { ChatInterface } from "#/components/features/chat/chat-interface";
import { useWsClient } from "#/context/ws-client-provider";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useConfig } from "#/hooks/query/use-config";
@@ -24,24 +25,15 @@ import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { OpenHandsAction } from "#/types/core/actions";
import { useEventStore } from "#/stores/use-event-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
vi.mock("#/context/ws-client-provider");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-unified-upload-files");
vi.mock("#/hooks/use-conversation-id");
// Mock React Router hooks at the top level
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
useRouteLoaderData: vi.fn(() => ({})),
};
});
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
@@ -59,6 +51,12 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
}),
}));
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(() => ({
curAgentState: AgentState.AWAITING_USER_INPUT,
})),
}));
// Helper function to render with Router context
const renderChatInterfaceWithRouter = () =>
renderWithProviders(
@@ -79,13 +77,26 @@ const renderChatInterface = (messages: Message[]) =>
const renderWithQueryClient = (
ui: React.ReactElement,
queryClient: QueryClient,
route = "/test-conversation-id",
) =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/:conversationId" element={ui} />
<Route path="/" element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
beforeEach(() => {
useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" });
vi.mocked(useConversationId).mockReturnValue({
conversationId: "test-conversation-id",
});
});
describe("ChatInterface - Chat Suggestions", () => {
// Create a new QueryClient for each test
let queryClient: QueryClient;
@@ -121,7 +132,9 @@ describe("ChatInterface - Chat Suggestions", () => {
mutateAsync: vi.fn(),
isLoading: false,
});
(useUnifiedUploadFiles 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: [] }),
@@ -252,7 +265,9 @@ describe("ChatInterface - Empty state", () => {
mutateAsync: vi.fn(),
isLoading: false,
});
(useUnifiedUploadFiles 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: [] }),
@@ -344,6 +359,28 @@ describe("ChatInterface - Empty state", () => {
);
});
describe('ChatInterface - Status Indicator', () => {
it("should render ChatStatusIndicator when agent is not awaiting user input / conversation is NOT ready", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.LOADING,
});
renderChatInterfaceWithRouter();
expect(screen.getByTestId("chat-status-indicator")).toBeInTheDocument();
});
it("should NOT render ChatStatusIndicator when agent is awaiting user input / conversation is ready", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.AWAITING_USER_INPUT,
});
renderChatInterfaceWithRouter();
expect(screen.queryByTestId("chat-status-indicator")).not.toBeInTheDocument();
});
});
describe.skip("ChatInterface - General functionality", () => {
beforeAll(() => {
// mock useScrollToBottom hook
@@ -605,3 +642,43 @@ describe.skip("ChatInterface - General functionality", () => {
expect(screen.getByTestId("feedback-actions")).toBeInTheDocument();
});
});
describe("ChatInterface skeleton loading state", () => {
test("renders chat message skeleton when loading existing conversation", () => {
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: true,
parsedEvents: [],
});
renderWithQueryClient(<ChatInterface />, new QueryClient());
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
});
});
test("does not render skeleton for new conversation (shows spinner instead)", () => {
useParamsMock.mockReturnValue({ conversationId: undefined } as unknown as {
conversationId: string;
});
(useConversationId as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
conversationId: "",
});
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: true,
parsedEvents: [],
});
renderWithQueryClient(<ChatInterface />, new QueryClient(), "/");
expect(screen.getAllByTestId("loading-spinner").length).toBeGreaterThan(0);
expect(
screen.queryByTestId("chat-messages-skeleton"),
).not.toBeInTheDocument();
});

View File

@@ -1,217 +0,0 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { AuthModal } from "#/components/features/waitlist/auth-modal";
// Mock the useAuthUrl hook
vi.mock("#/hooks/use-auth-url", () => ({
useAuthUrl: () => "https://gitlab.com/oauth/authorize",
}));
// Mock the useTracking hook
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackLoginButtonClick: vi.fn(),
}),
}));
describe("AuthModal", () => {
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetAllMocks();
});
it("should render the GitHub and GitLab buttons", () => {
render(
<MemoryRouter>
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github", "gitlab"]}
/>
</MemoryRouter>,
);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
const gitlabButton = screen.getByRole("button", {
name: "GITLAB$CONNECT_TO_GITLAB",
});
expect(githubButton).toBeInTheDocument();
expect(gitlabButton).toBeInTheDocument();
});
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://github.com/login/oauth/authorize";
render(
<MemoryRouter>
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
expect(window.location.href).toBe(mockUrl);
});
it("should render Terms of Service and Privacy Policy text with correct links", () => {
render(
<MemoryRouter>
<AuthModal githubAuthUrl="mock-url" appMode="saas" />
</MemoryRouter>,
);
// Find the terms of service section using data-testid
const termsSection = screen.getByTestId("terms-and-privacy-notice");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
);
expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE");
expect(termsSection).toHaveTextContent("COMMON$AND");
expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY");
// Check Terms of Service link
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toBeInTheDocument();
expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos");
expect(tosLink).toHaveAttribute("target", "_blank");
expect(tosLink).toHaveClass("underline", "hover:text-primary");
// Check Privacy Policy link
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toBeInTheDocument();
expect(privacyLink).toHaveAttribute(
"href",
"https://www.all-hands.dev/privacy",
);
expect(privacyLink).toHaveAttribute("target", "_blank");
expect(privacyLink).toHaveClass("underline", "hover:text-primary");
// Verify that both links are within the terms section
expect(termsSection).toContainElement(tosLink);
expect(termsSection).toContainElement(privacyLink);
});
it("should display email verified message when emailVerified prop is true", () => {
render(
<MemoryRouter>
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
emailVerified={true}
/>
</MemoryRouter>,
);
expect(
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
).toBeInTheDocument();
});
it("should not display email verified message when emailVerified prop is false", () => {
render(
<MemoryRouter>
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
emailVerified={false}
/>
</MemoryRouter>,
);
expect(
screen.queryByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
).not.toBeInTheDocument();
});
it("should open Terms of Service link in new tab", () => {
render(
<MemoryRouter>
<AuthModal githubAuthUrl="mock-url" appMode="saas" />
</MemoryRouter>,
);
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toHaveAttribute("target", "_blank");
});
it("should open Privacy Policy link in new tab", () => {
render(
<MemoryRouter>
<AuthModal githubAuthUrl="mock-url" appMode="saas" />
</MemoryRouter>,
);
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toHaveAttribute("target", "_blank");
});
describe("Duplicate email error message", () => {
const renderAuthModalWithRouter = (initialEntries: string[]) => {
const hasDuplicatedEmail = initialEntries.includes(
"/?duplicated_email=true",
);
return render(
<MemoryRouter initialEntries={initialEntries}>
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github"]}
hasDuplicatedEmail={hasDuplicatedEmail}
/>
</MemoryRouter>,
);
};
it("should display error message when duplicated_email query parameter is true", () => {
// Arrange
const initialEntries = ["/?duplicated_email=true"];
// Act
renderAuthModalWithRouter(initialEntries);
// Assert
const errorMessage = screen.getByText("AUTH$DUPLICATE_EMAIL_ERROR");
expect(errorMessage).toBeInTheDocument();
});
it("should not display error message when duplicated_email query parameter is missing", () => {
// Arrange
const initialEntries = ["/"];
// Act
renderAuthModalWithRouter(initialEntries);
// Assert
const errorMessage = screen.queryByText("AUTH$DUPLICATE_EMAIL_ERROR");
expect(errorMessage).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,204 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { LoginContent } from "#/components/features/auth/login-content";
vi.mock("#/hooks/use-auth-url", () => ({
useAuthUrl: (config: {
identityProvider: string;
appMode: string | null;
authUrl?: string;
}) => {
const urls: Record<string, string> = {
gitlab: "https://gitlab.com/oauth/authorize",
bitbucket: "https://bitbucket.org/site/oauth2/authorize",
};
if (config.appMode === "saas") {
return urls[config.identityProvider] || null;
}
return null;
},
}));
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackLoginButtonClick: vi.fn(),
}),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: undefined,
}),
}));
vi.mock("#/hooks/use-recaptcha", () => ({
useRecaptcha: () => ({
isReady: false,
isLoading: false,
error: null,
executeRecaptcha: vi.fn().mockResolvedValue(null),
}),
}));
vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: vi.fn(),
}));
describe("LoginContent", () => {
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("should render login content with heading", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github", "gitlab", "bitbucket"]}
/>
</MemoryRouter>,
);
expect(screen.getByTestId("login-content")).toBeInTheDocument();
expect(screen.getByText("AUTH$LETS_GET_STARTED")).toBeInTheDocument();
});
it("should display all configured provider buttons", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
authUrl="https://auth.example.com"
providersConfigured={["github", "gitlab", "bitbucket"]}
/>
</MemoryRouter>,
);
expect(
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
).toBeInTheDocument();
const bitbucketButton = screen.getByRole("button", {
name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i,
});
expect(bitbucketButton).toBeInTheDocument();
});
it("should only display configured providers", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
).not.toBeInTheDocument();
});
it("should display message when no providers are configured", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={[]}
/>
</MemoryRouter>,
);
expect(
screen.getByText("AUTH$NO_PROVIDERS_CONFIGURED"),
).toBeInTheDocument();
});
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://github.com/login/oauth/authorize";
render(
<MemoryRouter>
<LoginContent
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
// Wait for async handleAuthRedirect to complete
await waitFor(() => {
expect(window.location.href).toBe(mockUrl);
});
});
it("should display email verified message when emailVerified is true", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
emailVerified
/>
</MemoryRouter>,
);
expect(
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
).toBeInTheDocument();
});
it("should display duplicate email error when hasDuplicatedEmail is true", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
hasDuplicatedEmail
/>
</MemoryRouter>,
);
expect(screen.getByText("AUTH$DUPLICATE_EMAIL_ERROR")).toBeInTheDocument();
});
it("should display Terms and Privacy notice", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,551 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { ToolItem } from "#/components/features/conversation-panel/system-message-modal/tool-item";
import type { ChatCompletionToolParam } from "#/types/v1/core";
describe("ToolItem", () => {
const user = userEvent.setup();
const onToggleMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
describe("Name/Title Extraction", () => {
it("should display name from V0 format function.name", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Test description",
parameters: {},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("test_function");
});
it("should display title from V1 format root level", () => {
// Arrange
const v1Tool = {
title: "V1 Tool Title",
description: "V1 description",
parameters: { type: "object" },
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("V1 Tool Title");
});
it("should prioritize root title over annotations.title in V1 format", () => {
// Arrange
const v1Tool = {
title: "Root Title",
annotations: {
title: "Annotations Title",
},
description: "Description",
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("Root Title");
});
it("should fallback to annotations.title when root title is missing", () => {
// Arrange
const v1Tool = {
annotations: {
title: "Annotations Title",
},
description: "Description",
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("Annotations Title");
});
it("should display empty string when no name or title is available", () => {
// Arrange
const toolWithoutName = {
description: "Description only",
};
// Act
renderWithProviders(
<ToolItem
tool={toolWithoutName}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("");
});
});
describe("Description Extraction", () => {
it("should display description from V0 format function.description when expanded", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "V0 function description",
parameters: {},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const markdownRenderer = screen.getByTestId("markdown-renderer");
expect(markdownRenderer).toHaveTextContent("V0 function description");
});
it("should display description from V1 format root level when expanded", () => {
// Arrange
const v1Tool = {
title: "V1 Tool",
description: "V1 root description",
parameters: {},
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const markdownRenderer = screen.getByTestId("markdown-renderer");
expect(markdownRenderer).toHaveTextContent("V1 root description");
});
it("should prioritize root description over function.description in V1 format", () => {
// Arrange
const v1Tool = {
title: "V1 Tool",
description: "Root description",
function: {
description: "Function description",
},
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const markdownRenderer = screen.getByTestId("markdown-renderer");
expect(markdownRenderer).toHaveTextContent("Root description");
});
it("should display empty string when no description is available", () => {
// Arrange
const toolWithoutDescription = {
title: "Tool Name",
};
// Act
renderWithProviders(
<ToolItem
tool={toolWithoutDescription}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const markdownRenderer = screen.getByTestId("markdown-renderer");
expect(markdownRenderer).toHaveTextContent("");
});
it("should not display description when collapsed", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Should not be visible",
parameters: {},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument();
});
});
describe("Parameters Extraction", () => {
it("should display parameters from V0 format function.parameters when expanded", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Description",
parameters: {
type: "object",
properties: {
param1: { type: "string" },
},
},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const toolParameters = screen.getByTestId("tool-parameters");
expect(toolParameters).toBeInTheDocument();
// Verify that the parameters are rendered (ReactJsonView will render the JSON)
expect(toolParameters).toHaveTextContent("param1");
});
it("should display parameters from V1 format root level when expanded", () => {
// Arrange
const v1Tool = {
title: "V1 Tool",
description: "Description",
parameters: {
type: "object",
properties: {
param2: { type: "number" },
},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const toolParameters = screen.getByTestId("tool-parameters");
expect(toolParameters).toBeInTheDocument();
// Verify that the parameters are rendered (ReactJsonView will render the JSON)
expect(toolParameters).toHaveTextContent("param2");
});
it("should prioritize function.parameters over root parameters in V0 format", () => {
// Arrange
const v0Tool = {
type: "function",
function: {
name: "test_function",
description: "Description",
parameters: {
type: "object",
source: "function",
},
},
parameters: {
type: "object",
source: "root",
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const toolParameters = screen.getByTestId("tool-parameters");
expect(toolParameters).toBeInTheDocument();
// Verify that function parameters are used (not root parameters)
expect(toolParameters).toHaveTextContent("function");
expect(toolParameters).not.toHaveTextContent("root");
});
it("should not display parameters when they are null", () => {
// Arrange
const toolWithoutParameters = {
title: "Tool Name",
description: "Description",
};
// Act
renderWithProviders(
<ToolItem
tool={toolWithoutParameters}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
});
it("should not display parameters when collapsed", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Description",
parameters: {
type: "object",
},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
});
});
describe("Toggle Functionality", () => {
it("should call onToggle with correct index when toggle button is clicked", async () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Description",
parameters: {},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={2}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
const toggleButton = screen.getByTestId("toggle-button");
await user.click(toggleButton);
// Assert
expect(onToggleMock).toHaveBeenCalledOnce();
expect(onToggleMock).toHaveBeenCalledWith(2);
});
it("should show expanded content when isExpanded is true", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Test description",
parameters: { type: "object" },
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.getByTestId("markdown-renderer")).toBeInTheDocument();
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
});
it("should hide expanded content when isExpanded is false", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Test description",
parameters: { type: "object" },
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument();
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
});
});
describe("Complex Scenarios", () => {
it("should handle V0 format with type field correctly", () => {
// Arrange
const v0Tool = {
type: "function",
function: {
name: "typed_function",
description: "Typed description",
parameters: { type: "object" },
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.getByTestId("toggle-button")).toHaveTextContent(
"typed_function",
);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(
"Typed description",
);
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
});
it("should handle tool data where function is at root level (fallback behavior)", () => {
// Arrange
const toolWithFunctionAtRoot = {
name: "root_function",
description: "Root function description",
parameters: { type: "object" },
};
// Act
renderWithProviders(
<ToolItem
tool={toolWithFunctionAtRoot}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.getByTestId("toggle-button")).toHaveTextContent(
"root_function",
);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(
"Root function description",
);
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
});
});
});

View File

@@ -1,22 +1,49 @@
import { screen, within } from "@testing-library/react";
import { screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { renderWithProviders } from "test-utils";
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
import { BrowserRouter } from "react-router";
import type { Conversation } from "#/api/open-hands.types";
// Mock the hooks and utilities
const mockMutate = vi.fn();
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
// Hoisted mocks for controllable return values
const {
mockMutate,
mockDisplaySuccessToast,
useActiveConversationMock,
useConfigMock,
} = vi.hoisted(() => ({
mockMutate: vi.fn(),
mockDisplaySuccessToast: vi.fn(),
useActiveConversationMock: vi.fn(() => ({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
},
}),
})),
useConfigMock: vi.fn(() => ({
data: {
APP_MODE: "oss",
},
})),
}));
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => useActiveConversationMock(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
}));
vi.mock("#/hooks/mutation/use-update-conversation", () => ({
@@ -26,7 +53,7 @@ vi.mock("#/hooks/mutation/use-update-conversation", () => ({
}));
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
displaySuccessToast: mockDisplaySuccessToast,
}));
// Mock react-i18next
@@ -47,6 +74,10 @@ vi.mock("react-i18next", async () => {
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
"Close Conversation (Stop Runtime)",
COMMON$DELETE_CONVERSATION: "Delete Conversation",
CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
CONVERSATION$LINK_COPIED: "Link copied to clipboard",
BUTTON$COPY_TO_CLIPBOARD: "Copy to Clipboard",
BUTTON$OPEN_IN_NEW_TAB: "Open in New Tab",
};
return translations[key] || key;
},
@@ -72,6 +103,9 @@ describe("ConversationName", () => {
open: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
location: {
origin: "http://localhost:3000",
},
});
});
@@ -569,3 +603,313 @@ describe("ConversationNameContextMenu", () => {
expect(onClose).toBeDefined();
});
});
describe("ConversationNameContextMenu - Share Link Functionality", () => {
const { mockWriteText, featureFlagMock } = vi.hoisted(() => ({
mockWriteText: vi.fn().mockResolvedValue(undefined),
featureFlagMock: vi.fn(() => true),
}));
const mockOnCopyShareLink = vi.fn();
const mockOnTogglePublic = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
onClose: mockOnClose,
onTogglePublic: mockOnTogglePublic,
onCopyShareLink: mockOnCopyShareLink,
shareUrl: "https://example.com/shared/conversations/test-id",
};
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PUBLIC_CONVERSATION_SHARING: () => featureFlagMock(),
}));
vi.mock("#/hooks/mutation/use-update-conversation-public-flag", () => ({
useUpdateConversationPublicFlag: () => ({
mutate: vi.fn(),
}),
}));
beforeAll(() => {
// Mock navigator.clipboard
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
readText: vi.fn(),
},
writable: true,
configurable: true,
});
});
beforeEach(() => {
mockWriteText.mockClear();
mockDisplaySuccessToast.mockClear();
featureFlagMock.mockReturnValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should display copy and open buttons when conversation is public", () => {
// Arrange
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
},
});
// Act
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
// Assert
expect(screen.getByTestId("copy-share-link-button")).toBeInTheDocument();
expect(screen.getByTestId("open-share-link-button")).toBeInTheDocument();
});
it("should not display share buttons when conversation is not public", () => {
// Arrange
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: false,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
},
});
// Act
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
// Assert
expect(
screen.queryByTestId("copy-share-link-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("open-share-link-button"),
).not.toBeInTheDocument();
});
it("should call copy handler when copy button is clicked", async () => {
// Arrange
const user = userEvent.setup();
const shareUrl = "https://example.com/shared/conversations/test-id";
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
},
});
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} shareUrl={shareUrl} />,
);
const copyButton = screen.getByTestId("copy-share-link-button");
// Act
await user.click(copyButton);
// Assert
expect(mockOnCopyShareLink).toHaveBeenCalledTimes(1);
});
it("should have correct attributes for open share link button", () => {
// Arrange
const shareUrl = "https://example.com/shared/conversations/test-id";
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
},
});
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} shareUrl={shareUrl} />,
);
const openButton = screen.getByTestId("open-share-link-button");
// Assert
expect(openButton).toHaveAttribute("href", shareUrl);
expect(openButton).toHaveAttribute("target", "_blank");
expect(openButton).toHaveAttribute("rel", "noopener noreferrer");
});
it("should display correct tooltips for share buttons", () => {
// Arrange
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
},
});
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
// Assert
const copyButton = screen.getByTestId("copy-share-link-button");
const openButton = screen.getByTestId("open-share-link-button");
expect(copyButton).toHaveAttribute("title", "Copy to Clipboard");
expect(openButton).toHaveAttribute("title", "Open in New Tab");
});
describe("Integration with ConversationName component", () => {
beforeEach(() => {
// Default mocks for public V1 conversation in SAAS mode
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "STOPPED",
conversation_version: "V1" as const,
public: true,
} as Conversation,
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
},
});
});
it("should copy share URL to clipboard and show success toast when copy button is clicked through ConversationName", async () => {
// Arrange
const user = userEvent.setup();
const expectedUrl =
"http://localhost:3000/shared/conversations/test-conversation-id";
// Ensure navigator.clipboard is properly mocked
if (!navigator.clipboard) {
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
readText: vi.fn(),
},
writable: true,
configurable: true,
});
} else {
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(
mockWriteText,
);
}
renderConversationNameWithRouter();
// Open context menu by clicking ellipsis
const ellipsisButton = screen.getByRole("button", { hidden: true });
await user.click(ellipsisButton);
// Wait for context menu to appear and find share publicly button
const sharePubliclyButton = await screen.findByTestId(
"share-publicly-button",
);
expect(sharePubliclyButton).toBeInTheDocument();
// Find copy button
const copyButton = screen.getByTestId("copy-share-link-button");
// Act
await user.click(copyButton);
// Assert - clipboard.writeText is async, so we need to wait
await waitFor(
() => {
expect(mockWriteText).toHaveBeenCalledWith(expectedUrl);
expect(mockDisplaySuccessToast).toHaveBeenCalledWith(
"Link copied to clipboard",
);
},
{ timeout: 2000, container: document.body },
);
});
it("should not show share buttons when feature flag is disabled", () => {
// Arrange
featureFlagMock.mockReturnValue(false);
renderConversationNameWithRouter();
// Act - try to find share buttons (should not exist even if conversation is public)
const copyButton = screen.queryByTestId("copy-share-link-button");
const openButton = screen.queryByTestId("open-share-link-button");
// Assert
expect(copyButton).not.toBeInTheDocument();
expect(openButton).not.toBeInTheDocument();
});
it("should show both copy and open buttons when conversation is public and feature flag is enabled", async () => {
// Arrange
const user = userEvent.setup();
featureFlagMock.mockReturnValue(true);
renderConversationNameWithRouter();
// Act - open context menu
const ellipsisButton = screen.getByRole("button", { hidden: true });
await user.click(ellipsisButton);
// Wait for context menu
const sharePubliclyButton = await screen.findByTestId(
"share-publicly-button",
);
// Assert
expect(sharePubliclyButton).toBeInTheDocument();
expect(screen.getByTestId("copy-share-link-button")).toBeInTheDocument();
expect(screen.getByTestId("open-share-link-button")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router";
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
function renderWithRouter(conversationId: string, onClose: () => void) {
return render(
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
<Routes>
<Route
path="/conversations/:conversationId"
element={<ConversationTabsContextMenu isOpen onClose={onClose} />}
/>
</Routes>
</MemoryRouter>,
);
}
describe("ConversationTabsContextMenu", () => {
afterEach(() => {
localStorage.clear();
});
it("should use per-conversation localStorage key for unpinned tabs", async () => {
const user = userEvent.setup();
const onClose = () => {};
// Render for conversation-1
const { unmount } = renderWithRouter("conversation-1", onClose);
// Unpin the terminal tab in conversation-1
const terminalItem = screen.getByText("COMMON$TERMINAL");
await user.click(terminalItem);
// Verify localStorage key is per-conversation
const stored1 = JSON.parse(
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
);
expect(stored1).toContain("terminal");
unmount();
// Switch to conversation-2
renderWithRouter("conversation-2", onClose);
// conversation-2 should have its own empty state
const stored2 = JSON.parse(
localStorage.getItem("conversation-unpinned-tabs-conversation-2") || "[]",
);
expect(stored2).toEqual([]);
// conversation-1 state should still have terminal unpinned
const stored1Again = JSON.parse(
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
);
expect(stored1Again).toContain("terminal");
});
it("should toggle tab pin state when clicked", async () => {
const user = userEvent.setup();
const onClose = () => {};
renderWithRouter("conversation-1", onClose);
const terminalItem = screen.getByText("COMMON$TERMINAL");
// Click to unpin
await user.click(terminalItem);
let stored = JSON.parse(
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
);
expect(stored).toContain("terminal");
// Click again to pin
await user.click(terminalItem);
stored = JSON.parse(
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
);
expect(stored).not.toContain("terminal");
});
});

View File

@@ -1,25 +1,70 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import {
renderWithProviders,
createAxiosNotFoundErrorObject,
} from "test-utils";
import { createRoutesStub } from "react-router";
import { waitFor } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { GetConfigResponse } from "#/api/option-service/option.types";
// Helper to create mock config with sensible defaults
const createMockConfig = (
overrides: Omit<Partial<GetConfigResponse>, "FEATURE_FLAGS"> & {
FEATURE_FLAGS?: Partial<GetConfigResponse["FEATURE_FLAGS"]>;
} = {},
): GetConfigResponse => {
const { FEATURE_FLAGS: featureFlagOverrides, ...restOverrides } = overrides;
return {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
...featureFlagOverrides,
},
...restOverrides,
};
};
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
const RouterStub = createRoutesStub([
const ConversationRouterStub = createRoutesStub([
{
path: "/conversation/:conversationId",
Component: () => <Sidebar />,
},
]);
const renderSidebar = () =>
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
const SettingsRouterStub = createRoutesStub([
{
path: "/settings",
Component: () => <Sidebar />,
},
]);
const renderSidebar = (path: "conversation" | "settings" = "conversation") => {
if (path === "settings") {
return renderWithProviders(
<SettingsRouterStub initialEntries={["/settings"]} />,
);
}
return renderWithProviders(
<ConversationRouterStub initialEntries={["/conversation/123"]} />,
);
};
describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
afterEach(() => {
vi.clearAllMocks();
@@ -29,4 +74,100 @@ describe("Sidebar", () => {
renderSidebar();
await waitFor(() => expect(getSettingsSpy).toHaveBeenCalled());
});
describe("Settings modal auto-open behavior", () => {
it("should NOT open settings modal when HIDE_LLM_SETTINGS is true even with 404 error", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: true } }),
);
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
renderSidebar();
await waitFor(() => {
expect(getConfigSpy).toHaveBeenCalled();
expect(getSettingsSpy).toHaveBeenCalled();
});
// Settings modal should NOT appear when HIDE_LLM_SETTINGS is true
await waitFor(() => {
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
});
});
it("should open settings modal when HIDE_LLM_SETTINGS is false and 404 error in OSS mode", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
);
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
renderSidebar();
// Settings modal should appear when HIDE_LLM_SETTINGS is false
await waitFor(() => {
expect(screen.getByTestId("ai-config-modal")).toBeInTheDocument();
});
});
it("should NOT open settings modal in SaaS mode even with 404 error", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({
APP_MODE: "saas",
FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false },
}),
);
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
renderSidebar();
await waitFor(() => {
expect(getConfigSpy).toHaveBeenCalled();
expect(getSettingsSpy).toHaveBeenCalled();
});
// Settings modal should NOT appear in SaaS mode (only opens in OSS mode)
await waitFor(() => {
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
});
});
it("should NOT open settings modal when settings exist (no 404 error)", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
);
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderSidebar();
await waitFor(() => {
expect(getConfigSpy).toHaveBeenCalled();
expect(getSettingsSpy).toHaveBeenCalled();
});
// Settings modal should NOT appear when settings exist
await waitFor(() => {
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
});
});
it("should NOT open settings modal when on /settings path", async () => {
getConfigSpy.mockResolvedValue(
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
);
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
renderSidebar("settings");
await waitFor(() => {
expect(getConfigSpy).toHaveBeenCalled();
expect(getSettingsSpy).toHaveBeenCalled();
});
// Settings modal should NOT appear when on /settings path
// (prevents modal from showing when user is already viewing settings)
await waitFor(() => {
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { PostHogWrapper } from "#/components/providers/posthog-wrapper";
import OptionService from "#/api/option-service/option-service.api";
// Mock PostHogProvider to capture the options passed to it
const mockPostHogProvider = vi.fn();
vi.mock("posthog-js/react", () => ({
PostHogProvider: (props: Record<string, unknown>) => {
mockPostHogProvider(props);
return props.children;
},
}));
describe("PostHogWrapper", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset URL hash
window.location.hash = "";
// Mock the config fetch
// @ts-expect-error - partial mock
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
POSTHOG_CLIENT_KEY: "test-posthog-key",
});
});
it("should initialize PostHog with bootstrap IDs from URL hash", async () => {
// Set up URL hash with cross-domain tracking params
window.location.hash = "ph_distinct_id=user-123&ph_session_id=session-456";
render(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
// Wait for async config fetch and PostHog initialization
await screen.findByTestId("child");
// Verify PostHogProvider was called with bootstrap options
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: {
distinctID: "user-123",
sessionID: "session-456",
},
}),
}),
);
});
it("should clean up URL hash after extracting bootstrap IDs", async () => {
// Set up URL hash with cross-domain tracking params
window.location.hash = "ph_distinct_id=user-123&ph_session_id=session-456";
render(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
// Wait for async config fetch and PostHog initialization
await screen.findByTestId("child");
// Verify URL hash was cleaned up
expect(window.location.hash).toBe("");
});
});

View File

@@ -11,6 +11,7 @@ import {
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useBrowserStore } from "#/stores/browser-store";
import { useCommandStore } from "#/stores/command-store";
@@ -78,13 +79,22 @@ function renderWithWebSocketContext(
return render(
<QueryClientProvider client={queryClient}>
<ConversationWebSocketProvider
conversationId={conversationId}
conversationUrl={conversationUrl}
sessionApiKey={sessionApiKey}
>
{children}
</ConversationWebSocketProvider>
<MemoryRouter initialEntries={["/test-conversation-default"]}>
<Routes>
<Route
path="/:conversationId"
element={
<ConversationWebSocketProvider
conversationId={conversationId}
conversationUrl={conversationUrl}
sessionApiKey={sessionApiKey}
>
{children}
</ConversationWebSocketProvider>
}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
@@ -295,11 +305,13 @@ describe("Conversation WebSocket Handler", () => {
});
it("should set error message store on WebSocket connection errors", async () => {
// Set up MSW to simulate connection error
// Simulate a connect-then-fail sequence (the MSW server auto-connects by default).
// This should surface an error message because the app has previously connected.
mswServer.use(
wsLink.addEventListener("connection", ({ client }) => {
// Simulate connection error by closing immediately
client.close(1006, "Connection failed");
setTimeout(() => {
client.close(1006, "Connection failed");
}, 50);
}),
);
@@ -314,14 +326,13 @@ describe("Conversation WebSocket Handler", () => {
// Initially should show "none"
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
// Wait for connection error and error message to be set
// Wait for disconnect
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"CLOSED",
);
});
// Should set error message on connection failure
await waitFor(() => {
expect(screen.getByTestId("error-message")).not.toHaveTextContent(
"none",
@@ -378,17 +389,15 @@ describe("Conversation WebSocket Handler", () => {
it("should clear error message store when connection is restored", async () => {
let connectionAttempt = 0;
// Set up MSW to fail first connection, then succeed on retry
// Fail once (after connect), then allow reconnection to stay open.
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
wsLink.addEventListener("connection", ({ client }) => {
connectionAttempt += 1;
if (connectionAttempt === 1) {
// First attempt fails
client.close(1006, "Initial connection failed");
} else {
// Second attempt succeeds
server.connect();
setTimeout(() => {
client.close(1006, "Initial connection failed");
}, 50);
}
}),
);
@@ -404,7 +413,7 @@ describe("Conversation WebSocket Handler", () => {
// Initially should show "none"
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
// Wait for first connection failure and error message
// Wait for first failure
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"CLOSED",
@@ -417,12 +426,16 @@ describe("Conversation WebSocket Handler", () => {
);
});
// Simulate reconnection attempt (this would normally be triggered by the WebSocket context)
// For now, we'll just verify the pattern - when connection is restored, error should clear
// This test will fail until the WebSocket handler implements the clear logic
// Note: This test demonstrates the expected behavior but may need adjustment
// based on how the actual reconnection logic is implemented
// Wait for reconnect to happen and verify error clears on successful connection
await waitFor(
() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
},
{ timeout: 5000 },
);
});
it("should not create duplicate events when WebSocket reconnects with resend_all=true", async () => {

View File

@@ -14,7 +14,44 @@ import SettingsService from "#/api/settings-service/settings-service.api";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
describe("frontend/routes/_oh", () => {
const RouteStub = createRoutesStub([{ Component: MainApp, path: "/" }]);
const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted(
() => {
const defaultFeatureFlags = {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
};
return {
DEFAULT_FEATURE_FLAGS: defaultFeatureFlags,
useIsAuthedMock: vi.fn().mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
}),
useConfigMock: vi.fn().mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: defaultFeatureFlags },
isLoading: false,
}),
};
},
);
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => useIsAuthedMock(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
}));
const RouteStub = createRoutesStub([
{ Component: MainApp, path: "/" },
{ Component: () => <div data-testid="login-page" />, path: "/login" },
]);
const { userIsAuthenticatedMock, settingsAreUpToDateMock } = vi.hoisted(
() => ({
@@ -40,6 +77,17 @@ describe("frontend/routes/_oh", () => {
});
it("should render", async () => {
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
renderWithProviders(<RouteStub />);
await screen.findByTestId("root-layout");
});
@@ -53,6 +101,17 @@ describe("frontend/routes/_oh", () => {
it("should not render the AI config modal if the settings are up-to-date", async () => {
settingsAreUpToDateMock.mockReturnValue(true);
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
renderWithProviders(<RouteStub />);
await waitFor(() => {
@@ -120,6 +179,10 @@ describe("frontend/routes/_oh", () => {
ENABLE_LINEAR: false,
},
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
renderWithProviders(<RouteStub />);
@@ -204,6 +267,10 @@ describe("frontend/routes/_oh", () => {
ENABLE_LINEAR: false,
},
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());

View File

@@ -1,5 +1,5 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
@@ -9,9 +9,47 @@ import { GitRepository } from "#/types/git";
import SettingsService from "#/api/settings-service/settings-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
import MainApp from "#/routes/root-layout";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted(
() => {
const defaultFeatureFlags = {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
};
return {
DEFAULT_FEATURE_FLAGS: defaultFeatureFlags,
useIsAuthedMock: vi.fn().mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
}),
useConfigMock: vi.fn().mockReturnValue({
data: {
APP_MODE: "oss",
FEATURE_FLAGS: defaultFeatureFlags,
},
isLoading: false,
}),
};
},
);
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => useIsAuthedMock(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
}));
const RouterStub = createRoutesStub([
{
Component: MainApp,
@@ -31,6 +69,10 @@ const RouterStub = createRoutesStub([
},
],
},
{
Component: () => <div data-testid="login-page" />,
path: "/login",
},
]);
const selectRepository = async (repoName: string) => {
@@ -90,19 +132,55 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
describe("HomeScreen", () => {
beforeEach(() => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
vi.clearAllMocks();
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
// Mock config to avoid SaaS redirect logic
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "fake-token",
gitlab: "fake-token",
},
});
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
});
it("should render", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("should render", async () => {
renderHomeScreen();
screen.getByTestId("home-screen");
await screen.findByTestId("home-screen");
});
it("should render the repository connector and suggested tasks sections", async () => {
@@ -353,13 +431,49 @@ describe("HomeScreen", () => {
});
describe("Settings 404", () => {
beforeEach(() => {
vi.resetAllMocks();
});
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
beforeEach(() => {
vi.resetAllMocks();
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
@@ -395,16 +509,15 @@ describe("Settings 404", () => {
});
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
@@ -419,23 +532,59 @@ describe("Setup Payment modal", () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test
beforeEach(() => {
vi.clearAllMocks();
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
FEATURE_FLAGS: { ...DEFAULT_FEATURE_FLAGS, ENABLE_BILLING: true },
},
isLoading: false,
});
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: { ...DEFAULT_FEATURE_FLAGS, ENABLE_BILLING: true },
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("should only render if SaaS mode and is new user", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
is_new_user: true,
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderHomeScreen();
await screen.findByTestId("root-layout");
const setupPaymentModal = await screen.findByTestId(
"proceed-to-stripe-button",
);

View File

@@ -0,0 +1,429 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoutesStub } from "react-router";
import LoginPage from "#/routes/login";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
const { useEmailVerificationMock } = vi.hoisted(() => ({
useEmailVerificationMock: vi.fn(() => ({
emailVerified: false,
hasDuplicatedEmail: false,
emailVerificationModalOpen: false,
setEmailVerificationModalOpen: vi.fn(),
})),
}));
vi.mock("#/hooks/use-github-auth-url", () => ({
useGitHubAuthUrl: () => "https://github.com/login/oauth/authorize",
}));
vi.mock("#/hooks/use-email-verification", () => ({
useEmailVerification: () => useEmailVerificationMock(),
}));
const { useAuthUrlMock } = vi.hoisted(() => ({
useAuthUrlMock: vi.fn(
(config: { identityProvider: string; appMode: string | null }) => {
const urls: Record<string, string> = {
gitlab: "https://gitlab.com/oauth/authorize",
bitbucket: "https://bitbucket.org/site/oauth2/authorize",
};
if (config.appMode === "saas") {
return (
urls[config.identityProvider] || "https://gitlab.com/oauth/authorize"
);
}
return null;
},
),
}));
vi.mock("#/hooks/use-auth-url", () => ({
useAuthUrl: (config: { identityProvider: string; appMode: string | null }) =>
useAuthUrlMock(config),
}));
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackLoginButtonClick: vi.fn(),
}),
}));
const RouterStub = createRoutesStub([
{
Component: LoginPage,
path: "/login",
},
]);
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return Wrapper;
};
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("location", { href: "" });
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github", "gitlab", "bitbucket"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
vi.spyOn(AuthService, "authenticate").mockRejectedValue({
response: { status: 401 },
isAxiosError: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("Rendering", () => {
it("should render login page with heading", async () => {
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
});
expect(screen.getByText("AUTH$LETS_GET_STARTED")).toBeInTheDocument();
});
it("should display all configured provider buttons", async () => {
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByTestId("login-content")).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i,
}),
).toBeInTheDocument();
});
});
it("should only display configured providers", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: ["github"],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
).toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", {
name: "BITBUCKET$CONNECT_TO_BITBUCKET",
}),
).not.toBeInTheDocument();
});
it("should display message when no providers are configured", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
PROVIDERS_CONFIGURED: [],
AUTH_URL: "https://auth.example.com",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(
screen.getByText("AUTH$NO_PROVIDERS_CONFIGURED"),
).toBeInTheDocument();
});
});
});
describe("OAuth Flow", () => {
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://github.com/login/oauth/authorize";
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
).toBeInTheDocument();
});
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
expect(window.location.href).toBe(mockUrl);
});
it("should redirect to GitLab auth URL when GitLab button is clicked", async () => {
const user = userEvent.setup();
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
).toBeInTheDocument();
});
const gitlabButton = screen.getByRole("button", {
name: "GITLAB$CONNECT_TO_GITLAB",
});
await user.click(gitlabButton);
expect(window.location.href).toBe("https://gitlab.com/oauth/authorize");
});
it("should redirect to Bitbucket auth URL when Bitbucket button is clicked", async () => {
const user = userEvent.setup();
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByTestId("login-content")).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole("button", {
name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i,
}),
).toBeInTheDocument();
});
const bitbucketButton = screen.getByRole("button", {
name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i,
});
await user.click(bitbucketButton);
expect(window.location.href).toBe(
"https://bitbucket.org/site/oauth2/authorize",
);
});
});
describe("Redirects", () => {
it("should redirect authenticated users to home", async () => {
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(
() => {
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it("should redirect authenticated users to returnTo destination", async () => {
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
render(<RouterStub initialEntries={["/login?returnTo=/settings"]} />, {
wrapper: createWrapper(),
});
await waitFor(
() => {
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it("should redirect OSS mode users to home", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(
() => {
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
});
describe("Email Verification", () => {
it("should display email verified message when emailVerified is true", async () => {
useEmailVerificationMock.mockReturnValue({
emailVerified: true,
hasDuplicatedEmail: false,
emailVerificationModalOpen: false,
setEmailVerificationModalOpen: vi.fn(),
});
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
).toBeInTheDocument();
});
});
it("should display duplicate email error when hasDuplicatedEmail is true", async () => {
useEmailVerificationMock.mockReturnValue({
emailVerified: false,
hasDuplicatedEmail: true,
emailVerificationModalOpen: false,
setEmailVerificationModalOpen: vi.fn(),
});
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(
screen.getByText("AUTH$DUPLICATE_EMAIL_ERROR"),
).toBeInTheDocument();
});
});
});
describe("Loading States", () => {
it("should show loading spinner while checking auth", async () => {
vi.spyOn(AuthService, "authenticate").mockImplementation(
() => new Promise(() => {}),
);
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
const spinner = document.querySelector(".animate-spin");
expect(spinner).toBeInTheDocument();
});
});
it("should show loading spinner while loading config", async () => {
vi.spyOn(OptionService, "getConfig").mockImplementation(
() => new Promise(() => {}),
);
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
const spinner = document.querySelector(".animate-spin");
expect(spinner).toBeInTheDocument();
});
});
});
describe("Terms and Privacy", () => {
it("should display Terms and Privacy notice", async () => {
render(<RouterStub initialEntries={["/login"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(
screen.getByTestId("terms-and-privacy-notice"),
).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,103 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoutesStub } from "react-router";
import MainApp from "#/routes/root-layout";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
// Hoisted mocks for useIsAuthed and useConfig to allow dynamic control in tests
const { useIsAuthedMock, useConfigMock } = vi.hoisted(() => ({
useIsAuthedMock: vi.fn(),
useConfigMock: vi.fn(),
}));
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => useIsAuthedMock(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
}));
const DEFAULT_FEATURE_FLAGS = {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
};
const RouterStub = createRoutesStub([
{
Component: MainApp,
path: "/",
children: [
{
Component: () => <div data-testid="outlet-content" />,
path: "/",
},
{
Component: () => <div data-testid="settings-page" />,
path: "/settings",
},
],
},
{
Component: () => <div data-testid="login-page" />,
path: "/login",
},
]);
describe("MainApp - Auth refetch behavior", () => {
it("should NOT show loading spinner when auth is refetching for an authenticated user", async () => {
// Setup: Mock hooks to simulate authenticated user CURRENTLY REFETCHING
// This is the state when the auth cache is invalidated and refetching
useIsAuthedMock.mockReturnValue({
data: true, // Still have cached data showing user is authenticated
isLoading: false, // Not initial loading
isFetching: true, // IS refetching - this is the key!
isError: false,
});
useConfigMock.mockReturnValue({
data: {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
},
isLoading: false,
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(<RouterStub initialEntries={["/settings"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
// BUG: The settings page should still be visible during refetch
// but the current implementation shows a loading spinner because
// shouldRedirectToLogin includes isFetchingAuth in its condition
//
// This test will FAIL until the bug is fixed.
// Current behavior: shows full-page loading spinner, redirects to login
// Expected behavior: shows settings page with root-layout, no redirect
// Wait a tick for any effects to run
await waitFor(() => {
// The root-layout should be present (not replaced by full-page loading spinner)
const rootLayout = screen.queryByTestId("root-layout");
// The settings page should remain visible during refetch
const settingsPage = screen.queryByTestId("settings-page");
// These assertions describe the EXPECTED behavior (will fail until bug is fixed)
expect(rootLayout).toBeInTheDocument();
expect(settingsPage).toBeInTheDocument();
});
});
});

View File

@@ -1,13 +1,13 @@
import { render, screen, waitFor } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoutesStub } from "react-router";
import { createRoutesStub, useSearchParams } from "react-router";
import MainApp from "#/routes/root-layout";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
// Mock other hooks that are not the focus of these tests
vi.mock("#/hooks/use-github-auth-url", () => ({
useGitHubAuthUrl: () => "https://github.com/oauth/authorize",
}));
@@ -42,38 +42,101 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
}));
function LoginStub() {
const [searchParams] = useSearchParams();
const emailVerificationRequired =
searchParams.get("email_verification_required") === "true";
const emailVerified = searchParams.get("email_verified") === "true";
const emailVerificationText = "AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY";
return (
<div data-testid="login-page">
<div data-testid="login-content">
{emailVerified && <div data-testid="email-verified-message" />}
{emailVerificationRequired && (
<div data-testid="email-verification-modal">
{emailVerificationText}
</div>
)}
</div>
</div>
);
}
const RouterStub = createRoutesStub([
{
Component: MainApp,
path: "/",
children: [
{
Component: () => <div data-testid="outlet-content">Content</div>,
Component: () => <div data-testid="outlet-content" />,
path: "/",
},
],
},
{
Component: LoginStub,
path: "/login",
},
]);
const RouterStubWithLogin = createRoutesStub([
{
Component: MainApp,
path: "/",
children: [
{
Component: () => <div data-testid="outlet-content" />,
path: "/",
},
{
Component: () => <div data-testid="settings-page" />,
path: "/settings",
},
],
},
{
Component: () => <div data-testid="login-page" />,
path: "/login",
},
]);
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
const renderMainApp = (initialEntries: string[] = ["/"]) =>
render(<RouterStub initialEntries={initialEntries} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
{children}
</QueryClientProvider>
),
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
const renderWithLoginStub = (
RouterStubComponent: ReturnType<typeof createRoutesStub>,
initialEntries: string[] = ["/"],
) =>
render(<RouterStubComponent initialEntries={initialEntries} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
{children}
</QueryClientProvider>
),
});
describe("MainApp - Email Verification Flow", () => {
describe("MainApp", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mocks for services
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-client-id",
@@ -91,28 +154,10 @@ describe("MainApp - Email Verification Flow", () => {
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
language: "en",
user_consents_to_analytics: true,
llm_model: "",
llm_base_url: "",
agent: "",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
max_budget_per_task: null,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
MOCK_DEFAULT_USER_SETTINGS,
);
// Mock localStorage
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
@@ -126,117 +171,145 @@ describe("MainApp - Email Verification Flow", () => {
vi.unstubAllGlobals();
});
it("should display EmailVerificationModal when email_verification_required=true is in query params", async () => {
// Arrange & Act
render(
<RouterStub initialEntries={["/?email_verification_required=true"]} />,
{ wrapper: createWrapper() },
);
describe("Email Verification", () => {
it("should redirect to login when email_verification_required=true is in query params", async () => {
const axiosError = {
response: { status: 401 },
isAxiosError: true,
};
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
// Assert
await waitFor(() => {
expect(
screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
).toBeInTheDocument();
});
});
renderMainApp(["/?email_verification_required=true"]);
it("should set emailVerified state and pass to AuthModal when email_verified=true is in query params", async () => {
// Arrange
// Mock a 401 error to simulate unauthenticated user
const axiosError = {
response: { status: 401 },
isAxiosError: true,
};
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
// Act
render(<RouterStub initialEntries={["/?email_verified=true"]} />, {
wrapper: createWrapper(),
});
// Assert - Wait for AuthModal to render (since user is not authenticated)
await waitFor(() => {
expect(
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
).toBeInTheDocument();
});
});
it("should handle both email_verification_required and email_verified params together", async () => {
// Arrange & Act
render(
<RouterStub
initialEntries={[
"/?email_verification_required=true&email_verified=true",
]}
/>,
{ wrapper: createWrapper() },
);
// Assert - EmailVerificationModal should take precedence
await waitFor(() => {
expect(
screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
).toBeInTheDocument();
});
});
it("should remove query parameters from URL after processing", async () => {
// Arrange & Act
const { container } = render(
<RouterStub initialEntries={["/?email_verification_required=true"]} />,
{ wrapper: createWrapper() },
);
// Assert - Wait for the modal to appear (which indicates processing happened)
await waitFor(() => {
expect(
screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
).toBeInTheDocument();
});
// Verify that the query parameter was processed by checking the modal appeared
// The hook removes the parameter from the URL, so we verify the behavior indirectly
expect(container).toBeInTheDocument();
});
it("should not display EmailVerificationModal when email_verification_required is not in query params", async () => {
// Arrange - No query params set
// Act
render(<RouterStub />, { wrapper: createWrapper() });
// Assert
await waitFor(() => {
expect(
screen.queryByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
).not.toBeInTheDocument();
});
});
it("should not display email verified message when email_verified is not in query params", async () => {
// Arrange
// Mock a 401 error to simulate unauthenticated user
const axiosError = {
response: { status: 401 },
isAxiosError: true,
};
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
// Act
render(<RouterStub />, { wrapper: createWrapper() });
// Assert - AuthModal should render but without email verified message
await waitFor(() => {
const authModal = screen.queryByText(
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it("should redirect to login when email_verified=true is in query params", async () => {
const axiosError = {
response: { status: 401 },
isAxiosError: true,
};
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
renderMainApp(["/?email_verified=true"]);
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it("should redirect to login when email_verification_required and email_verified params are in query params together", async () => {
const axiosError = {
response: { status: 401 },
isAxiosError: true,
};
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
renderMainApp(["/?email_verification_required=true&email_verified=true"]);
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it("should redirect to login when email_verification_required=true is in query params", async () => {
const axiosError = {
response: { status: 401 },
isAxiosError: true,
};
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
renderMainApp(["/?email_verification_required=true"]);
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it("should not display EmailVerificationModal when email_verification_required is not in query params", async () => {
const axiosError = {
response: { status: 401 },
isAxiosError: true,
};
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
renderMainApp(["/"]);
// User will be redirected to login, but modal should not show without query param
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
expect(
screen.queryByTestId("email-verification-modal"),
).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it("should not display email verified message when email_verified is not in query params", async () => {
const axiosError = {
response: { status: 401 },
isAxiosError: true,
};
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
renderMainApp(["/login"]);
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
expect(
screen.queryByTestId("email-verified-message"),
).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
});
describe("Unauthenticated redirect", () => {
beforeEach(() => {
vi.spyOn(AuthService, "authenticate").mockRejectedValue({
response: { status: 401 },
isAxiosError: true,
});
});
it("should redirect unauthenticated SaaS users to /login", async () => {
renderWithLoginStub(RouterStubWithLogin, ["/"]);
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it("should redirect to /login with returnTo parameter when on a specific page", async () => {
renderWithLoginStub(RouterStubWithLogin, ["/settings"]);
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
},
{ timeout: 2000 },
);
if (authModal) {
expect(
screen.queryByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
).not.toBeInTheDocument();
}
});
});
});

View File

@@ -1,9 +1,26 @@
import { test, expect } from "vitest";
import { describe, it, expect, vi, test } from "vitest";
import {
formatTimestamp,
getExtension,
removeApiKey,
} from "../../src/utils/utils";
import { getStatusText } from "#/utils/utils";
import { AgentState } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
// Mock translations
const t = (key: string) => {
const translations: { [key: string]: string } = {
COMMON$WAITING_FOR_SANDBOX: "Waiting For Sandbox",
COMMON$STOPPING: "Stopping",
COMMON$STARTING: "Starting",
COMMON$SERVER_STOPPED: "Server stopped",
COMMON$RUNNING: "Running",
CONVERSATION$READY: "Ready",
CONVERSATION$ERROR_STARTING_CONVERSATION: "Error starting conversation",
};
return translations[key] || key;
};
test("removeApiKey", () => {
const data = [{ args: { LLM_API_KEY: "key", LANGUAGE: "en" } }];
@@ -23,3 +40,143 @@ test("formatTimestamp", () => {
const eveningDate = new Date("2021-10-10T22:10:10.000").toISOString();
expect(formatTimestamp(eveningDate)).toBe("10/10/2021, 22:10:10");
});
describe("getStatusText", () => {
it("returns STOPPING when pausing", () => {
const result = getStatusText({
isPausing: true,
isTask: false,
taskStatus: null,
taskDetail: null,
isStartingStatus: false,
isStopStatus: false,
curAgentState: AgentState.RUNNING,
t,
});
expect(result).toBe(t(I18nKey.COMMON$STOPPING));
});
it("formats task status when polling a task", () => {
const result = getStatusText({
isPausing: false,
isTask: true,
taskStatus: "WAITING_FOR_SANDBOX",
taskDetail: null,
isStartingStatus: false,
isStopStatus: false,
curAgentState: AgentState.RUNNING,
t,
});
expect(result).toBe(t(I18nKey.COMMON$WAITING_FOR_SANDBOX));
});
it("returns task detail when task status is ERROR and detail exists", () => {
const result = getStatusText({
isPausing: false,
isTask: true,
taskStatus: "ERROR",
taskDetail: "Sandbox failed",
isStartingStatus: false,
isStopStatus: false,
curAgentState: AgentState.RUNNING,
t,
});
expect(result).toBe("Sandbox failed");
});
it("returns translated error when task status is ERROR and no detail", () => {
const result = getStatusText({
isPausing: false,
isTask: true,
taskStatus: "ERROR",
taskDetail: null,
isStartingStatus: false,
isStopStatus: false,
curAgentState: AgentState.RUNNING,
t,
});
expect(result).toBe(
t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION),
);
});
it("returns READY translation when task is ready", () => {
const result = getStatusText({
isPausing: false,
isTask: true,
taskStatus: "READY",
taskDetail: null,
isStartingStatus: false,
isStopStatus: false,
curAgentState: AgentState.RUNNING,
t,
});
expect(result).toBe(t(I18nKey.CONVERSATION$READY));
});
it("returns STARTING when starting status is true", () => {
const result = getStatusText({
isPausing: false,
isTask: false,
taskStatus: null,
taskDetail: null,
isStartingStatus: true,
isStopStatus: false,
curAgentState: AgentState.INIT,
t,
});
expect(result).toBe(t(I18nKey.COMMON$STARTING));
});
it("returns SERVER_STOPPED when stop status is true", () => {
const result = getStatusText({
isPausing: false,
isTask: false,
taskStatus: null,
taskDetail: null,
isStartingStatus: false,
isStopStatus: true,
curAgentState: AgentState.STOPPED,
t,
});
expect(result).toBe(t(I18nKey.COMMON$SERVER_STOPPED));
});
it("returns errorMessage when agent state is ERROR", () => {
const result = getStatusText({
isPausing: false,
isTask: false,
taskStatus: null,
taskDetail: null,
isStartingStatus: false,
isStopStatus: false,
curAgentState: AgentState.ERROR,
errorMessage: "Something broke",
t,
});
expect(result).toBe("Something broke");
});
it("returns default RUNNING status", () => {
const result = getStatusText({
isPausing: false,
isTask: false,
taskStatus: null,
taskDetail: null,
isStartingStatus: false,
isStopStatus: false,
curAgentState: AgentState.RUNNING,
t,
});
expect(result).toBe(t(I18nKey.COMMON$RUNNING));
});
});

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
extractBaseHost,
extractPathPrefix,
buildHttpBaseUrl,
buildWebSocketUrl,
} from "#/utils/websocket-url";
describe("websocket-url utilities", () => {
beforeEach(() => {
vi.stubGlobal("location", {
host: "localhost:3001",
protocol: "https:",
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("extractBaseHost", () => {
it("should extract host from a standard URL", () => {
const result = extractBaseHost(
"https://example.com/api/conversations/123",
);
expect(result).toBe("example.com");
});
it("should extract host with port from URL", () => {
const result = extractBaseHost(
"http://localhost:3000/api/conversations/123",
);
expect(result).toBe("localhost:3000");
});
it("should extract host from proxy deployment URL", () => {
const result = extractBaseHost(
"https://openhands.example.com/runtime/55313/api/conversations/abc123",
);
expect(result).toBe("openhands.example.com");
});
it("should return window.location.host for relative URLs", () => {
const result = extractBaseHost("/api/conversations/123");
expect(result).toBe("localhost:3001");
});
it("should return window.location.host for null, undefined, or invalid URL", () => {
expect(extractBaseHost(null)).toBe("localhost:3001");
expect(extractBaseHost(undefined)).toBe("localhost:3001");
expect(extractBaseHost("not-a-valid-url")).toBe("localhost:3001");
});
});
describe("extractPathPrefix", () => {
it("should return empty string for URL without path prefix", () => {
const result = extractPathPrefix(
"https://example.com/api/conversations/123",
);
expect(result).toBe("");
});
it("should extract path prefix from proxy deployment URL", () => {
const result = extractPathPrefix(
"https://openhands.example.com/runtime/55313/api/conversations/abc123",
);
expect(result).toBe("/runtime/55313");
});
it("should handle multiple path segments before /api/conversations", () => {
const result = extractPathPrefix(
"https://example.com/prefix/sub/path/api/conversations/123",
);
expect(result).toBe("/prefix/sub/path");
});
it("should remove trailing slash from path prefix", () => {
// This test ensures the function handles URLs where the path ends with /
const result = extractPathPrefix(
"https://example.com/runtime/55313/api/conversations/123",
);
expect(result).not.toMatch(/\/$/);
});
it("should return empty string for relative URLs, null, undefined, or invalid URL", () => {
expect(extractPathPrefix("/api/conversations/123")).toBe("");
expect(extractPathPrefix(null)).toBe("");
expect(extractPathPrefix(undefined)).toBe("");
expect(extractPathPrefix("not-a-valid-url")).toBe("");
});
});
describe("buildHttpBaseUrl", () => {
it("should build HTTP URL without path prefix", () => {
const result = buildHttpBaseUrl(
"https://example.com/api/conversations/123",
);
expect(result).toBe("https://example.com");
});
it("should build HTTP URL with path prefix for proxy deployment", () => {
const result = buildHttpBaseUrl(
"https://openhands.example.com/runtime/55313/api/conversations/abc123",
);
expect(result).toBe("https://openhands.example.com/runtime/55313");
});
it("should use http protocol when window.location.protocol is http:", () => {
vi.stubGlobal("location", {
host: "localhost:3001",
protocol: "http:",
});
const result = buildHttpBaseUrl(
"http://localhost:3000/api/conversations/123",
);
expect(result).toBe("http://localhost:3000");
});
it("should fallback to window.location for null URL", () => {
const result = buildHttpBaseUrl(null);
expect(result).toBe("https://localhost:3001");
});
});
describe("buildWebSocketUrl", () => {
it("should return null when conversationId is undefined or empty", () => {
expect(
buildWebSocketUrl(
undefined,
"https://example.com/api/conversations/123",
),
).toBeNull();
expect(
buildWebSocketUrl("", "https://example.com/api/conversations/123"),
).toBeNull();
});
it("should build WebSocket URL without path prefix", () => {
const result = buildWebSocketUrl(
"conv-123",
"https://example.com/api/conversations/conv-123",
);
expect(result).toBe("wss://example.com/sockets/events/conv-123");
});
it("should build WebSocket URL with path prefix for proxy deployment", () => {
const result = buildWebSocketUrl(
"abc123",
"https://openhands.example.com/runtime/55313/api/conversations/abc123",
);
expect(result).toBe(
"wss://openhands.example.com/runtime/55313/sockets/events/abc123",
);
});
it("should use ws protocol when window.location.protocol is http:", () => {
vi.stubGlobal("location", {
host: "localhost:3001",
protocol: "http:",
});
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:3000/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:3000/sockets/events/conv-123");
});
it("should fallback to window.location.host for null URL", () => {
const result = buildWebSocketUrl("conv-123", null);
expect(result).toBe("wss://localhost:3001/sockets/events/conv-123");
});
it("should handle complex path prefixes", () => {
const result = buildWebSocketUrl(
"test-conv",
"https://app.example.com/org/team/runtime/12345/api/conversations/test-conv",
);
expect(result).toBe(
"wss://app.example.com/org/team/runtime/12345/sockets/events/test-conv",
);
});
});
});

11
frontend/global.d.ts vendored
View File

@@ -5,7 +5,7 @@ interface Window {
init: (config: { clientID: string }) => void;
identify: (identity: {
username: string;
type: "github" |"email";
type: "github" | "email";
other_identities?: Array<{
username: string;
type: "github" | "email";
@@ -15,4 +15,13 @@ interface Window {
company?: string;
}) => void;
};
grecaptcha?: {
enterprise: {
ready: (callback: () => void) => void;
execute: (
siteKey: string,
options: { action: string },
) => Promise<string>;
};
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"type": "module",
"engines": {
@@ -8,10 +8,10 @@
},
"dependencies": {
"@heroui/react": "2.8.7",
"@microlink/react-json-view": "^1.26.2",
"@microlink/react-json-view": "^1.27.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.11.0",
"@react-router/serve": "^7.11.0",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.16",
"@uidotdev/usehooks": "^2.4.1",
@@ -22,21 +22,21 @@
"clsx": "^2.1.1",
"downshift": "^9.0.13",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.25",
"i18next": "^25.7.3",
"framer-motion": "^12.26.2",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.32",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.55.1",
"posthog-js": "^1.313.0",
"posthog-js": "^1.319.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.5.1",
"react-i18next": "^16.5.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.11.0",
"react-router": "^7.12.0",
"react-syntax-highlighter": "^16.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
@@ -44,8 +44,8 @@
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.4.0",
"tailwind-scrollbar": "^4.0.2",
"vite": "^7.3.0",
"zustand": "^5.0.9"
"vite": "^7.3.1",
"zustand": "^5.0.10"
},
"scripts": {
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
@@ -82,20 +82,20 @@
"devDependencies": {
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.57.0",
"@react-router/dev": "^7.11.0",
"@react-router/dev": "^7.12.0",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/eslint-plugin-query": "^5.91.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/node": "^25.0.7",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/coverage-v8": "^4.0.17",
"cross-env": "^10.1.0",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
@@ -104,7 +104,7 @@
"eslint-plugin-i18next": "^6.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.2.0",
@@ -116,7 +116,7 @@
"tailwindcss": "^4.1.8",
"typescript": "^5.9.3",
"vite-plugin-svgr": "^4.5.0",
"vite-tsconfig-paths": "^6.0.3",
"vite-tsconfig-paths": "^6.0.4",
"vitest": "^4.0.14"
},
"packageManager": "npm@10.5.0",

View File

@@ -7,6 +7,7 @@ export interface GetConfigResponse {
POSTHOG_CLIENT_KEY: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
RECAPTCHA_SITE_KEY?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;

View File

@@ -0,0 +1,7 @@
<svg width="1365" height="1365" viewBox="0 -24 148 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M71.7542 16.863V2.97414C71.7542 1.82355 72.6872 0.890503 73.8378 0.890503C74.9884 0.890503 75.9214 1.82355 75.9214 2.97414V16.863C75.9214 18.0136 74.9884 18.9466 73.8378 18.9466C72.6872 18.9466 71.7542 18.0136 71.7542 16.863Z" fill="white"/>
<path d="M82.5272 18.9329L89.4716 6.90477C90.0469 5.90832 91.3215 5.5668 92.3179 6.1421C93.3144 6.7174 93.6559 7.99197 93.0806 8.98841L86.1362 21.0165C85.5609 22.0129 84.2863 22.3545 83.2899 21.7792C82.2934 21.2039 81.9519 19.9293 82.5272 18.9329Z" fill="white"/>
<path d="M65.1481 18.9329L58.2037 6.90477C57.6284 5.90832 56.3538 5.5668 55.3574 6.1421C54.3609 6.7174 54.0194 7.99197 54.5947 8.98841L61.5391 21.0165C62.1144 22.0129 63.389 22.3545 64.3854 21.7792C65.3819 21.2039 65.7234 19.9293 65.1481 18.9329Z" fill="white"/>
<path d="M140.606 62.0292C140.606 58.409 141.583 47.6748 141.89 44.1323C142.097 41.7374 141.809 40.4247 141.424 39.7542C141.141 39.2626 140.699 38.915 139.634 38.8436C138.865 38.7921 138.027 39.0114 137.401 39.5761C136.814 40.1052 136.159 41.1682 136.159 43.3176L136.155 43.4388L135.198 59.758C135.164 60.3451 134.883 60.8911 134.424 61.2599C133.966 61.6284 133.374 61.7859 132.793 61.6941L122.764 60.1068L111.948 58.6703C110.949 58.5376 110.188 57.7084 110.142 56.7016L109.561 44.1323C109.535 43.621 109.51 43.1141 109.484 42.6146C109.241 37.9294 109.022 33.7805 109.022 32.4282C109.022 28.3859 108.338 26.6806 107.74 25.9634C107.263 25.3915 106.577 25.1402 105.11 25.1402C104.583 25.1402 104.212 25.2481 103.933 25.4111C103.659 25.5714 103.346 25.8587 103.049 26.4208C102.41 27.6257 101.945 29.891 102.118 33.8479C102.342 38.9804 102.692 42.8146 103.035 46.2718C103.377 49.7231 103.718 52.8561 103.908 56.4971C104.204 62.1966 104.178 66.1256 103.945 68.7924C103.828 70.124 103.656 71.1996 103.423 72.0501C103.202 72.8558 102.871 73.6757 102.296 74.2887C101.6 75.0303 100.608 75.3844 99.577 75.136C98.7592 74.9389 98.1847 74.4215 97.8706 74.0916C97.2141 73.4017 96.7501 72.5106 96.568 72.0512C95.5097 69.3812 92.2352 63.1808 87.8023 59.6811C86.5089 58.6599 85.5666 58.3652 84.9736 58.3204C84.4148 58.2783 84.0094 58.4436 83.6909 58.6967C83.34 58.9756 83.0781 59.3811 82.9479 59.7643C82.9019 59.8999 82.8823 59.9968 82.8741 60.0584C84.0759 62.0865 88.8421 69.5222 91.0896 77.069C92.7648 82.6941 96.8038 88.4259 99.8194 90.8809C102.74 93.258 107.988 94.7313 113.9 95.0218C119.756 95.3095 125.788 94.4121 130.033 92.5092C138.233 88.8334 139.903 80.7382 140.651 77.2292C141.232 74.5057 141.243 71.5987 141.087 68.9009C141.01 67.5551 140.894 66.2969 140.793 65.1373C140.695 64.0105 140.606 62.9215 140.606 62.0292ZM120.986 27.0953C120.986 25.8314 120.648 24.7049 120.089 23.9514C119.583 23.27 118.84 22.7987 117.646 22.7984C116.668 22.7982 116.011 22.9187 115.546 23.1167C115.13 23.2943 114.781 23.5699 114.463 24.0831C113.73 25.2671 113.192 27.6455 113.189 32.384L113.721 43.9088C113.91 47.5661 114.106 51.4922 114.235 54.7707L120.986 55.6666V27.0953ZM125.153 56.2652L131.172 57.218L131.992 43.267V32.5083C131.992 31.031 131.39 30.1275 130.678 29.5489C129.884 28.9039 128.957 28.6731 128.519 28.6731C127.722 28.6731 126.899 28.797 126.306 29.2179C125.849 29.5421 125.153 30.3087 125.153 32.5083V56.2652ZM136.159 35.4278C137.406 34.8069 138.74 34.6083 139.912 34.6868C142.037 34.8292 143.91 35.718 145.037 37.6779C146.06 39.4592 146.273 41.8136 146.041 44.4927C145.72 48.1949 144.772 58.6457 144.772 62.0292C144.772 62.708 144.843 63.6116 144.944 64.7758C145.042 65.907 145.165 67.2389 145.247 68.6606C145.411 71.4987 145.422 74.8383 144.727 78.0987C144.002 81.4953 142.041 91.6918 131.738 96.3108C126.731 98.5551 120.002 99.4936 113.696 99.1838C107.445 98.8767 101.128 97.3189 97.1887 94.1122C93.4809 91.0937 88.9938 84.6307 87.0962 78.2589C84.9529 71.0619 80.3109 63.9646 79.1527 61.9533C78.4706 60.7689 78.684 59.3628 79.0019 58.4258C79.3607 57.3688 80.0554 56.2631 81.0993 55.4337C82.1758 54.5784 83.6043 54.0377 85.2876 54.1647C86.9369 54.2893 88.6462 55.0393 90.3834 56.4107C94.8541 59.9401 98.1342 65.5082 99.7424 68.9231C99.759 68.7664 99.779 68.6024 99.7941 68.4298C100.003 66.0435 100.039 62.3344 99.7467 56.7132C99.5635 53.1942 99.2356 50.1809 98.8888 46.6828C98.5425 43.1904 98.184 39.2713 97.955 34.0302C97.7722 29.8481 98.2012 26.6722 99.3672 24.471C99.9716 23.3302 100.79 22.4223 101.83 21.814C102.866 21.2087 103.995 20.974 105.11 20.974C106.759 20.974 108.813 21.2062 110.448 22.7678C110.593 22.4576 110.75 22.1652 110.921 21.8899C111.676 20.6698 112.681 19.8084 113.912 19.2835C115.095 18.7791 116.378 18.6309 117.646 18.6311C120.195 18.6315 122.164 19.7567 123.434 21.4683C124.256 22.576 124.75 23.8775 124.985 25.1982C126.338 24.5876 127.691 24.5068 128.519 24.5068C129.933 24.5068 131.784 25.0791 133.305 26.3154C134.908 27.6179 136.159 29.6733 136.159 32.5083V35.4278Z" fill="white"/>
<path d="M7.15661 62.0292C7.15661 58.409 6.17994 47.6748 5.87291 44.1323C5.6654 41.7374 5.95357 40.4247 6.33875 39.7542C6.62116 39.2626 7.06336 38.915 8.12834 38.8436C8.89759 38.7921 9.73544 39.0114 10.3616 39.5761C10.9484 40.1052 11.6032 41.1682 11.6032 43.3176L11.6074 43.4388L12.5644 59.758C12.5988 60.3451 12.8798 60.8911 13.338 61.2599C13.7961 61.6284 14.3887 61.7859 14.9695 61.6941L24.9988 60.1068L35.8143 58.6703C36.8135 58.5376 37.5741 57.7084 37.6208 56.7016L38.2015 44.1323C38.2279 43.621 38.2525 43.1141 38.2784 42.6146C38.5218 37.9294 38.7401 33.7805 38.7401 32.4282C38.7401 28.3859 39.4246 26.6806 40.0227 25.9634C40.4996 25.3915 41.185 25.1402 42.6523 25.1402C43.1794 25.1402 43.5505 25.2481 43.8295 25.4111C44.1038 25.5714 44.416 25.8587 44.7138 26.4208C45.3521 27.6257 45.8173 29.891 45.6444 33.8479C45.4201 38.9804 45.0703 42.8146 44.7275 46.2718C44.3853 49.7231 44.0443 52.8561 43.8548 56.4971C43.5582 62.1966 43.5847 66.1256 43.8179 68.7924C43.9344 70.124 44.1069 71.1996 44.3396 72.0501C44.5601 72.8558 44.891 73.6757 45.4663 74.2887C46.1625 75.0303 47.1546 75.3844 48.1855 75.136C49.0033 74.9389 49.5778 74.4215 49.8918 74.0916C50.5484 73.4017 51.0123 72.5106 51.1945 72.0512C52.2527 69.3812 55.5272 63.1808 59.9601 59.6811C61.2536 58.6599 62.1958 58.3652 62.7889 58.3204C63.3476 58.2783 63.753 58.4436 64.0715 58.6967C64.4225 58.9756 64.6844 59.3811 64.8146 59.7643C64.8606 59.8999 64.8801 59.9968 64.8883 60.0584C63.6866 62.0865 58.9204 69.5222 56.6729 77.069C54.9977 82.6941 50.9586 88.4259 47.9431 90.8809C45.0229 93.258 39.7747 94.7313 33.8624 95.0218C28.0068 95.3095 21.9748 94.4121 17.7297 92.5092C9.52988 88.8334 7.85961 80.7382 7.11129 77.2292C6.53054 74.5057 6.5195 71.5987 6.67496 68.9009C6.75251 67.5551 6.86809 66.2969 6.96901 65.1373C7.06707 64.0105 7.1566 62.9215 7.15661 62.0292ZM26.7768 27.0953C26.7768 25.8314 27.1147 24.7049 27.6737 23.9514C28.1792 23.27 28.9221 22.7987 30.1167 22.7984C31.0942 22.7982 31.7518 22.9187 32.2162 23.1167C32.6326 23.2943 32.9817 23.5699 33.2996 24.0831C34.0328 25.2671 34.5705 27.6455 34.5738 32.384L34.0416 43.9088C33.8524 47.5661 33.6565 51.4922 33.5273 54.7707L26.7768 55.6666V27.0953ZM22.6095 56.2652L16.5904 57.218L15.7705 43.267V32.5083C15.7705 31.031 16.3726 30.1275 17.0847 29.5489C17.8785 28.9039 18.8058 28.6731 19.2432 28.6731C20.0404 28.6731 20.8634 28.797 21.4565 29.2179C21.9131 29.5421 22.6095 30.3087 22.6095 32.5083V56.2652ZM11.6032 35.4278C10.3568 34.8069 9.02265 34.6083 7.8501 34.6868C5.72541 34.8292 3.85197 35.718 2.72584 37.6779C1.70247 39.4592 1.48924 41.8136 1.72143 44.4927C2.0423 48.1949 2.99038 58.6457 2.99038 62.0292C2.99037 62.708 2.91991 63.6116 2.81859 64.7758C2.72014 65.907 2.59699 67.2389 2.51505 68.6606C2.3515 71.4987 2.34041 74.8383 3.0357 78.0987C3.76005 81.4953 5.72154 91.6918 16.0245 96.3108C21.0311 98.5551 27.7601 99.4936 34.0669 99.1838C40.3172 98.8767 46.6346 97.3189 50.5737 94.1122C54.2816 91.0937 58.7686 84.6307 60.6662 78.2589C62.8095 71.0619 67.4515 63.9646 68.6098 61.9533C69.2919 60.7689 69.0785 59.3628 68.7605 58.4258C68.4018 57.3688 67.707 56.2631 66.6632 55.4337C65.5867 54.5784 64.1582 54.0377 62.4748 54.1647C60.8256 54.2893 59.1162 55.0393 57.379 56.4107C52.9083 59.9401 49.6283 65.5082 48.02 68.9231C48.0034 68.7664 47.9835 68.6024 47.9684 68.4298C47.7597 66.0435 47.7232 62.3344 48.0158 56.7132C48.1989 53.1942 48.5269 50.1809 48.8737 46.6828C49.22 43.1904 49.5784 39.2713 49.8075 34.0302C49.9903 29.8481 49.5612 26.6722 48.3952 24.471C47.7909 23.3302 46.9729 22.4223 45.9321 21.814C44.8964 21.2087 43.7676 20.974 42.6523 20.974C41.0038 20.974 38.9497 21.2062 37.3141 22.7678C37.1698 22.4576 37.0124 22.1652 36.8419 21.8899C36.0863 20.6698 35.0817 19.8084 33.8508 19.2835C32.6679 18.7791 31.3849 18.6309 30.1167 18.6311C27.5677 18.6315 25.5986 19.7567 24.3285 21.4683C23.5066 22.576 23.0121 23.8775 22.7771 25.1982C21.4247 24.5876 20.0718 24.5068 19.2432 24.5068C17.8298 24.5068 15.9788 25.0791 14.4573 26.3154C12.8542 27.6179 11.6032 29.6733 11.6032 32.5083V35.4278Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1,207 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
import { useAuthUrl } from "#/hooks/use-auth-url";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
import { useRecaptcha } from "#/hooks/use-recaptcha";
import { useConfig } from "#/hooks/query/use-config";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
export interface LoginContentProps {
githubAuthUrl: string | null;
appMode?: GetConfigResponse["APP_MODE"] | null;
authUrl?: GetConfigResponse["AUTH_URL"];
providersConfigured?: Provider[];
emailVerified?: boolean;
hasDuplicatedEmail?: boolean;
recaptchaBlocked?: boolean;
}
export function LoginContent({
githubAuthUrl,
appMode,
authUrl,
providersConfigured,
emailVerified = false,
hasDuplicatedEmail = false,
recaptchaBlocked = false,
}: LoginContentProps) {
const { t } = useTranslation();
const { trackLoginButtonClick } = useTracking();
const { data: config } = useConfig();
// reCAPTCHA - only need token generation, verification happens at backend callback
const { isReady: recaptchaReady, executeRecaptcha } = useRecaptcha({
siteKey: config?.RECAPTCHA_SITE_KEY,
});
const gitlabAuthUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "gitlab",
authUrl,
});
const bitbucketAuthUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "bitbucket",
authUrl,
});
const handleAuthRedirect = async (
redirectUrl: string,
provider: Provider,
) => {
trackLoginButtonClick({ provider });
if (!config?.RECAPTCHA_SITE_KEY || !recaptchaReady) {
// No reCAPTCHA or token generation failed - redirect normally
window.location.href = redirectUrl;
return;
}
// If reCAPTCHA is configured, encode token in OAuth state
try {
const token = await executeRecaptcha("LOGIN");
if (token) {
const url = new URL(redirectUrl);
const currentState =
url.searchParams.get("state") || window.location.origin;
// Encode state with reCAPTCHA token for backend verification
const stateData = {
redirect_url: currentState,
recaptcha_token: token,
};
url.searchParams.set("state", btoa(JSON.stringify(stateData)));
window.location.href = url.toString();
}
} catch (err) {
displayErrorToast(t(I18nKey.AUTH$RECAPTCHA_BLOCKED));
}
};
const handleGitHubAuth = () => {
if (githubAuthUrl) {
handleAuthRedirect(githubAuthUrl, "github");
}
};
const handleGitLabAuth = () => {
if (gitlabAuthUrl) {
handleAuthRedirect(gitlabAuthUrl, "gitlab");
}
};
const handleBitbucketAuth = () => {
if (bitbucketAuthUrl) {
handleAuthRedirect(bitbucketAuthUrl, "bitbucket");
}
};
const showGithub =
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("github");
const showGitlab =
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("gitlab");
const showBitbucket =
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("bitbucket");
const noProvidersConfigured =
!providersConfigured || providersConfigured.length === 0;
const buttonBaseClasses =
"w-[301.5px] h-10 rounded p-2 flex items-center justify-center cursor-pointer transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed";
const buttonLabelClasses = "text-sm font-medium leading-5 px-1";
return (
<div
className="flex flex-col items-center w-full gap-12.5"
data-testid="login-content"
>
<div>
<OpenHandsLogoWhite width={106} height={72} />
</div>
<h1 className="text-[39px] leading-5 font-medium text-white text-center">
{t(I18nKey.AUTH$LETS_GET_STARTED)}
</h1>
{emailVerified && (
<p className="text-sm text-muted-foreground text-center">
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)}
</p>
)}
{hasDuplicatedEmail && (
<p className="text-sm text-danger text-center">
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
</p>
)}
{recaptchaBlocked && (
<p className="text-sm text-danger text-center max-w-125">
{t(I18nKey.AUTH$RECAPTCHA_BLOCKED)}
</p>
)}
<div className="flex flex-col items-center gap-3">
{noProvidersConfigured ? (
<div className="text-center p-4 text-muted-foreground">
{t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)}
</div>
) : (
<>
{showGithub && (
<button
type="button"
onClick={handleGitHubAuth}
className={`${buttonBaseClasses} bg-[#9E28B0] text-white`}
>
<GitHubLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
</span>
</button>
)}
{showGitlab && (
<button
type="button"
onClick={handleGitLabAuth}
className={`${buttonBaseClasses} bg-[#FC6B0E] text-white`}
>
<GitLabLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
</span>
</button>
)}
{showBitbucket && (
<button
type="button"
onClick={handleBitbucketAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</span>
</button>
)}
</>
)}
</div>
<TermsAndPrivacyNotice className="max-w-[320px] text-[#A3A3A3]" />
</div>
);
}

View File

@@ -21,6 +21,7 @@ import { useAgentState } from "#/hooks/use-agent-state";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ChatMessagesSkeleton } from "./chat-messages-skeleton";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
@@ -49,6 +50,8 @@ import {
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
import ChatStatusIndicator from "./chat-status-indicator";
import { getStatusColor, getStatusText } from "#/utils/utils";
function getEntryPoint(
hasRepository: boolean | null,
@@ -65,7 +68,7 @@ export function ChatInterface() {
const { data: conversation } = useActiveConversation();
const { errorMessage } = useErrorMessageStore();
const { isLoadingMessages } = useWsClient();
const { isTask } = useTaskPolling();
const { isTask, taskStatus, taskDetail } = useTaskPolling();
const conversationWebSocket = useConversationWebSocket();
const { send } = useSendMessage();
const storeEvents = useEventStore((state) => state.events);
@@ -122,6 +125,13 @@ export function ChatInterface() {
prevV1LoadingRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory]);
const isReturningToConversation = !!params.conversationId;
const isHistoryLoading =
(isLoadingMessages && !isV1Conversation) ||
(isV1Conversation &&
(conversationWebSocket?.isLoadingHistory || !showV1Messages));
const isChatLoading = isHistoryLoading && !isTask;
// Filter V0 events
const v0Events = storeEvents
.filter(isV0Event)
@@ -235,12 +245,38 @@ export function ChatInterface() {
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
// Get server status indicator props
const isStartingStatus =
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
const isStopStatus = curAgentState === AgentState.STOPPED;
const isPausing = curAgentState === AgentState.PAUSED;
const serverStatusColor = getStatusColor({
isPausing,
isTask,
taskStatus,
isStartingStatus,
isStopStatus,
curAgentState,
});
const serverStatusText = getStatusText({
isPausing,
isTask,
taskStatus,
taskDetail,
isStartingStatus,
isStopStatus,
curAgentState,
errorMessage,
t,
});
return (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between pr-0 md:pr-4 relative">
{!hasSubstantiveAgentActions &&
!optimisticUserMessage &&
!userEventsExist && (
!userEventsExist &&
!isChatLoading && (
<ChatSuggestions
onSuggestionsClick={(message) => setMessageToSend(message)}
/>
@@ -250,22 +286,18 @@ export function ChatInterface() {
<div
ref={scrollRef}
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"
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{isLoadingMessages && !isV1Conversation && !isTask && (
<div className="flex justify-center">
{isChatLoading && isReturningToConversation && (
<ChatMessagesSkeleton />
)}
{isChatLoading && !isReturningToConversation && (
<div className="flex justify-center" data-testid="loading-spinner">
<LoadingSpinner size="small" />
</div>
)}
{(conversationWebSocket?.isLoadingHistory || !showV1Messages) &&
isV1Conversation &&
!isTask && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{!isLoadingMessages && v0UserEventsExist && (
<V0Messages
messages={v0Events}
@@ -282,8 +314,14 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px]">
<div className="flex justify-between relative">
<div className="flex items-center gap-1">
<div className="flex items-end gap-1">
<ConfirmationModeEnabled />
{isStartingStatus && (
<ChatStatusIndicator
statusColor={serverStatusColor}
status={serverStatusText}
/>
)}
{totalEvents > 0 && !isV1Conversation && (
<TrajectoryActions
onPositiveFeedback={() =>

View File

@@ -0,0 +1,37 @@
import React from "react";
const SKELETON_PATTERN = [
{ width: "w-[25%]", height: "h-4", align: "justify-end" },
{ width: "w-[60%]", height: "h-4", align: "justify-start" },
{ width: "w-[45%]", height: "h-4", align: "justify-start" },
{ width: "w-[65%]", height: "h-20", align: "justify-start" },
{ width: "w-[35%]", height: "h-4", align: "justify-end" },
{ width: "w-[50%]", height: "h-4", align: "justify-start" },
{ width: "w-[30%]", height: "h-4", align: "justify-end" },
{ width: "w-[75%]", height: "h-4", align: "justify-start" },
{ width: "w-[55%]", height: "h-4", align: "justify-start" },
];
function SkeletonBlock({ width, height }: { width: string; height: string }) {
return (
<div
className={`rounded-md bg-foreground/5 animate-pulse ${width} ${height}`}
/>
);
}
export function ChatMessagesSkeleton() {
return (
<div
className="flex flex-col gap-6 p-4 w-full h-full overflow-hidden"
data-testid="chat-messages-skeleton"
aria-label="Loading conversation"
>
{SKELETON_PATTERN.map((item, i) => (
<div key={i} className={`flex w-full ${item.align}`}>
<SkeletonBlock width={item.width} height={item.height} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { cn } from "@heroui/react";
import { motion, AnimatePresence } from "framer-motion";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
interface ChatStatusIndicatorProps {
status: string;
statusColor: string;
}
function ChatStatusIndicator({
status,
statusColor,
}: ChatStatusIndicatorProps) {
return (
<div
data-testid="chat-status-indicator"
className={cn(
"h-[31px] w-fit rounded-[100px] pt-[20px] pr-[16px] pb-[20px] pl-[5px] bg-[#25272D] flex items-center",
)}
>
<AnimatePresence mode="wait">
{/* Dot */}
<motion.span
key={`dot-${status}`}
className="animate-[pulse_1.2s_ease-in-out_infinite]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<DebugStackframeDot
className="w-6 h-6 shrink-0"
color={statusColor}
/>
</motion.span>
{/* Text */}
<motion.span
key={`text-${status}`}
initial={{ opacity: 0, y: -2 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 2 }}
transition={{ duration: 0.3 }}
className="font-normal text-[11px] leading-[20px] normal-case"
>
{status}
</motion.span>
</AnimatePresence>
</div>
);
}
export default ChatStatusIndicator;

View File

@@ -1,11 +1,10 @@
import { useTranslation } from "react-i18next";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { getStatusColor } from "#/utils/utils";
import { getStatusColor, getStatusText } from "#/utils/utils";
import { useErrorMessageStore } from "#/stores/error-message-store";
export interface ServerStatusProps {
@@ -20,13 +19,12 @@ export function ServerStatus({
isPausing = false,
}: ServerStatusProps) {
const { curAgentState } = useAgentState();
const { t } = useTranslation();
const { isTask, taskStatus, taskDetail } = useTaskPolling();
const { t } = useTranslation();
const { errorMessage } = useErrorMessageStore();
const isStartingStatus =
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
const isStopStatus = conversationStatus === "STOPPED";
const statusColor = getStatusColor({
@@ -38,45 +36,17 @@ export function ServerStatus({
curAgentState,
});
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);
}
if (isStopStatus) {
return t(I18nKey.COMMON$SERVER_STOPPED);
}
if (curAgentState === AgentState.ERROR) {
return errorMessage || t(I18nKey.COMMON$ERROR);
}
return t(I18nKey.COMMON$RUNNING);
};
const statusText = getStatusText();
const statusText = getStatusText({
isPausing,
isTask,
taskStatus,
taskDetail,
isStartingStatus,
isStopStatus,
curAgentState,
errorMessage,
t,
});
return (
<div className={className} data-testid="server-status">

View File

@@ -0,0 +1,12 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { ConversationCardSkeleton } from "./conversation-card-skeleton";
describe("ConversationCardSkeleton", () => {
it("renders skeleton card", () => {
render(<ConversationCardSkeleton />);
expect(
screen.getByTestId("conversation-card-skeleton"),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,24 @@
import React from "react";
export function ConversationCardSkeleton() {
return (
<div
data-testid="conversation-card-skeleton"
className="relative h-auto w-full p-3.5 border-b border-neutral-600"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full">
<div className="skeleton-round h-1.5 w-1.5" />
<div className="skeleton h-3 w-2/3 rounded" />
</div>
</div>
<div className="mt-2 flex flex-col gap-1">
<div className="skeleton h-2 w-1/2 rounded" />
<div className="flex justify-between">
<div className="skeleton h-2 w-1/4 rounded" />
<div className="skeleton h-2 w-8 rounded" />
</div>
</div>
</div>
);
}

View File

@@ -30,6 +30,10 @@ export function ConversationCardTitle({
onSave(trimmed);
}}
onKeyUp={(event: React.KeyboardEvent<HTMLInputElement>) => {
// Ignore Enter key during IME composition (e.g., Chinese, Japanese, Korean input)
if (event.nativeEvent.isComposing) {
return;
}
if (event.key === "Enter") {
event.currentTarget.blur();
}

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