Compare commits

...

35 Commits

Author SHA1 Message Date
chuckbutkus d01c74e985 Lint fixes 2026-04-08 01:26:37 -04:00
openhands 371b226bcd feat: add /api/me endpoint for user info with role and permissions
Add a new GET endpoint at /api/me that returns authenticated user information
including their role and permissions in their current organization.

The endpoint:
- Uses SaasUserAuth to authenticate via API key or cookie
- Returns user_id, email, org_id, org_name, role, and permissions
- Uses authorization.py's get_user_org_role and get_role_permissions
- Returns 401 for unauthenticated requests
- Returns 404 if user or organization not found

Also adds unit tests for the new endpoint covering:
- Unauthenticated access
- User not found scenario
- Successful retrieval with permissions
- User with no role in organization

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 04:51:46 +00:00
Tim O'Farrell 754a96e7f3 chore(frontend): remove unused hooks and code (#13810)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 13:10:19 -06:00
Tim O'Farrell 211b73a088 Refactor conversation list to use V1 API (#13803)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 12:35:11 -06:00
Hiep Le 54041dd093 feat: remove ENABLE_ORG_CLAIMS_RESOLVER_ROUTING feature flag (#13809) 2026-04-08 00:55:36 +07:00
Hiep Le f271346724 feat(backend): route Jira resolver conversations to claimed org workspaces (#13805) 2026-04-07 23:58:52 +07:00
Hiep Le d6a0dd7fe4 feat(backend): route Linear resolver conversations to claimed org workspaces (#13804) 2026-04-07 23:22:48 +07:00
Tim O'Farrell e46bcfa82f Add V1 API endpoints for git search and branches (#13794)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 06:52:56 -06:00
Tim O'Farrell 2eefa5edfd Deprecate /api/options/models, add /api/v1/config/models/search endpoint (#13799)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 06:51:49 -06:00
Ray Myers 54858c0fc0 ci: retire Blacksmith from all GitHub Actions workflows (#13795)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 16:51:09 -05:00
Rohit Malhotra 384c324652 fix(slack): immediately display 'No Repository' option (#13791)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 14:21:59 -04:00
Tim O'Farrell 4e68f57807 Add V1 git routes with pagination for installations and repositories (#13790)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 12:01:22 -06:00
Jamie Chicago 649ebc4078 Succinct pr template (#13779)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 19:05:24 +02:00
Tim O'Farrell e3246c27d4 Added new v1 endpoint for user git info and deprecated old endpoint (#13787)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 09:54:24 -06:00
Ray Myers 72194f19db chore: Add sdk to mypy checking and fix the resulting errors (#13637)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-04-06 11:43:31 -04:00
gpothier 0c5e30ab33 Add KVM device passthrough support for hardware virtualization (#13618)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-04-06 14:57:58 +00:00
simonrosenberg b8f2932b02 fix(security): redact credentials from MCP config logging (#13720)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 08:46:42 -06:00
dependabot[bot] 62673c028a chore(deps): bump the version-all group across 1 directory with 7 updates (#13774)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-04-06 08:39:09 -06:00
Hiep Le 7af2285fe6 fix(backend): custom API key overwritten when using non-OpenHands provider in basic view (#13785) 2026-04-06 21:14:14 +07:00
Hiep Le 69d281c6be fix(frontend): prevent budget/credit error banner from disappearing immediately (#13786) 2026-04-06 21:13:30 +07:00
Jamie Chicago 8ce3089a68 Add contributors section to README (#13696)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-04 01:27:45 +02:00
Tim O'Farrell b9b10ebf5e APP-1197 Mark conversation endpoints as deprecated with updated docs (#13775)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 14:45:32 -06:00
Tim O'Farrell ce6d5b77c4 Add more endpoints as deprecated (microagent repository endpoints) (#13776)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 20:45:14 +00:00
simonrosenberg a458c9b785 Fix credential leak in callback event logging (#13718)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:29:26 +00:00
Hiep Le a65ddc3db6 feat(backend): route Slack resolver conversations to claimed org workspaces (#13758) 2026-04-04 03:09:21 +07:00
Tim O'Farrell 732a1c1991 APP-1197 Migrate secrets endpoints to V1 API (#13770)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 14:06:51 -06:00
Hiep Le d058323a87 feat(backend): route gitlab resolver conversations to claimed org workspaces (#13755) 2026-04-04 02:27:46 +07:00
aivong-openhands 7d04cffe4e Fix CVE-2026-25645: Update requests to 2.33.1 (#13692)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-03 13:55:31 -05:00
Hiep Le 6ad27b77bb feat(backend): route resolver conversations to claimed org workspaces (#13713) 2026-04-04 01:32:43 +07:00
aivong-openhands 2739fc8fbe Fix CVE-2026-22815: Update aiohttp to 3.13.5 (#13705)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-03 13:21:05 -05:00
dependabot[bot] 38b7e10252 chore(deps): bump the security-all group across 1 directory with 2 updates (#13764)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 11:46:46 -05:00
mamoodi 7b7d1c0c55 Update CODEOWNERS (#13762) 2026-04-03 12:01:58 -04:00
Tim O'Farrell e38eda4ac9 APP-1197 Migrate settings endpoints to V1 API (/api/v1/settings) (#13759)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 09:38:24 -06:00
aivong-openhands 99c19b6ef0 enterprise lock update openhands aci to version already in openhands (#13704)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 09:57:14 -04:00
Jathin Sreenivas 0731e8c68a feat(frontend): Display LLM model on conversation cards and header (#13616)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-03 17:57:37 +07:00
163 changed files with 6537 additions and 2284 deletions
+3 -4
View File
@@ -1,8 +1,7 @@
# CODEOWNERS file for OpenHands repository
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
/frontend/ @amanape @hieptl
/openhands-ui/ @amanape @hieptl
/frontend/ @hieptl
/openhands-ui/ @hieptl
/openhands/ @tofarr @malhotra5 @hieptl
/enterprise/ @chuckbutkus @tofarr @malhotra5
/evaluation/ @xingyaoww @neubig
/enterprise/ @chuckbutkus @tofarr @malhotra5 @jlav @aivong-openhands
+35 -27
View File
@@ -1,38 +1,46 @@
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
<!-- Keep this PR as draft until it is ready for review. -->
## Summary of PR
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
<!-- Summarize what the PR does -->
- [ ] A human has tested these changes.
## 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. -->
## Why
## Change Type
<!-- Describe problem, motivation, etc.-->
<!-- Choose the types that apply to your PR -->
## Summary
<!-- 1-3 bullets describing what changed. -->
-
## Issue Number
<!-- Required if there is a relevant issue to this PR. -->
## How to Test
<!--
Required. Share the steps for the reviewer to be able to test your PR. e.g. You can test by running `npm install` then `npm build dev`.
If you could not test this, say why.
-->
## Video/Screenshots
<!--
Provide a video or screenshots of testing your PR. e.g. you added a new feature to the gui, show us the video of you testing it successfully.
-->
## Type
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Feature
- [ ] Refactor
- [ ] Other (dependency update, docs, typo fixes, etc.)
- [ ] Breaking change
- [ ] Docs / chore
## Checklist
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
## Notes
- [ ] I have read and reviewed the code and I understand what the code is doing.
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
## Fixes
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
Resolves #(issue)
## Release Notes
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
end-user friendly description for your change below the checkbox. -->
- [ ] Include this change in the Release Notes.
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->
+4 -2
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
fe-e2e-test:
name: FE E2E Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [22]
@@ -26,9 +26,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
+4 -2
View File
@@ -21,7 +21,7 @@ jobs:
# Run frontend unit tests
fe-test:
name: FE Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [22]
@@ -30,9 +30,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
+10 -10
View File
@@ -30,7 +30,7 @@ env:
jobs:
define-matrix:
runs-on: blacksmith
runs-on: ubuntu-latest
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
platforms: ${{ steps.define-base-images.outputs.platforms }}
@@ -57,7 +57,7 @@ jobs:
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
needs: define-matrix
permissions:
@@ -92,7 +92,7 @@ jobs:
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
@@ -122,7 +122,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: poetry
@@ -149,7 +149,7 @@ jobs:
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ env.DOCKER_TAGS }}
@@ -163,7 +163,7 @@ jobs:
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
@@ -176,7 +176,7 @@ jobs:
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: read
packages: write
@@ -229,7 +229,7 @@ jobs:
# rather than a mutable branch tag like "main" which can serve stale cached layers.
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
context: .
file: enterprise/Dockerfile
@@ -248,7 +248,7 @@ jobs:
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -257,7 +257,7 @@ jobs:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
needs: [ghcr_build_runtime]
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
+8 -7
View File
@@ -9,7 +9,7 @@ jobs:
lint-fix-frontend:
if: github.event.label.name == 'lint-fix'
name: Fix frontend linting issues
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: write
pull-requests: write
@@ -22,13 +22,14 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: |
cd frontend
npm install --frozen-lockfile
working-directory: ./frontend
run: npm ci
- name: Generate i18n and route types
run: |
cd frontend
@@ -58,7 +59,7 @@ jobs:
lint-fix-python:
if: github.event.label.name == 'lint-fix'
name: Fix Python linting issues
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: write
pull-requests: write
@@ -71,7 +72,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"
+11 -10
View File
@@ -19,34 +19,35 @@ jobs:
# Run lint on the frontend code
lint-frontend:
name: Lint frontend
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: |
cd frontend
npm install --frozen-lockfile
working-directory: ./frontend
run: npm ci
- name: Lint, TypeScript compilation, and translation checks
run: |
cd frontend
npm run lint
npm run make-i18n && tsc
npm run make-i18n && npx tsc
npm run check-translation-completeness
# Run lint on the python code
lint-python:
name: Lint python
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"
@@ -57,13 +58,13 @@ jobs:
lint-enterprise-python:
name: Lint enterprise python
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"
+2 -2
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
check-version:
name: Check if version has changed
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
defaults:
run:
shell: bash
@@ -55,7 +55,7 @@ jobs:
publish:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
+7 -5
View File
@@ -19,7 +19,7 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
@@ -37,13 +37,15 @@ jobs:
- name: Install tmux
run: sudo apt-get update && sudo apt-get install -y tmux
- name: Setup Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -73,7 +75,7 @@ jobs:
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.12"]
@@ -82,7 +84,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
+2 -2
View File
@@ -17,14 +17,14 @@ on:
jobs:
release:
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli' and don't start with 'cloud-'
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
steps:
- uses: actions/checkout@v6
- uses: useblacksmith/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install Poetry
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
stale:
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v10
+1 -1
View File
@@ -19,7 +19,7 @@ concurrency:
jobs:
ui-build:
name: Build openhands-ui
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
+14 -1
View File
@@ -86,8 +86,19 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
<hr>
### Thank You to Our Contributors
<div align="center">
[![OpenHands Contributors](https://assets.openhands.dev/readme/openhands-openhands-contributors.svg)](https://github.com/OpenHands/OpenHands/graphs/contributors)
</div>
<hr>
### Trusted by Engineers at
<div align="center">
<strong>Trusted by engineers at</strong>
<br/><br/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
@@ -138,3 +149,5 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
</picture>
</div>
</div>
@@ -58,6 +58,8 @@ repos:
types-Markdown,
pydantic,
lxml,
"openhands-sdk==1.14",
"openhands-tools==1.14",
]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/
+8
View File
@@ -14,3 +14,11 @@ exclude = (third_party/|enterprise/)
[mypy-openhands.memory.condenser.impl.*]
disable_error_code = override
[mypy-openai.*]
follow_imports = skip
ignore_missing_imports = True
[mypy-litellm.*]
follow_imports = skip
ignore_missing_imports = True
+37 -17
View File
@@ -10,6 +10,7 @@ from integrations.github.github_types import (
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
@@ -26,6 +27,7 @@ from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.org_store import OrgStore
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -41,16 +43,14 @@ from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
@@ -154,12 +154,17 @@ class GithubIssue(ResolverViewInterface):
return user_secrets.custom_secrets if user_secrets else None
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
self.v1_enabled = await is_v1_enabled_for_github_resolver(
self.user_info.keycloak_user_id
)
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='github',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
@@ -173,16 +178,28 @@ class GithubIssue(ResolverViewInterface):
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._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
self.conversation_id = conversation_metadata.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=self.user_info.keycloak_user_id,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITHUB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -294,7 +311,10 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
github_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@@ -322,7 +342,7 @@ class GithubIssue(ResolverViewInterface):
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
},
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)
@@ -476,7 +496,7 @@ class GithubInlinePRComment(GithubPRComment):
'comment_id': self.comment_id,
},
inline_pr_comment=True,
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)
+37 -14
View File
@@ -3,6 +3,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_V1_GITLAB_RESOLVER,
@@ -14,6 +15,7 @@ from integrations.utils import (
from jinja2 import Environment
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -29,15 +31,13 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
CONFIDENTIAL_NOTE = 'confidential_note'
@@ -118,6 +118,14 @@ class GitlabIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# v1_enabled is already set at construction time in the factory method
# This is the source of truth for the conversation type
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='gitlab',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
if self.v1_enabled:
# Create dummy conversation metadata
# Don't save to conversation store
@@ -128,16 +136,28 @@ class GitlabIssue(ResolverViewInterface):
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._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITLAB,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
self.conversation_id = conversation_metadata.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=self.user_info.keycloak_user_id,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITLAB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -228,7 +248,10 @@ class GitlabIssue(ResolverViewInterface):
)
# Set up the GitLab user context for the V1 system
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
gitlab_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
async with get_app_conversation_service(
@@ -260,7 +283,7 @@ class GitlabIssue(ResolverViewInterface):
'is_mr': self.is_mr,
'discussion_id': getattr(self, 'discussion_id', None),
},
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)
+73 -14
View File
@@ -7,6 +7,7 @@ Views are responsible for:
"""
from dataclasses import dataclass, field
from uuid import uuid4
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
@@ -15,18 +16,25 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from jinja2 import Environment
from server.config import get_config
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from storage.saas_conversation_store import SaasConversationStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import create_new_conversation
from openhands.server.services.conversation_service import start_conversation
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 (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
@@ -166,20 +174,68 @@ class JiraNewConversationView(JiraViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
user_id=self.jira_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.JIRA,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
user_id = self.jira_user.keycloak_user_id
# Resolve git provider from repository
resolved_git_provider = None
if provider_tokens:
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(
self.selected_repo
)
resolved_git_provider = repository.git_provider
except Exception as e:
logger.warning(
f'[Jira] Failed to resolve git provider for {self.selected_repo}: {e}'
)
# Resolve target org based on claimed git organizations
resolved_org_id = None
if resolved_git_provider and self.selected_repo:
try:
resolved_org_id = await resolve_org_for_repo(
provider=resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=user_id,
)
except Exception as e:
logger.warning(
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
)
# Create the conversation store with resolver org routing
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
resolved_org_id,
)
self.conversation_id = agent_loop_info.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.JIRA,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_msg,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
)
self.conversation_id = conversation_id
logger.info(
'[Jira] Created conversation',
@@ -187,6 +243,9 @@ class JiraNewConversationView(JiraViewInterface):
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
'resolved_org_id': str(resolved_org_id)
if resolved_org_id
else None,
},
)
+73 -14
View File
@@ -1,25 +1,34 @@
from dataclasses import dataclass
from uuid import uuid4
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from server.config import get_config
from storage.linear_conversation import LinearConversation
from storage.linear_integration_store import LinearIntegrationStore
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from storage.saas_conversation_store import SaasConversationStore
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
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 (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
integration_store = LinearIntegrationStore.get_instance()
@@ -61,20 +70,70 @@ class LinearNewConversationView(LinearViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
user_id=self.linear_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.LINEAR,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
user_id = self.linear_user.keycloak_user_id
# Resolve git provider from repository
resolved_git_provider = None
if provider_tokens:
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(
self.selected_repo
)
resolved_git_provider = repository.git_provider
except Exception as e:
logger.warning(
f'[Linear] Failed to resolve git provider for {self.selected_repo}: {e}'
)
# Resolve target org based on claimed git organizations
resolved_org_id = None
if resolved_git_provider and self.selected_repo:
try:
resolved_org_id = await resolve_org_for_repo(
provider=resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=user_id,
)
except Exception as e:
logger.warning(
f'[Linear] Failed to resolve org for {self.selected_repo}: {e}'
)
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
resolved_org_id,
)
self.conversation_id = agent_loop_info.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.LINEAR,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_msg,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
)
self.conversation_id = conversation_id
logger.info(f'[Linear] Created conversation {self.conversation_id}')
+8 -1
View File
@@ -1,7 +1,9 @@
from uuid import UUID
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.integrations.service_types import ProviderType, UserGitInfo
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -12,8 +14,10 @@ class ResolverUserContext(UserContext):
def __init__(
self,
saas_user_auth: UserAuth,
resolver_org_id: UUID | None = None,
):
self.saas_user_auth = saas_user_auth
self.resolver_org_id = resolver_org_id
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
@@ -81,3 +85,6 @@ class ResolverUserContext(UserContext):
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()
async def get_user_git_info(self) -> UserGitInfo | None:
return await self.saas_user_auth.get_user_git_info()
@@ -0,0 +1,68 @@
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
This module provides a reusable utility for routing resolver conversations
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
workspace based on claimed Git organizations.
"""
from uuid import UUID
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
async def resolve_org_for_repo(
provider: str,
full_repo_name: str,
keycloak_user_id: str,
) -> UUID | None:
"""Determine the OpenHands org_id for a resolver conversation.
If the repo's git organization is claimed by an OpenHands org AND the user
is a member of that org, returns the claiming org's ID. Otherwise returns
None (caller should fall back to user.current_org_id / personal workspace).
Args:
provider: Git provider name ("github", "gitlab", "bitbucket")
full_repo_name: Full repository name (e.g., "OpenHands/foo")
keycloak_user_id: The user's Keycloak UUID string
Returns:
The org_id if the repo's org is claimed and user is a member, else None
"""
git_org = full_repo_name.split('/')[0].lower()
try:
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider, git_org
)
if not claim:
logger.debug(
f'[OrgResolver] No claim found for {provider}/{git_org}',
)
return None
member = await OrgMemberStore.get_org_member(
claim.org_id, UUID(keycloak_user_id)
)
if not member:
logger.debug(
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
f'{claim.org_id} (claimed {provider}/{git_org}). '
f'Falling back to personal workspace.',
)
return None
logger.info(
f'[OrgResolver] Routing conversation to org {claim.org_id} '
f'for {provider}/{git_org} (user {keycloak_user_id})',
)
return claim.org_id
except Exception as e:
logger.error(
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
exc_info=True,
)
return None
+83 -36
View File
@@ -239,12 +239,14 @@ class SlackManager(Manager[SlackViewInterface]):
def _generate_repo_selection_form(
self, message_ts: str, thread_ts: str | None
) -> list[dict[str, Any]]:
"""Generate a repo selection form using external_select for dynamic loading.
"""Generate a repo selection form with immediate "No Repository" button and search dropdown.
This uses Slack's external_select element which allows:
- Type-ahead search for repositories
- Dynamic loading of options from an external endpoint
- Support for users with many repositories (no 100 option limit)
This form provides two options side-by-side:
1. A "No Repository" button - immediately clickable without any loading
2. An external_select dropdown - for searching repositories dynamically
This design ensures "No Repository" is always immediately available while
still providing full dynamic search capability for repositories.
Args:
message_ts: The message timestamp for tracking
@@ -266,12 +268,22 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': 'Type to search your repositories:',
'text': 'Select a repository or continue without one:',
},
},
{
'type': 'actions',
'elements': [
{
'type': 'button',
'action_id': f'no_repository:{message_ts}:{thread_ts}',
'text': {
'type': 'plain_text',
'text': 'No Repository',
'emoji': True,
},
'value': '-',
},
{
'type': 'external_select',
'action_id': f'repository_select:{message_ts}:{thread_ts}',
@@ -279,8 +291,8 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'plain_text',
'text': 'Search repositories...',
},
'min_query_length': 0, # Load initial options immediately
}
'min_query_length': 0,
},
],
},
]
@@ -288,8 +300,11 @@ class SlackManager(Manager[SlackViewInterface]):
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
"""Build Slack options list from repositories.
Always includes a "No Repository" option at the top, followed by up to 99
repositories (Slack has a 100 option limit for external_select).
Returns up to 100 repositories formatted as Slack options
(Slack has a 100 option limit for external_select).
Note: "No Repository" is handled by a separate button in the form,
so it's not included in the dropdown options.
Args:
repos: List of Repository objects
@@ -297,13 +312,7 @@ class SlackManager(Manager[SlackViewInterface]):
Returns:
List of Slack option objects
"""
options: list[dict[str, Any]] = [
{
'text': {'type': 'plain_text', 'text': 'No Repository'},
'value': '-',
}
]
options.extend(
return [
{
'text': {
'type': 'plain_text',
@@ -311,9 +320,8 @@ class SlackManager(Manager[SlackViewInterface]):
},
'value': repo.full_name,
}
for repo in repos[:99] # Leave room for "No Repository" option
)
return options
for repo in repos[:100]
]
async def search_repos_for_slack(
self, user_auth: UserAuth, query: str, per_page: int = 20
@@ -363,33 +371,69 @@ class SlackManager(Manager[SlackViewInterface]):
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
)
async def receive_form_interaction(self, slack_payload: dict):
"""Process a Slack form interaction (repository selection).
def _parse_form_action(self, action: dict) -> tuple[str, str | None, str] | None:
"""Parse action payload and extract message_ts, thread_ts, and selected value.
This handles the block_actions payload when a user selects a repository
from the dropdown form. It retrieves the original user message from Redis
and delegates to receive_message for processing.
This handles the different payload structures for button clicks vs dropdown
selections in the repository selection form.
Args:
action: The action object from the Slack payload
Returns:
Tuple of (message_ts, thread_ts, selected_value) if action is recognized,
None if the action_id is unknown.
"""
action_id = action['action_id']
if action_id.startswith('no_repository:'):
# Button click - value is in 'value' field
attribs = action_id.split('no_repository:')[-1]
selected_value = action.get('value', '-')
elif action_id.startswith('repository_select:'):
# Dropdown selection - value is in 'selected_option'
attribs = action_id.split('repository_select:')[-1]
selected_value = action['selected_option']['value']
else:
return None
message_ts, thread_ts = attribs.split(':')
thread_ts = None if thread_ts == 'None' else thread_ts
return message_ts, thread_ts, selected_value
async def receive_form_interaction(self, slack_payload: dict):
"""Process a Slack form interaction (repository selection or button click).
This handles the block_actions payload when a user interacts with the
repository selection form. It can handle:
- "No Repository" button click: proceeds with conversation without a repo
- Repository selection from dropdown: proceeds with the selected repo
Args:
slack_payload: The raw Slack interaction payload
"""
# Extract fields from the Slack interaction payload
selected_repository = slack_payload['actions'][0]['selected_option']['value']
if selected_repository == '-':
selected_repository = None
action = slack_payload['actions'][0]
slack_user_id = slack_payload['user']['id']
channel_id = slack_payload['container']['channel_id']
team_id = slack_payload['team']['id']
# Get original message_ts and thread_ts from action_id
attribs = slack_payload['actions'][0]['action_id'].split('repository_select:')[
-1
]
message_ts, thread_ts = attribs.split(':')
thread_ts = None if thread_ts == 'None' else thread_ts
# Parse the action to extract message_ts, thread_ts, and selected value
parsed = self._parse_form_action(action)
if parsed is None:
logger.warning(
'slack_unknown_action_id',
extra={
'action_id': action['action_id'],
'slack_user_id': slack_user_id,
},
)
return
# Build partial payload for error handling during Redis retrieval
message_ts, thread_ts, selected_value = parsed
# Build partial payload for error handling
payload = {
'team_id': team_id,
'channel_id': channel_id,
@@ -398,6 +442,9 @@ class SlackManager(Manager[SlackViewInterface]):
'thread_ts': thread_ts,
}
# Convert "-" (No Repository) to None
selected_repository = None if selected_value == '-' else selected_value
# Retrieve the original user message from Redis
try:
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)
+58 -25
View File
@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.slack.slack_types import (
SlackMessageView,
SlackViewInterface,
@@ -17,7 +18,9 @@ from integrations.utils import (
get_user_v1_enabled_setting,
)
from jinja2 import Environment
from server.config import get_config
from slack_sdk import WebClient
from storage.saas_conversation_store import SaasConversationStore
from storage.slack_conversation import SlackConversation
from storage.slack_conversation_store import SlackConversationStore
from storage.slack_team_store import SlackTeamStore
@@ -36,18 +39,20 @@ 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, ProviderType
from openhands.integrations.provider import ProviderHandler
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
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 (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import GENERAL_TIMEOUT
from openhands.utils.conversation_summary import get_default_conversation_title
# =================================================
# SECTION: Slack view types
@@ -202,6 +207,22 @@ class SlackNewConversationView(SlackViewInterface):
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Determine git provider from repository (needed for both org routing and conversation creation)
self._resolved_git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
self._resolved_git_provider = repository.git_provider
# Resolve target org based on claimed git organizations
self.resolved_org_id = None
if self._resolved_git_provider and self.selected_repo:
self.resolved_org_id = await resolve_org_for_repo(
provider=self._resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
)
# 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
@@ -224,30 +245,44 @@ class SlackNewConversationView(SlackViewInterface):
jinja
)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
user_id = self.slack_to_openhands_user.keycloak_user_id
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
self.resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.SLACK,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=self._resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = agent_loop_info.conversation_id
self.conversation_id = conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
@@ -265,13 +300,8 @@ class SlackNewConversationView(SlackViewInterface):
# 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)
# Use git provider resolved in create_or_update_conversation
git_provider = self._resolved_git_provider
# Get the app conversation service and start the conversation
injector_state = InjectorState()
@@ -292,7 +322,10 @@ class SlackNewConversationView(SlackViewInterface):
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
slack_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(
+12 -12
View File
@@ -549,7 +549,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"},
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"},
@@ -1944,8 +1944,8 @@ files = [
[package.dependencies]
bytecode = [
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
{version = ">=0.15.1", markers = "python_version ~= \"3.12.0\""},
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
]
envier = ">=0.6.1,<0.7.0"
legacy-cgi = {version = ">=2.0.0", markers = "python_version >= \"3.13.0\""}
@@ -2994,8 +2994,8 @@ googleapis-common-protos = ">=1.63.2,<2.0.0"
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=4.25.8,<7.0.0"
requests = ">=2.20.0,<3.0.0"
@@ -3106,8 +3106,8 @@ google-auth = ">=2.47.0,<3.0.0"
google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0"
google-cloud-resource-manager = ">=1.3.3,<3.0.0"
google-cloud-storage = [
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.32.0,<4.0.0", markers = "python_version < \"3.13\""},
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
]
google-genai = {version = ">=1.59.0,<2.0.0", markers = "python_version >= \"3.10\""}
packaging = ">=14.3"
@@ -3214,8 +3214,8 @@ google-api-core = {version = ">=2.11.0,<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"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=4.25.8,<8.0.0"
@@ -3237,8 +3237,8 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpc-google-iam-v1 = ">=0.14.0,<1.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"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=4.25.8,<8.0.0"
@@ -4795,7 +4795,7 @@ description = "Fork of the standard library cgi and cgitb modules removed in Pyt
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd"},
{file = "legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577"},
@@ -6486,7 +6486,7 @@ files = []
develop = true
[package.dependencies]
aiohttp = ">=3.13.3"
aiohttp = ">=3.13.5"
anthropic = {version = "*", extras = ["vertex"]}
anyio = "4.9"
asyncpg = ">=0.30"
@@ -6554,7 +6554,7 @@ pyyaml = ">=6.0.2"
qtconsole = ">=5.6.1"
rapidfuzz = ">=3.9"
redis = ">=5.2,<7"
requests = ">=2.32.5"
requests = ">=2.33"
setuptools = ">=78.1.1"
shellingham = ">=1.5.4"
sqlalchemy = {version = ">=2.0.40", extras = ["asyncio"]}
@@ -6691,8 +6691,8 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.57,<2.0"
grpcio = [
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
]
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.39.1"
@@ -13730,7 +13730,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
@@ -13747,7 +13747,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13.0\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
+62
View File
@@ -633,6 +633,68 @@ async def logout(request: Request):
return response
@api_router.get('/me')
async def get_current_user(request: Request):
"""Get current authenticated user information including org role and permissions."""
try:
user_auth = await SaasUserAuth.get_instance(request)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_id = await user_auth.get_user_id()
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
email = await user_auth.get_user_email()
# Get user and their current org
user = await UserStore.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
from server.auth.authorization import get_role_permissions, get_user_org_role
from storage.org_store import OrgStore
# Get the current org
org = await OrgStore.get_org_by_id(user.current_org_id)
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Organization not found',
)
# Get user's role in the current org
role = await get_user_org_role(user_id, user.current_org_id)
role_name = role.name if role else None
# Get permissions for the role
permissions: list[str] = []
if role_name:
role_permissions = get_role_permissions(role_name)
permissions = [p.value for p in role_permissions]
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
'user_id': user_id,
'email': email,
'org_id': str(user.current_org_id),
'org_name': org.name,
'role': role_name,
'permissions': permissions,
},
)
@api_router.get('/refresh-tokens', response_model=TokenResponse)
async def refresh_tokens(
request: Request,
+1 -1
View File
@@ -7,8 +7,8 @@ from storage.database import a_session_maker
from storage.feedback import ConversationFeedback
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.events.event_store import EventStore
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import file_store
from openhands.server.user_auth import get_user_id
@@ -335,6 +335,9 @@ async def on_options_load(request: Request, background_tasks: BackgroundTasks):
2. Searches for repositories matching the user's query
3. Returns up to 100 options for the dropdown
Note: "No Repository" is handled by a separate button in the form, so it's
not included in the dropdown options. Error cases return an empty list.
Configuration: Set the Options Load URL in Slack App settings to:
https://your-domain/slack/on-options-load
"""
+15 -4
View File
@@ -7,6 +7,7 @@ from server.auth.token_manager import TokenManager
from storage.user_store import UserStore
from utils.identity import resolve_display_name
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
@@ -23,7 +24,6 @@ from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.routes.git import (
get_repository_branches,
get_repository_microagent_content,
@@ -45,7 +45,12 @@ saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies()
token_manager = TokenManager()
@saas_user_router.get('/installations', response_model=list[str])
@saas_user_router.get(
'/installations',
response_model=list[str],
deprecated=True,
description='Deprecated: Use `/api/v1/git/installations` instead.',
)
async def saas_get_user_installations(
provider: ProviderType,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
@@ -115,7 +120,12 @@ async def saas_get_user_git_organizations(
}
@saas_user_router.get('/repositories', response_model=list[Repository])
@saas_user_router.get(
'/repositories',
response_model=list[Repository],
deprecated=True,
description='Deprecated: Use `/api/v1/git/repositories` instead.',
)
async def saas_get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
@@ -146,12 +156,13 @@ async def saas_get_user_repositories(
)
@saas_user_router.get('/info', response_model=User)
@saas_user_router.get('/info', response_model=User, deprecated=True)
async def saas_get_user(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> User | JSONResponse:
"""Get the current user git info. Use GET /api/v1/users/git-info instead"""
if not provider_tokens:
if not access_token:
return JSONResponse(
@@ -363,6 +363,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
if api_key_org_id is not None:
org_id = api_key_org_id
# Override with resolver org_id if set (from git org claim resolution)
resolver_org_id = getattr(self.user_context, 'resolver_org_id', None)
if resolver_org_id is not None:
org_id = resolver_org_id
# Check if SAAS metadata already exists
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)
+33 -3
View File
@@ -34,10 +34,17 @@ class SaasConversationStore(ConversationStore):
session_maker: sessionmaker
org_id: UUID | None = None # will be fetched automatically
def __init__(self, user_id: str, org_id: UUID, session_maker: sessionmaker):
def __init__(
self,
user_id: str,
org_id: UUID,
session_maker: sessionmaker,
resolver_org_id: UUID | None = None,
):
self.user_id = user_id
self.org_id = org_id
self.session_maker = session_maker
self.resolver_org_id = resolver_org_id
def _select_by_id(self, session, conversation_id: str):
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
@@ -103,6 +110,13 @@ class SaasConversationStore(ConversationStore):
stored_metadata = StoredConversationMetadata(**kwargs)
# Override with resolver org_id if set (from git org claim resolution),
# same pattern as V1's save_app_conversation_info in
# saas_app_conversation_info_injector.py
org_id = self.org_id
if self.resolver_org_id is not None:
org_id = self.resolver_org_id
def _save_metadata():
with self.session_maker() as session:
# Save the main conversation metadata
@@ -122,13 +136,13 @@ class SaasConversationStore(ConversationStore):
saas_metadata = StoredConversationMetadataSaas(
conversation_id=stored_metadata.conversation_id,
user_id=UUID(self.user_id),
org_id=self.org_id,
org_id=org_id,
)
session.add(saas_metadata)
else:
# Validate
expected_user_id = UUID(self.user_id)
expected_org_id = self.org_id
expected_org_id = org_id
if saas_metadata.user_id != expected_user_id:
raise ValueError(
@@ -240,3 +254,19 @@ class SaasConversationStore(ConversationStore):
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker)
@classmethod
async def get_resolver_instance(
cls,
config: OpenHandsConfig,
user_id: str,
resolver_org_id: UUID | None = None,
) -> 'SaasConversationStore':
"""Get a store for resolver conversations with explicit org routing.
Unlike get_instance, this accepts a resolver_org_id that overrides
the user's default org when saving conversation metadata.
"""
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)
+7 -1
View File
@@ -182,7 +182,13 @@ class SaasSettingsStore(SettingsStore):
return None
# Check if we need to generate an LLM key.
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
# Only generate/verify proxy keys when the base URL is explicitly the
# LiteLLM proxy, or when it's unset and the model is an OpenHands model
# (which always needs a proxy key). For non-OpenHands models with no
# base URL (e.g. basic view BYOR), preserve the user's own API key.
if item.llm_base_url == LITE_LLM_API_URL or (
not item.llm_base_url and is_openhands_model(item.llm_model)
):
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
)
@@ -88,6 +88,7 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -144,6 +145,7 @@ class TestGithubViewV1InitialUserMessage:
]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -200,6 +202,7 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = []
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)
@@ -3,6 +3,7 @@ Tests for Jira view classes and factory.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.jira.jira_payload import (
@@ -18,6 +19,9 @@ from integrations.jira.jira_view import (
JiraNewConversationView,
)
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
class TestJiraNewConversationView:
"""Tests for JiraNewConversationView"""
@@ -86,29 +90,49 @@ class TestJiraNewConversationView:
assert 'Test Issue' in user_msg
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.create_new_conversation')
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.integration_store')
async def test_create_or_update_conversation_success(
self,
mock_store,
mock_create_conversation,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test successful conversation creation"""
new_conversation_view._issue_title = 'Test Issue'
new_conversation_view._issue_description = 'Test description'
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result == 'conv-123'
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
assert result is not None
assert isinstance(result, str)
assert len(result) == 32 # uuid4().hex format
mock_start_convo.assert_called_once()
mock_integration_store.create_conversation.assert_called_once()
@pytest.mark.asyncio
async def test_create_or_update_conversation_no_repo(
@@ -348,6 +372,125 @@ class TestJiraFactory:
)
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
class TestJiraV0ConversationRouting:
"""Test V0 conversation routing logic based on claimed git organizations."""
@pytest.fixture
def routing_view(
self,
sample_webhook_payload,
sample_jira_user,
sample_jira_workspace,
):
"""View with non-empty provider tokens for routing tests."""
user_auth = MagicMock(spec=UserAuth)
user_auth.get_provider_tokens = AsyncMock(
return_value={ProviderType.GITHUB: MagicMock()}
)
user_auth.get_secrets = AsyncMock(return_value=None)
return JiraNewConversationView(
payload=sample_webhook_payload,
saas_user_auth=user_auth,
jira_user=sample_jira_user,
jira_workspace=sample_jira_workspace,
selected_repo='test/repo1',
_issue_title='Test Issue',
_issue_description='Test description',
_decrypted_api_key='decrypted_key',
)
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.integration_store')
async def test_routes_to_claimed_org_when_user_is_member(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
routing_view,
mock_jinja_env,
):
"""When repo belongs to a claimed org and user is a member, conversation is created in that org."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
# Act
await routing_view.create_or_update_conversation(mock_jinja_env)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='test/repo1',
keycloak_user_id='test_keycloak_id',
)
call_args = mock_get_resolver_instance.call_args
assert call_args[0][1] == 'test_keycloak_id' # user_id
assert call_args[0][2] == CLAIMING_ORG_ID # resolver_org_id
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider == ProviderType.GITHUB
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.integration_store')
async def test_falls_back_to_personal_workspace_when_no_claim(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
routing_view,
mock_jinja_env,
):
"""When no org has claimed the git org, conversation goes to personal workspace."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
# Act
await routing_view.create_or_update_conversation(mock_jinja_env)
# Assert
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
class TestJiraPayloadParser:
"""Tests for JiraPayloadParser"""
@@ -73,6 +73,7 @@ def sample_user_auth():
"""Create a mock UserAuth for testing."""
user_auth = MagicMock(spec=UserAuth)
user_auth.get_provider_tokens = AsyncMock(return_value={})
user_auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
user_auth.get_access_token = AsyncMock(return_value='test_token')
user_auth.get_user_id = AsyncMock(return_value='test_user_id')
return user_auth
@@ -29,27 +29,33 @@ class TestLinearNewConversationView:
assert 'Test Issue' in user_msg
assert 'Fix this bug @openhands' in user_msg
@patch('integrations.linear.linear_view.create_new_conversation')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.integration_store')
async def test_create_or_update_conversation_success(
self,
mock_store,
mock_create_conversation,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test successful conversation creation"""
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result == 'conv-123'
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
assert result is not None
mock_start_convo.assert_called_once()
mock_integration_store.create_conversation.assert_called_once()
async def test_create_or_update_conversation_no_repo(
self, new_conversation_view, mock_jinja_env
@@ -60,12 +66,23 @@ class TestLinearNewConversationView:
with pytest.raises(StartingConvoException, match='No repository selected'):
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
@patch('integrations.linear.linear_view.create_new_conversation')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
async def test_create_or_update_conversation_failure(
self, mock_create_conversation, new_conversation_view, mock_jinja_env
self,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
):
"""Test conversation creation failure"""
mock_create_conversation.side_effect = Exception('Creation failed')
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.side_effect = Exception('Creation failed')
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
@@ -300,43 +317,57 @@ class TestLinearFactory:
class TestLinearViewEdgeCases:
"""Tests for edge cases and error scenarios"""
@patch('integrations.linear.linear_view.create_new_conversation')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.integration_store')
async def test_conversation_creation_with_no_user_secrets(
self,
mock_store,
mock_create_conversation,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when user has no secrets"""
new_conversation_view.saas_user_auth.get_secrets.return_value = None
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
new_conversation_view.saas_user_auth.get_secrets = AsyncMock(return_value=None)
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result == 'conv-123'
# Verify create_new_conversation was called with custom_secrets=None
call_kwargs = mock_create_conversation.call_args[1]
assert result is not None
# Verify start_conversation was called with custom_secrets=None
call_kwargs = mock_start_convo.call_args[1]
assert call_kwargs['custom_secrets'] is None
@patch('integrations.linear.linear_view.create_new_conversation')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.integration_store')
async def test_conversation_creation_store_failure(
self,
mock_store,
mock_create_conversation,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when store creation fails"""
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock(side_effect=Exception('Store error'))
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock(
side_effect=Exception('Store error')
)
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
@@ -32,6 +32,28 @@ def resolver_context(mock_saas_user_auth):
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
# ---------------------------------------------------------------------------
# Tests for resolver_org_id - org routing for resolver conversations
# ---------------------------------------------------------------------------
def test_resolver_org_id_defaults_to_none(mock_saas_user_auth):
"""Test that resolver_org_id defaults to None when not provided."""
ctx = ResolverUserContext(saas_user_auth=mock_saas_user_auth)
assert ctx.resolver_org_id is None
def test_resolver_org_id_can_be_set_via_constructor(mock_saas_user_auth):
"""Test that resolver_org_id can be set via constructor for org routing."""
from uuid import UUID
org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
ctx = ResolverUserContext(
saas_user_auth=mock_saas_user_auth, resolver_org_id=org_id
)
assert ctx.resolver_org_id == org_id
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
"""Helper to create CustomSecret instances."""
return CustomSecret(secret=SecretStr(value), description=description)
@@ -0,0 +1,111 @@
"""Tests for resolver org routing logic.
Tests the resolve_org_for_repo function which determines which OpenHands
organization workspace a resolver conversation should be created in.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
# Patch at module level where the names are looked up
_CLAIM_STORE = 'enterprise.integrations.resolver_org_router.OrgGitClaimStore'
_MEMBER_STORE = 'enterprise.integrations.resolver_org_router.OrgMemberStore'
@pytest.fixture(autouse=True)
def mock_stores():
"""Mock OrgGitClaimStore and OrgMemberStore for all tests."""
with (
patch(_CLAIM_STORE) as mock_claim_store,
patch(_MEMBER_STORE) as mock_member_store,
):
mock_claim_store.get_claim_by_provider_and_git_org = AsyncMock(
return_value=None
)
mock_member_store.get_org_member = AsyncMock(return_value=None)
yield mock_claim_store, mock_member_store
@pytest.mark.asyncio
async def test_returns_org_id_when_claimed_and_user_is_member(mock_stores):
"""When the git org is claimed and the user is a member, return the claiming org's ID."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, mock_member_store = mock_stores
# Arrange
claim = MagicMock()
claim.org_id = CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
mock_member_store.get_org_member.return_value = MagicMock() # member exists
# Act
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
# Assert
assert result == CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'openhands'
)
mock_member_store.get_org_member.assert_called_once_with(
CLAIMING_ORG_ID, UUID(USER_ID)
)
@pytest.mark.asyncio
async def test_returns_none_when_claimed_but_user_not_member(mock_stores):
"""When the git org is claimed but user is not a member, return None."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, mock_member_store = mock_stores
# Arrange
claim = MagicMock()
claim.org_id = CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
mock_member_store.get_org_member.return_value = None
# Act
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_no_claim_exists(mock_stores):
"""When no org has claimed the git organization, return None."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, _ = mock_stores
mock_claim_store.get_claim_by_provider_and_git_org.return_value = None
# Act
result = await resolve_org_for_repo('github', 'UnclaimedOrg/repo', USER_ID)
# Assert
assert result is None
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'unclaimedorg'
)
@pytest.mark.asyncio
async def test_extracts_git_org_lowercase_from_repo_name(mock_stores):
"""The git org is extracted from repo name and lowercased for claim lookup."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, _ = mock_stores
# Act
await resolve_org_for_repo('github', 'MyOrg/some-repo', USER_ID)
# Assert
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'myorg'
)
@@ -1306,3 +1306,100 @@ class TestApiKeyOrgIdHandling:
conv_from_org1 = await user_service_org1.get_app_conversation_info(conv_id)
assert conv_from_org1 is not None
assert conv_from_org1.id == conv_id
class TestResolverOrgIdRouting:
"""Test that resolver_org_id on user_context overrides the default org_id."""
@pytest.mark.asyncio
async def test_save_uses_resolver_org_id_when_set_on_context(
self,
async_session_with_users: AsyncSession,
):
"""When user_context has resolver_org_id, conversation is saved in that org."""
from unittest.mock import AsyncMock
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from enterprise.integrations.resolver_context import ResolverUserContext
# Arrange: user1 is in ORG1, but resolver routes to ORG2
# Use spec to prevent MagicMock from auto-creating undefined attributes
mock_context = MagicMock(spec=ResolverUserContext)
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
mock_context.resolver_org_id = ORG2_ID
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_resolver',
title='Resolver Routed Conversation',
)
# Act
await service.save_app_conversation_info(conv_info)
# Assert: conversation is stored in ORG2, not user's default ORG1
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None
assert saas_metadata.org_id == ORG2_ID
assert saas_metadata.user_id == USER1_ID
@pytest.mark.asyncio
async def test_save_uses_default_org_when_resolver_org_id_is_none(
self,
async_session_with_users: AsyncSession,
):
"""When resolver_org_id is None, conversation uses user's default org."""
from unittest.mock import AsyncMock
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from enterprise.integrations.resolver_context import ResolverUserContext
# Arrange: user1 in ORG1 with no resolver override
# Use spec to prevent MagicMock from auto-creating undefined attributes
mock_context = MagicMock(spec=ResolverUserContext)
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
mock_context.resolver_org_id = None
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_default',
title='Default Org Conversation',
)
# Act
await service.save_app_conversation_info(conv_info)
# Assert: conversation stored in user's default ORG1
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None
assert saas_metadata.org_id == ORG1_ID
+167
View File
@@ -2058,3 +2058,170 @@ async def test_accept_tos_stores_timezone_naive_datetime(mock_request):
# The datetime assigned to user.accepted_tos must be timezone-naive
# (compatible with TIMESTAMP WITHOUT TIME ZONE database column)
assert mock_user.accepted_tos.tzinfo is None
class TestGetCurrentUser:
"""Tests for the /api/me endpoint."""
@pytest.fixture
def mock_request(self):
request = MagicMock(spec=Request)
request.url = MagicMock()
request.url.hostname = 'localhost'
request.url.netloc = 'localhost:8000'
request.headers = {}
request.cookies = {}
return request
@pytest.mark.asyncio
async def test_get_current_user_not_authenticated(self, mock_request):
"""Test that unauthenticated requests return 401."""
from server.auth.auth_error import NoCredentialsError
from server.routes.auth import get_current_user
with patch(
'server.routes.auth.SaasUserAuth.get_instance',
AsyncMock(side_effect=NoCredentialsError('failed to authenticate')),
):
with pytest.raises(HTTPException) as exc_info:
await get_current_user(mock_request)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert exc_info.value.detail == 'User not authenticated'
@pytest.mark.asyncio
async def test_get_current_user_user_not_found(self, mock_request):
"""Test that requests for non-existent users return 404."""
from server.routes.auth import get_current_user
mock_user_auth = MagicMock(spec=SaasUserAuth)
mock_user_auth.get_user_id = AsyncMock(return_value='test_user_id')
mock_user_auth.get_user_email = AsyncMock(return_value='test@example.com')
with (
patch(
'server.routes.auth.SaasUserAuth.get_instance',
AsyncMock(return_value=mock_user_auth),
),
patch('server.routes.auth.UserStore') as mock_user_store,
):
mock_user_store.get_user_by_id = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await get_current_user(mock_request)
assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
assert exc_info.value.detail == 'User not found'
@pytest.mark.asyncio
async def test_get_current_user_success(self, mock_request):
"""Test successful retrieval of current user info."""
from uuid import UUID
from server.auth.authorization import Permission, RoleName
from server.routes.auth import get_current_user
test_user_id = 'test_user_id'
test_org_id = UUID('12345678-1234-5678-1234-567812345678')
test_email = 'test@example.com'
test_org_name = 'Test Organization'
mock_user_auth = MagicMock(spec=SaasUserAuth)
mock_user_auth.get_user_id = AsyncMock(return_value=test_user_id)
mock_user_auth.get_user_email = AsyncMock(return_value=test_email)
mock_user = MagicMock()
mock_user.current_org_id = test_org_id
mock_org = MagicMock()
mock_org.name = test_org_name
mock_role = MagicMock()
mock_role.name = RoleName.MEMBER.value
with (
patch(
'server.routes.auth.SaasUserAuth.get_instance',
AsyncMock(return_value=mock_user_auth),
),
patch('server.routes.auth.UserStore') as mock_user_store,
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
),
patch(
'storage.org_store.OrgStore.get_org_by_id',
AsyncMock(return_value=mock_org),
),
):
mock_user_store.get_user_by_id = AsyncMock(return_value=mock_user)
result = await get_current_user(mock_request)
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
# Parse the JSON content
import json
content = json.loads(result.body.decode())
assert content['user_id'] == test_user_id
assert content['email'] == test_email
assert content['org_id'] == str(test_org_id)
assert content['org_name'] == test_org_name
assert content['role'] == RoleName.MEMBER.value
assert Permission.MANAGE_SECRETS.value in content['permissions']
assert Permission.VIEW_LLM_SETTINGS.value in content['permissions']
# Members should not have owner-only permissions
assert Permission.DELETE_ORGANIZATION.value not in content['permissions']
@pytest.mark.asyncio
async def test_get_current_user_no_role(self, mock_request):
"""Test retrieval when user has no role in org."""
from uuid import UUID
from server.routes.auth import get_current_user
test_user_id = 'test_user_id'
test_org_id = UUID('12345678-1234-5678-1234-567812345678')
test_email = 'test@example.com'
test_org_name = 'Test Organization'
mock_user_auth = MagicMock(spec=SaasUserAuth)
mock_user_auth.get_user_id = AsyncMock(return_value=test_user_id)
mock_user_auth.get_user_email = AsyncMock(return_value=test_email)
mock_user = MagicMock()
mock_user.current_org_id = test_org_id
mock_org = MagicMock()
mock_org.name = test_org_name
with (
patch(
'server.routes.auth.SaasUserAuth.get_instance',
AsyncMock(return_value=mock_user_auth),
),
patch('server.routes.auth.UserStore') as mock_user_store,
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=None),
),
patch(
'storage.org_store.OrgStore.get_org_by_id',
AsyncMock(return_value=mock_org),
),
):
mock_user_store.get_user_by_id = AsyncMock(return_value=mock_user)
result = await get_current_user(mock_request)
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
import json
content = json.loads(result.body.decode())
assert content['user_id'] == test_user_id
assert content['role'] is None
assert content['permissions'] == []
+118 -1
View File
@@ -1,5 +1,6 @@
from unittest import TestCase, mock
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
@@ -215,3 +216,119 @@ class TestGithubV1ConversationRouting(TestCase):
jinja_env, saas_user_auth, conversation_metadata
)
mock_create_v0.assert_not_called()
class TestGithubOrgRouting(TestCase):
"""Test org routing for GitHub resolver conversations."""
def setUp(self):
self.user_data = UserData(
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
)
self.raw_payload = Message(
source=SourceType.GITHUB,
message={
'payload': {
'action': 'opened',
'issue': {'number': 42},
}
},
)
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
def _create_github_issue(self):
return GithubIssue(
user_info=self.user_data,
full_repo_name='ClaimedOrg/repo',
issue_number=42,
installation_id=456,
conversation_id='',
should_extract=True,
send_summary_instruction=False,
is_public_repo=True,
raw_payload=self.raw_payload,
uuid='test-uuid',
title='',
description='',
previous_comments=[],
v1_enabled=False,
)
@pytest.mark.asyncio
@patch(
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
):
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
# Arrange
mock_v1_setting.return_value = False
mock_resolve_org.return_value = self.resolved_org_id
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
github_issue = self._create_github_issue()
# Act
await github_issue.initialize_new_conversation()
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='ClaimedOrg/repo',
keycloak_user_id='test-keycloak-id',
)
# get_resolver_instance(config, user_id, resolver_org_id)
args, _ = mock_get_resolver.call_args
assert args[1] == 'test-keycloak-id'
assert args[2] == self.resolved_org_id
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_app_conversation_service')
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
self, mock_v1_setting, mock_resolve_org, mock_get_service
):
"""V1 path passes resolved org_id to ResolverUserContext."""
# Arrange
mock_v1_setting.return_value = True
mock_resolve_org.return_value = self.resolved_org_id
github_issue = self._create_github_issue()
# Initialize to set resolved_org_id and v1_enabled
await github_issue.initialize_new_conversation()
# Assert
assert github_issue.resolved_org_id == self.resolved_org_id
@pytest.mark.asyncio
@patch(
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_no_claim_passes_none_resolver_org_id(
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
):
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
# Arrange
mock_v1_setting.return_value = False
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
github_issue = self._create_github_issue()
# Act
await github_issue.initialize_new_conversation()
# Assert
args, _ = mock_get_resolver.call_args
assert args[2] is None
+126
View File
@@ -0,0 +1,126 @@
"""Tests for GitLab resolver org routing logic.
Tests that the GitLab resolver correctly resolves the target organization
and passes resolver_org_id through V0 and V1 conversation paths.
"""
from unittest import TestCase
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.gitlab.gitlab_view import GitlabIssue
from integrations.models import Message, SourceType
from integrations.types import UserData
class TestGitlabOrgRouting(TestCase):
"""Test org routing for GitLab resolver conversations."""
def setUp(self):
self.user_data = UserData(
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
)
self.raw_payload = Message(
source=SourceType.GITLAB,
message={
'payload': {
'object_kind': 'issue',
'object_attributes': {'action': 'open', 'iid': 42},
}
},
)
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
def _create_gitlab_issue(self):
return GitlabIssue(
user_info=self.user_data,
full_repo_name='ClaimedOrg/repo',
issue_number=42,
project_id=100,
installation_id='install-123',
conversation_id='',
should_extract=True,
send_summary_instruction=False,
is_public_repo=True,
raw_payload=self.raw_payload,
title='',
description='',
previous_comments=[],
is_mr=False,
v1_enabled=False,
)
@pytest.mark.asyncio
@patch(
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
self, mock_resolve_org, mock_get_resolver
):
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
# Arrange
mock_resolve_org.return_value = self.resolved_org_id
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
gitlab_issue = self._create_gitlab_issue()
# Act
await gitlab_issue.initialize_new_conversation()
# Assert
mock_resolve_org.assert_called_once_with(
provider='gitlab',
full_repo_name='ClaimedOrg/repo',
keycloak_user_id='test-keycloak-id',
)
# get_resolver_instance(config, user_id, resolver_org_id)
args, _ = mock_get_resolver.call_args
assert args[1] == 'test-keycloak-id'
assert args[2] == self.resolved_org_id
@pytest.mark.asyncio
@patch('integrations.gitlab.gitlab_view.get_app_conversation_service')
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
self, mock_resolve_org, mock_get_service
):
"""V1 path passes resolved org_id to ResolverUserContext."""
# Arrange
mock_resolve_org.return_value = self.resolved_org_id
gitlab_issue = self._create_gitlab_issue()
gitlab_issue.v1_enabled = True
# Initialize to set resolved_org_id
await gitlab_issue.initialize_new_conversation()
# Assert
assert gitlab_issue.resolved_org_id == self.resolved_org_id
@pytest.mark.asyncio
@patch(
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_no_claim_passes_none_resolver_org_id(
self, mock_resolve_org, mock_get_resolver
):
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
# Arrange
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
gitlab_issue = self._create_gitlab_issue()
# Act
await gitlab_issue.initialize_new_conversation()
# Assert
args, _ = mock_get_resolver.call_args
assert args[2] is None
+347
View File
@@ -0,0 +1,347 @@
"""Tests for Linear resolver org routing logic.
Tests that the LinearNewConversationView correctly resolves the target
organization and passes resolver_org_id through the V0 conversation path.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.linear.linear_view import LinearNewConversationView
from integrations.models import JobContext
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
KEYCLOAK_USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
@pytest.fixture
def mock_linear_user():
user = LinearUser()
user.id = 1
user.keycloak_user_id = KEYCLOAK_USER_ID
user.linear_user_id = 'linear-user-123'
user.linear_workspace_id = 1
user.status = 'active'
return user
@pytest.fixture
def mock_linear_workspace():
workspace = LinearWorkspace()
workspace.id = 1
workspace.name = 'test-workspace'
workspace.linear_org_id = 'linear-org-123'
workspace.admin_user_id = 'admin-123'
workspace.webhook_secret = 'secret'
workspace.svc_acc_email = 'svc@test.com'
workspace.svc_acc_api_key = 'api-key'
workspace.status = 'active'
return workspace
@pytest.fixture
def mock_user_auth():
auth = MagicMock(spec=UserAuth)
auth.get_provider_tokens = AsyncMock(
return_value={ProviderType.GITHUB: MagicMock()}
)
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
return auth
@pytest.fixture
def job_context():
return JobContext(
issue_id='issue-123',
issue_key='PROJ-42',
issue_title='Test issue',
issue_description='Test description',
user_msg='@openhands fix this',
user_email='user@test.com',
platform_user_id='linear-user-123',
workspace_name='test-workspace',
display_name='Test User',
)
@pytest.fixture
def linear_view(job_context, mock_user_auth, mock_linear_user, mock_linear_workspace):
return LinearNewConversationView(
job_context=job_context,
saas_user_auth=mock_user_auth,
linear_user=mock_linear_user,
linear_workspace=mock_linear_workspace,
selected_repo='OpenHands/foo',
conversation_id='',
)
class TestLinearV0OrgRouting:
"""Test V0 conversation routing logic for Linear resolver."""
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch('integrations.linear.linear_view.ProviderHandler')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
linear_view,
):
"""V0 path should resolve org and pass resolver_org_id to get_resolver_instance."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='OpenHands/foo',
keycloak_user_id=KEYCLOAK_USER_ID,
)
call_args = mock_get_resolver_instance.call_args
assert call_args[0][1] == KEYCLOAK_USER_ID
assert call_args[0][2] == CLAIMING_ORG_ID
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.trigger == ConversationTrigger.LINEAR
assert saved_metadata.git_provider == ProviderType.GITHUB
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch('integrations.linear.linear_view.ProviderHandler')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_v0_passes_none_when_no_claim(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
linear_view,
):
"""When no claim exists, resolver_org_id should be None (personal workspace)."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_no_provider_tokens_skips_org_resolution(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_resolve_org,
linear_view,
mock_user_auth,
):
"""When provider tokens are None, org resolution should be skipped."""
# Arrange
mock_user_auth.get_provider_tokens = AsyncMock(return_value=None)
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_not_called()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider is None
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch('integrations.linear.linear_view.ProviderHandler')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_verify_repo_provider_failure_falls_back_to_personal_workspace(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
linear_view,
):
"""When verify_repo_provider fails, should fall back to personal workspace."""
# Arrange
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(
side_effect=Exception('Repository not found')
)
mock_provider_handler_cls.return_value = mock_handler
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert - org resolution should be skipped, conversation created in personal workspace
mock_resolve_org.assert_not_called()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch('integrations.linear.linear_view.ProviderHandler')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_resolve_org_failure_falls_back_to_personal_workspace(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
linear_view,
):
"""When resolve_org_for_repo fails, should fall back to personal workspace."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.side_effect = Exception('Database connection failed')
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert - conversation should be created with resolver_org_id=None
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None
@@ -214,3 +214,125 @@ class TestGetInstance:
# Assert
assert store.user_id == user_id
assert store.org_id is None
@pytest.mark.asyncio
async def test_get_resolver_instance_passes_resolver_org_id(self):
"""Verify get_resolver_instance forwards resolver_org_id to the store."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
mock_user = MagicMock(spec=User)
mock_user.current_org_id = UUID(user_id)
mock_config = MagicMock(spec=OpenHandsConfig)
with patch(
'storage.saas_conversation_store.UserStore.get_user_by_id',
AsyncMock(return_value=mock_user),
), patch('storage.saas_conversation_store.session_maker'):
# Act
store = await SaasConversationStore.get_resolver_instance(
mock_config, user_id, resolver_org_id=resolver_org_id
)
# Assert
assert store.resolver_org_id == resolver_org_id
@pytest.mark.asyncio
async def test_get_instance_does_not_have_resolver_org_id(self):
"""Verify get_instance does not set resolver_org_id (it's not a resolver path)."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
mock_user = MagicMock(spec=User)
mock_user.current_org_id = UUID(user_id)
mock_config = MagicMock(spec=OpenHandsConfig)
with patch(
'storage.saas_conversation_store.UserStore.get_user_by_id',
AsyncMock(return_value=mock_user),
), patch('storage.saas_conversation_store.session_maker'):
# Act
store = await SaasConversationStore.get_instance(mock_config, user_id)
# Assert
assert store.resolver_org_id is None
class TestResolverOrgIdRouting:
"""Tests for resolver_org_id overriding org_id in save_metadata."""
@pytest.mark.asyncio
async def test_save_metadata_uses_resolver_org_id_over_default(self, session_maker):
"""When resolver_org_id is set, save_metadata stores it instead of the default org_id."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
store = SaasConversationStore(
user_id, default_org_id, session_maker, resolver_org_id=resolver_org_id
)
metadata = ConversationMetadata(
conversation_id='resolver-routed-conv',
user_id=user_id,
selected_repository='ClaimedOrg/repo',
selected_branch=None,
created_at=datetime.now(UTC),
last_updated_at=datetime.now(UTC),
)
# Act
await store.save_metadata(metadata)
# Assert - verify the SaaS metadata record has the resolver org, not the default
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
with session_maker() as session:
saas_record = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadataSaas.conversation_id
== 'resolver-routed-conv'
)
.first()
)
assert saas_record is not None
assert saas_record.org_id == resolver_org_id
assert saas_record.org_id != default_org_id
@pytest.mark.asyncio
async def test_save_metadata_uses_default_org_when_no_resolver_org(
self, session_maker
):
"""When resolver_org_id is None, save_metadata uses the default org_id."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
store = SaasConversationStore(user_id, default_org_id, session_maker)
metadata = ConversationMetadata(
conversation_id='default-org-conv',
user_id=user_id,
selected_repository='PersonalOrg/repo',
selected_branch=None,
created_at=datetime.now(UTC),
last_updated_at=datetime.now(UTC),
)
# Act
await store.save_metadata(metadata)
# Assert
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
with session_maker() as session:
saas_record = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadataSaas.conversation_id == 'default-org-conv'
)
.first()
)
assert saas_record is not None
assert saas_record.org_id == default_org_id
@@ -535,6 +535,99 @@ async def test_store_does_not_update_org_mcp_config(
assert org.mcp_config is None
@pytest.mark.asyncio
async def test_store_skips_ensure_api_key_for_non_openhands_model_without_base_url(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When saving a non-OpenHands model with no base URL (basic view BYOR),
_ensure_api_key should NOT be called, preserving the user's custom API key.
This is the primary bug fix: users selecting e.g. OpenAI in basic view and
providing their own API key should not have it overwritten by a proxy key.
"""
# Arrange
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
custom_api_key = 'sk-user-custom-openai-key'
settings = DataSettings(
llm_model='openai/gpt-5.2',
llm_base_url=None, # Basic view: no base URL provided
llm_api_key=SecretStr(custom_api_key),
)
# Act
with (
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
):
await store.store(settings)
# Assert
mock_ensure.assert_not_called()
@pytest.mark.asyncio
async def test_store_calls_ensure_api_key_for_openhands_model_without_base_url(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When saving an OpenHands model with no base URL, _ensure_api_key should
still be called to generate/verify the proxy key.
This guards the edge case of switching from a non-OpenHands provider to
OpenHands in basic view, where a stale BYOR key needs to be replaced.
"""
# Arrange
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
settings = DataSettings(
llm_model='openhands/claude-opus-4-5-20251101',
llm_base_url=None,
llm_api_key=SecretStr('sk-stale-openai-key'),
)
# Act
with (
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
):
await store.store(settings)
# Assert
mock_ensure.assert_called_once()
@pytest.mark.asyncio
async def test_store_calls_ensure_api_key_when_base_url_is_litellm_proxy(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When the base URL is explicitly the LiteLLM proxy, _ensure_api_key should
be called regardless of the model type."""
# Arrange
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
settings = DataSettings(
llm_model='openai/gpt-5.2',
llm_base_url=LITE_LLM_API_URL,
llm_api_key=SecretStr('sk-some-key'),
)
# Act
with (
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
):
await store.store(settings)
# Assert
mock_ensure.assert_called_once()
@pytest.mark.asyncio
async def test_load_returns_user_specific_mcp_config(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
+122 -51
View File
@@ -135,14 +135,19 @@ class TestRepoVerificationHandling:
@patch('integrations.slack.slack_manager.sio')
@patch.object(SlackManager, 'send_message', new_callable=AsyncMock)
async def test_no_repo_mentioned_shows_external_selector(
async def test_no_repo_mentioned_shows_button_and_dropdown(
self,
mock_send_message,
mock_sio,
slack_manager,
slack_new_conversation_view,
):
"""Test that when no repo is mentioned, external_select repo selector is shown."""
"""Test that when no repo is mentioned, a button and dropdown are shown.
The form shows:
1. A "No Repository" button - immediately clickable without loading
2. An external_select dropdown - for searching repositories dynamically
"""
# Setup Redis mock
mock_redis = AsyncMock()
mock_sio.manager.redis = mock_redis
@@ -162,17 +167,75 @@ class TestRepoVerificationHandling:
mock_send_message.assert_called_once()
call_args = mock_send_message.call_args
# Should be the repo selection form with external_select
# Should be the repo selection form with button + external_select
message = call_args[0][0]
assert isinstance(message, dict)
assert message.get('text') == 'Choose a Repository:'
# Verify it's using external_select
blocks = message.get('blocks', [])
actions_block = next((b for b in blocks if b.get('type') == 'actions'), None)
assert actions_block is not None
elements = actions_block.get('elements', [])
assert len(elements) > 0
assert elements[0].get('type') == 'external_select'
# Should have 2 elements: button and external_select
assert len(elements) == 2
# First element: "No Repository" button (immediately available)
assert elements[0].get('type') == 'button'
assert elements[0].get('action_id').startswith('no_repository:')
assert elements[0].get('value') == '-'
# Second element: external_select for searching repos
assert elements[1].get('type') == 'external_select'
assert elements[1].get('action_id').startswith('repository_select:')
@pytest.mark.asyncio
@patch('integrations.slack.slack_manager.sio')
async def test_no_repository_button_click_processes_correctly(
self,
mock_sio,
slack_manager,
):
"""Test that clicking 'No Repository' button correctly processes the interaction.
This verifies the button click path through receive_form_interaction, ensuring
the no_repository: action_id is correctly parsed and processed.
"""
# Setup: Mock Redis to return a stored user message
mock_redis = AsyncMock()
mock_sio.manager.redis = mock_redis
stored_msg = json.dumps({'text': 'Hello, help me with code', 'user': 'U123'})
mock_redis.get = AsyncMock(return_value=stored_msg)
# Simulate button click payload (what Slack sends when button is clicked)
button_payload = {
'type': 'block_actions',
'actions': [
{
'action_id': 'no_repository:1234567890.123456:None',
'type': 'button',
'value': '-',
}
],
'user': {'id': 'U123'},
'container': {'channel_id': 'C123'},
'team': {'id': 'T123'},
}
# Mock receive_message to capture what's passed to it
with patch.object(
slack_manager, 'receive_message', new_callable=AsyncMock
) as mock_receive:
await slack_manager.receive_form_interaction(button_payload)
# Verify receive_message was called
mock_receive.assert_called_once()
# Verify the message payload has selected_repo as None
call_args = mock_receive.call_args[0][0]
assert call_args.message['selected_repo'] is None
assert call_args.message['message_ts'] == '1234567890.123456'
assert call_args.message['thread_ts'] is None
@patch('integrations.slack.slack_manager.sio')
@patch('integrations.slack.slack_manager.ProviderHandler')
@@ -223,8 +286,8 @@ class TestRepoVerificationHandling:
class TestBuildRepoOptions:
"""Test the _build_repo_options helper method.
Note: _build_repo_options always includes the "No Repository" option at the top.
This is by design for the external_select dropdown.
Note: _build_repo_options returns only actual repositories. The "No Repository"
option is now handled by a separate button in the form, not the dropdown.
"""
def test_build_options_with_repos(self, slack_manager):
@@ -247,21 +310,20 @@ class TestBuildRepoOptions:
options = slack_manager._build_repo_options(repos)
# Should have 3 options: "No Repository" + 2 repos
assert len(options) == 3
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
assert options[1]['value'] == 'owner/repo1'
assert options[2]['value'] == 'owner/repo2'
# Should have 2 options (repos only - "No Repository" is now a button)
assert len(options) == 2
assert options[0]['value'] == 'owner/repo1'
assert options[1]['value'] == 'owner/repo2'
def test_build_options_empty_repos(self, slack_manager):
"""Test building options with empty repo list still includes No Repository."""
"""Test building options with empty repo list returns empty list.
Note: "No Repository" is now handled by a separate button in the form.
"""
options = slack_manager._build_repo_options([])
# Should have 1 option: just "No Repository"
assert len(options) == 1
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
# Should have 0 options (empty list)
assert len(options) == 0
def test_build_options_truncates_long_names(self, slack_manager):
"""Test that repo names longer than 75 chars are truncated."""
@@ -278,12 +340,12 @@ class TestBuildRepoOptions:
options = slack_manager._build_repo_options(repos)
# First option is "No Repository", second is the repo
assert len(options) == 2
# Should have 1 option (the repo only - "No Repository" is a button)
assert len(options) == 1
# Text should be truncated to 75 chars
assert len(options[1]['text']['text']) == 75
assert len(options[0]['text']['text']) == 75
# But value should have full name
assert options[1]['value'] == long_name
assert options[0]['value'] == long_name
class TestSearchRepositories:
@@ -413,23 +475,23 @@ class TestSearchRepositories:
options = slack_manager._build_repo_options(search_results)
# Verify: Options are correctly built from search results
assert len(options) == 4 # "No Repository" + 3 repos
# Note: "No Repository" is now a button, not in the dropdown
assert len(options) == 3 # 3 repos only
# First option should be "No Repository"
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
# Remaining options should be the repos in order
assert options[1]['value'] == 'myorg/react-dashboard'
assert options[1]['text']['text'] == 'myorg/react-dashboard'
assert options[2]['value'] == 'myorg/python-api'
assert options[3]['value'] == 'myorg/docs-site'
# Options should be the repos in order
assert options[0]['value'] == 'myorg/react-dashboard'
assert options[0]['text']['text'] == 'myorg/react-dashboard'
assert options[1]['value'] == 'myorg/python-api'
assert options[2]['value'] == 'myorg/docs-site'
@patch('integrations.slack.slack_manager.ProviderHandler')
async def test_search_with_empty_results_builds_no_repo_only_option(
async def test_search_with_empty_results_builds_empty_options(
self, mock_provider_handler_class, slack_manager, mock_user_auth
):
"""Test that when search returns no results, only 'No Repository' option is shown."""
"""Test that when search returns no results, empty options list is returned.
Note: "No Repository" is now handled by a separate button in the form.
"""
# Setup: No matching repos
mock_provider_handler = MagicMock()
mock_provider_handler.search_repositories = AsyncMock(return_value=[])
@@ -447,10 +509,8 @@ class TestSearchRepositories:
)
options = slack_manager._build_repo_options(search_results)
# Verify: Only "No Repository" option
assert len(options) == 1
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
# Verify: Empty options list (button handles "No Repository")
assert len(options) == 0
class TestUserMsgStorage:
@@ -669,7 +729,10 @@ class TestOnOptionsLoadEndpoint:
async def test_on_options_load_disabled_returns_empty_options(
self, mock_request, background_tasks
):
"""Test that when webhooks are disabled, empty options are returned."""
"""Test that when webhooks are disabled, empty options are returned.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
response = await on_options_load(mock_request, background_tasks)
@@ -683,7 +746,10 @@ class TestOnOptionsLoadEndpoint:
async def test_on_options_load_no_payload_returns_empty_options(
self, mock_request, background_tasks
):
"""Test that when no payload is in request, empty options are returned."""
"""Test that when no payload is in request, empty options are returned.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
mock_request.body = AsyncMock(return_value=b'')
@@ -731,7 +797,10 @@ class TestOnOptionsLoadEndpoint:
async def test_on_options_load_wrong_payload_type_returns_empty_options(
self, mock_signature_verifier, mock_request, background_tasks
):
"""Test that non-block_suggestion payload returns empty options."""
"""Test that non-block_suggestion payload returns empty options.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
payload = {
@@ -764,7 +833,10 @@ class TestOnOptionsLoadEndpoint:
background_tasks,
valid_block_suggestion_payload,
):
"""Test that unauthenticated users get empty options and linking message is queued."""
"""Test that unauthenticated users get empty options and linking message is queued.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
payload_str = json.dumps(valid_block_suggestion_payload)
@@ -817,9 +889,8 @@ class TestOnOptionsLoadEndpoint:
return_value=(mock_slack_user, mock_user_auth)
)
# Expected options from search_repos_for_slack
# Expected options from search_repos_for_slack (no "No Repository" - that's a button)
expected_options = [
{'text': {'type': 'plain_text', 'text': 'No Repository'}, 'value': '-'},
{
'text': {'type': 'plain_text', 'text': 'owner/repo1'},
'value': 'owner/repo1',
@@ -878,11 +949,8 @@ class TestOnOptionsLoadEndpoint:
mock_slack_manager.authenticate_user = AsyncMock(
return_value=(mock_slack_user, mock_user_auth)
)
mock_slack_manager.search_repos_for_slack = AsyncMock(
return_value=[
{'text': {'type': 'plain_text', 'text': 'No Repository'}, 'value': '-'}
]
)
# Empty search returns empty list (no repos found, and "No Repository" is a button)
mock_slack_manager.search_repos_for_slack = AsyncMock(return_value=[])
response = await on_options_load(mock_request, background_tasks)
@@ -907,7 +975,10 @@ class TestOnOptionsLoadEndpoint:
mock_slack_user,
mock_user_auth,
):
"""Test that when search raises an exception, empty options are returned gracefully."""
"""Test that when search raises an exception, empty options are returned gracefully.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
payload_str = json.dumps(valid_block_suggestion_payload)
+331
View File
@@ -0,0 +1,331 @@
"""Tests for Slack view org routing logic.
Tests that the SlackNewConversationView correctly resolves the target org
based on claimed git organizations and passes it through V0/V1 paths.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.slack.slack_view import SlackNewConversationView
from storage.slack_user import SlackUser
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
KEYCLOAK_USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
@pytest.fixture
def mock_slack_user():
"""Create a mock SlackUser."""
user = SlackUser()
user.slack_user_id = 'U1234567890'
user.keycloak_user_id = KEYCLOAK_USER_ID
user.slack_display_name = 'Test User'
user.org_id = UUID('cccccccc-cccc-cccc-cccc-cccccccccccc')
return user
@pytest.fixture
def mock_user_auth():
"""Create a mock UserAuth."""
auth = MagicMock(spec=UserAuth)
auth.get_provider_tokens = AsyncMock(
return_value={ProviderType.GITHUB: MagicMock()}
)
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
auth.get_access_token = AsyncMock(return_value='access-token')
auth.get_user_id = AsyncMock(return_value=KEYCLOAK_USER_ID)
return auth
@pytest.fixture
def slack_view(mock_slack_user, mock_user_auth):
"""Create a SlackNewConversationView instance for testing."""
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='OpenHands/foo',
should_extract=True,
send_summary_instruction=True,
conversation_id='',
team_id='T1234567890',
v1_enabled=False,
)
@pytest.fixture
def slack_view_no_repo(mock_slack_user, mock_user_auth):
"""Create a SlackNewConversationView with no selected repo."""
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=None,
should_extract=True,
send_summary_instruction=True,
conversation_id='',
team_id='T1234567890',
v1_enabled=False,
)
class TestSlackV0ConversationRouting:
"""Test V0 conversation routing logic in Slack integration."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_v0_passes_resolver_org_id(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V0 path should pass resolver_org_id to SaasConversationStore.get_resolver_instance."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='OpenHands/foo',
keycloak_user_id=KEYCLOAK_USER_ID,
)
mock_get_resolver_instance.assert_called_once()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][1] == KEYCLOAK_USER_ID # user_id
assert call_args[0][2] == CLAIMING_ORG_ID # resolver_org_id
mock_store.save_metadata.assert_called_once()
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider == ProviderType.GITHUB
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_v0_passes_none_when_no_claim(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V0 path should pass resolver_org_id=None when no claim exists."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
class TestSlackV1ConversationRouting:
"""Test V1 conversation routing logic in Slack integration."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=True,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch('integrations.slack.slack_view.get_app_conversation_service')
@patch('integrations.slack.slack_view.ResolverUserContext')
async def test_v1_passes_resolver_org_id_to_context(
self,
mock_resolver_ctx_cls,
mock_get_service,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V1 path should pass resolver_org_id to ResolverUserContext."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_resolver_ctx_cls.return_value = MagicMock()
# Mock the async context manager for app_conversation_service
mock_service = MagicMock()
mock_service.start_app_conversation = MagicMock(return_value=aiter_empty())
mock_ctx = MagicMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_service)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
mock_get_service.return_value = mock_ctx
mock_jinja = MagicMock()
# Act
with patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
):
with patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='OpenHands/foo',
keycloak_user_id=KEYCLOAK_USER_ID,
)
mock_resolver_ctx_cls.assert_called_once_with(
saas_user_auth=slack_view.saas_user_auth,
resolver_org_id=CLAIMING_ORG_ID,
)
class TestSlackNoRepoRouting:
"""Test routing when no repository is selected."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_no_repo_skips_org_resolution(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_resolve_org,
mock_v1_enabled,
slack_view_no_repo,
):
"""When selected_repo is None, org resolution should be skipped."""
# Arrange
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view_no_repo,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(
slack_view_no_repo, 'save_slack_convo', new_callable=AsyncMock
),
patch.object(slack_view_no_repo, '_verify_necessary_values_are_set'),
):
await slack_view_no_repo.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_not_called()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider is None
async def aiter_empty():
"""Helper: empty async iterator."""
return
yield # noqa: unreachable - makes this an async generator
@@ -15,7 +15,7 @@ import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card/conversation-card";
import { clickOnEditButton } from "./utils";
import { ConversationCardActions } from "#/components/features/conversation-panel/conversation-card/conversation-card-actions";
import { ConversationStatus } from "#/types/conversation-status";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
// We'll use the actual i18next implementation but override the translation function
@@ -434,23 +434,63 @@ describe("ConversationCard", () => {
expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
});
const statusTable: [ConversationStatus, boolean][] = [
it("should render the llm model when provided", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
llmModel="anthropic/claude-sonnet-4-20250514"
/>,
);
const model = screen.getByTestId("conversation-card-llm-model");
expect(model).toBeInTheDocument();
expect(model).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
expect(model).toHaveAttribute("title", "anthropic/claude-sonnet-4-20250514");
expect(model.querySelector("svg")).toBeInTheDocument();
// Verify truncation structure: text is wrapped in a span with truncate class
const textSpan = model.querySelector("span.truncate");
expect(textSpan).toBeInTheDocument();
expect(textSpan).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
});
it("should not render the llm model when not provided", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
expect(
screen.queryByTestId("conversation-card-llm-model"),
).not.toBeInTheDocument();
});
const statusTable: [V1SandboxStatus, boolean][] = [
["RUNNING", true],
["STARTING", true],
["STOPPED", false],
["ARCHIVED", false],
["ERROR", false],
["PAUSED", false],
["MISSING", false],
];
it.each(statusTable)(
"should toggle stop button visibility correctly for status",
(status, shouldShow) => {
"should toggle stop button visibility correctly for sandbox status",
(sandboxStatus, shouldShow) => {
renderWithProviders(
<ConversationCardActions
contextMenuOpen={true}
onContextMenuToggle={vi.fn()}
onStop={vi.fn()}
conversationStatus={status}
sandboxStatus={sandboxStatus}
/>,
);
@@ -5,8 +5,10 @@ import { createRoutesStub } from "react-router";
import React from "react";
import { renderWithProviders } from "test-utils";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { V1AppConversation, V1ConversationExecutionStatus } from "#/api/conversation-service/v1-conversation-service.types";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import type { Provider } from "#/types/settings";
// Mock the unified stop conversation hook
const mockStopConversationMutate = vi.fn();
@@ -16,6 +18,29 @@ vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
}),
}));
// Helper to create complete V1AppConversation mock data
const createMockConversation = (overrides: Partial<V1AppConversation> = {}): V1AppConversation => ({
id: "test-id",
title: "Test Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
sandbox_status: "STOPPED" as V1SandboxStatus,
execution_status: "FINISHED" as V1ConversationExecutionStatus,
conversation_url: null,
created_by_user_id: "user1",
metrics: null,
llm_model: null,
sandbox_id: "sandbox1",
trigger: null,
pr_number: [],
session_api_key: null,
...overrides,
});
// Mock toast handlers to prevent unhandled rejection errors
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
@@ -49,54 +74,18 @@ describe("ConversationPanel", () => {
}));
});
const mockConversations: Conversation[] = [
{
conversation_id: "1",
title: "Conversation 1",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Conversation 2",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Conversation 3",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
const mockConversations: V1AppConversation[] = [
createMockConversation({ id: "1", title: "Conversation 1", updated_at: "2021-10-01T12:00:00Z", sandbox_id: "sandbox1" }),
createMockConversation({ id: "2", title: "Conversation 2", updated_at: "2021-10-02T12:00:00Z", sandbox_id: "sandbox2" }),
createMockConversation({ id: "3", title: "Conversation 3", updated_at: "2021-10-03T12:00:00Z", sandbox_id: "sandbox3" }),
];
beforeEach(() => {
vi.clearAllMocks();
mockStopConversationMutate.mockClear();
// Setup default mock for getUserConversations
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
results: [...mockConversations],
// Setup default mock for V1 searchConversations
vi.spyOn(V1ConversationService, "searchConversations").mockResolvedValue({
items: [...mockConversations],
next_page_id: null,
});
});
@@ -111,12 +100,12 @@ describe("ConversationPanel", () => {
});
it("should display an empty state when there are no conversations", async () => {
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
);
getUserConversationsSpy.mockResolvedValue({
results: [],
searchConversationsSpy.mockResolvedValue({
items: [],
next_page_id: null,
});
@@ -127,11 +116,11 @@ describe("ConversationPanel", () => {
});
it("should handle an error when fetching conversations", async () => {
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
);
getUserConversationsSpy.mockRejectedValue(
searchConversationsSpy.mockRejectedValue(
new Error("Failed to fetch conversations"),
);
@@ -177,63 +166,27 @@ describe("ConversationPanel", () => {
it("should delete a conversation", async () => {
const user = userEvent.setup();
const mockData: Conversation[] = [
{
conversation_id: "1",
title: "Conversation 1",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Conversation 2",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Conversation 3",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
const mockData: V1AppConversation[] = [
createMockConversation({ id: "1", title: "Conversation 1", updated_at: "2021-10-01T12:00:00Z", sandbox_id: "sandbox1" }),
createMockConversation({ id: "2", title: "Conversation 2", updated_at: "2021-10-02T12:00:00Z", sandbox_id: "sandbox2" }),
createMockConversation({ id: "3", title: "Conversation 3", updated_at: "2021-10-03T12:00:00Z", sandbox_id: "sandbox3" }),
];
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
);
getUserConversationsSpy.mockImplementation(async () => ({
results: mockData,
searchConversationsSpy.mockImplementation(async () => ({
items: mockData,
next_page_id: null,
}));
const deleteUserConversationSpy = vi.spyOn(
ConversationService,
"deleteUserConversation",
const deleteConversationSpy = vi.spyOn(
V1ConversationService,
"deleteConversation",
);
deleteUserConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex((conv) => conv.conversation_id === id);
deleteConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex((conv) => conv.id === id);
if (index !== -1) {
mockData.splice(index, 1);
}
@@ -242,6 +195,7 @@ describe("ConversationPanel", () => {
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Initially shows 3 conversations (no filtering)
expect(cards).toHaveLength(3);
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
@@ -255,15 +209,10 @@ describe("ConversationPanel", () => {
const confirmButton = screen.getByRole("button", { name: /confirm/i });
await user.click(confirmButton);
// Verify modal is closed after confirmation
expect(
screen.queryByRole("button", { name: /confirm/i }),
).not.toBeInTheDocument();
// Wait for the cards to update
await waitFor(() => {
const updatedCards = screen.getAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(2);
});
});
it("should call onClose after clicking a card", async () => {
@@ -279,12 +228,12 @@ describe("ConversationPanel", () => {
it("should refetch data on rerenders", async () => {
const user = userEvent.setup();
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
);
getUserConversationsSpy.mockResolvedValue({
results: [...mockConversations],
searchConversationsSpy.mockResolvedValue({
items: [...mockConversations],
next_page_id: null,
});
@@ -329,54 +278,18 @@ describe("ConversationPanel", () => {
const user = userEvent.setup();
// Create mock data with a RUNNING conversation
const mockRunningConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Stopped Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
const mockRunningConversations: V1AppConversation[] = [
createMockConversation({ id: "1", title: "Running Conversation", sandbox_status: "RUNNING", execution_status: "RUNNING", sandbox_id: "sandbox1" }),
createMockConversation({ id: "2", title: "Starting Conversation", sandbox_status: "STARTING", execution_status: "RUNNING", sandbox_id: "sandbox2" }),
createMockConversation({ id: "3", title: "Stopped Conversation", sandbox_status: "STOPPED", execution_status: "FINISHED", sandbox_id: "sandbox3" }),
];
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
);
getUserConversationsSpy.mockResolvedValue({
results: mockRunningConversations,
searchConversationsSpy.mockResolvedValue({
items: mockRunningConversations,
next_page_id: null,
});
@@ -412,48 +325,26 @@ describe("ConversationPanel", () => {
it("should stop a conversation", async () => {
const user = userEvent.setup();
const mockData: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
const mockData: V1AppConversation[] = [
createMockConversation({ id: "1", title: "Conversation 1", sandbox_status: "RUNNING", execution_status: "RUNNING", sandbox_id: "sandbox1" }),
createMockConversation({ id: "2", title: "Conversation 2", sandbox_status: "STOPPED", execution_status: "FINISHED", sandbox_id: "sandbox2" }),
createMockConversation({ id: "3", title: "Conversation 3", sandbox_status: "STOPPED", execution_status: "FINISHED", sandbox_id: "sandbox3" }),
];
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
);
getUserConversationsSpy.mockImplementation(async () => ({
results: mockData,
searchConversationsSpy.mockImplementation(async () => ({
items: mockData,
next_page_id: null,
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(2);
// Component shows all 3 conversations (no filtering by status)
expect(cards).toHaveLength(3);
// Click ellipsis on the first card (RUNNING status)
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
@@ -475,7 +366,7 @@ describe("ConversationPanel", () => {
// Verify the mutation was called
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "1",
version: undefined,
version: "V1",
});
expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
});
@@ -483,54 +374,18 @@ describe("ConversationPanel", () => {
it("should only show stop button for STARTING or RUNNING conversations", async () => {
const user = userEvent.setup();
const mockMixedStatusConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Stopped Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
const mockMixedStatusConversations: V1AppConversation[] = [
createMockConversation({ id: "1", title: "Running Conversation", sandbox_status: "RUNNING", execution_status: "RUNNING", sandbox_id: "sandbox1" }),
createMockConversation({ id: "2", title: "Starting Conversation", sandbox_status: "STARTING", execution_status: "RUNNING", sandbox_id: "sandbox2" }),
createMockConversation({ id: "3", title: "Stopped Conversation", sandbox_status: "STOPPED", execution_status: "FINISHED", sandbox_id: "sandbox3" }),
];
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
);
getUserConversationsSpy.mockResolvedValue({
results: mockMixedStatusConversations,
searchConversationsSpy.mockResolvedValue({
items: mockMixedStatusConversations,
next_page_id: null,
});
@@ -634,12 +489,12 @@ describe("ConversationPanel", () => {
it("should successfully update conversation title", async () => {
const user = userEvent.setup();
// Mock the updateConversation API call
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
// Mock the updateConversationTitle API call
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
);
updateConversationSpy.mockResolvedValue(true);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
renderConversationPanel();
@@ -661,19 +516,17 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API call was made with correct parameters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Updated Title",
});
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Updated Title");
});
it("should save title when Enter key is pressed", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
);
updateConversationSpy.mockResolvedValue(true);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
renderConversationPanel();
@@ -693,19 +546,17 @@ describe("ConversationPanel", () => {
await user.keyboard("{Enter}");
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Title Updated via Enter",
});
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Title Updated via Enter");
});
it("should trim whitespace from title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
);
updateConversationSpy.mockResolvedValue(true);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
renderConversationPanel();
@@ -725,19 +576,17 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API call was made with trimmed title
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Trimmed Title",
});
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Trimmed Title");
});
it("should revert to original title when empty", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
);
updateConversationSpy.mockResolvedValue(true);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
renderConversationPanel();
@@ -756,17 +605,18 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API was not called
expect(updateConversationSpy).not.toHaveBeenCalled();
expect(updateConversationTitleSpy).not.toHaveBeenCalled();
});
it("should handle API error when updating title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
);
updateConversationSpy.mockRejectedValue(new Error("API Error"));
updateConversationTitleSpy.mockRejectedValue(new Error("API Error"));
// Provide return type for mock
renderConversationPanel();
@@ -786,13 +636,11 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Failed Update",
});
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Failed Update");
// Wait for error handling
await waitFor(() => {
expect(updateConversationSpy).toHaveBeenCalled();
expect(updateConversationTitleSpy).toHaveBeenCalled();
});
});
@@ -828,11 +676,11 @@ describe("ConversationPanel", () => {
it("should not call API when title is unchanged", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
);
updateConversationSpy.mockResolvedValue(true);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
renderConversationPanel();
@@ -849,7 +697,7 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API was NOT called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationSpy).not.toHaveBeenCalledWith("1", {
expect(updateConversationTitleSpy).not.toHaveBeenCalledWith("1", {
title: "Conversation 1",
});
});
@@ -857,11 +705,11 @@ describe("ConversationPanel", () => {
it("should handle special characters in title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
);
updateConversationSpy.mockResolvedValue(true);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
renderConversationPanel();
@@ -881,9 +729,7 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API call was made with special characters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Special @#$%^&*()_+ Characters",
});
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Special @#$%^&*()_+ Characters");
});
it("should close delete modal when clicking backdrop", async () => {
@@ -918,24 +764,14 @@ describe("ConversationPanel", () => {
const user = userEvent.setup();
// Create mock data with a RUNNING conversation
const mockRunningConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
const mockRunningConversations: V1AppConversation[] = [
createMockConversation({ id: "1", title: "Running Conversation", sandbox_status: "RUNNING", execution_status: "RUNNING", sandbox_id: "sandbox1" }),
createMockConversation({ id: "2", title: "Starting Conversation", sandbox_status: "STARTING", execution_status: "RUNNING", sandbox_id: "sandbox2" }),
createMockConversation({ id: "3", title: "Stopped Conversation", sandbox_status: "STOPPED", execution_status: "FINISHED", sandbox_id: "sandbox3" }),
];
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
results: mockRunningConversations,
vi.spyOn(V1ConversationService, "searchConversations").mockResolvedValue({
items: mockRunningConversations,
next_page_id: null,
});
@@ -296,6 +296,46 @@ describe("ConversationName", () => {
).not.toBeInTheDocument();
});
it("should render the llm model when available", () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
llm_model: "openai/gpt-4o",
} as Conversation,
});
renderConversationNameWithRouter();
const model = screen.getByTestId("conversation-name-llm-model");
expect(model).toBeInTheDocument();
expect(model).toHaveTextContent("openai/gpt-4o");
expect(model).toHaveAttribute("title", "openai/gpt-4o");
expect(model.querySelector("svg")).toBeInTheDocument();
// Verify truncation structure: text is wrapped in a span with truncate class
const textSpan = model.querySelector("span.truncate");
expect(textSpan).toBeInTheDocument();
expect(textSpan).toHaveTextContent("openai/gpt-4o");
});
it("should not render the llm model when not available", () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
},
});
renderConversationNameWithRouter();
expect(
screen.queryByTestId("conversation-name-llm-model"),
).not.toBeInTheDocument();
});
it("should focus input when entering edit mode", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
@@ -0,0 +1,83 @@
import { screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { BrowserRouter } from "react-router";
import { RecentConversation } from "#/components/features/home/recent-conversations/recent-conversation";
import type { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
CONVERSATION$AGO: "ago",
COMMON$NO_REPOSITORY: "No repository",
};
return translations[key] || key;
},
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
const baseConversation: V1AppConversation = {
id: "test-id",
title: "Test Conversation",
sandbox_status: "RUNNING",
execution_status: "RUNNING",
updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
selected_repository: null,
selected_branch: null,
git_provider: null,
conversation_url: null,
created_by_user_id: "user1",
metrics: null,
llm_model: null,
sandbox_id: "sandbox1",
trigger: null,
pr_number: [],
session_api_key: null,
};
const renderRecentConversation = (conversation: V1AppConversation) =>
renderWithProviders(
<BrowserRouter>
<RecentConversation conversation={conversation} />
</BrowserRouter>,
);
describe("RecentConversation - llm_model", () => {
it("should render the llm model when provided", () => {
renderRecentConversation({
...baseConversation,
llm_model: "anthropic/claude-sonnet-4-20250514",
});
const model = screen.getByTestId("recent-conversation-llm-model");
expect(model).toBeInTheDocument();
expect(model).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
expect(model).toHaveAttribute(
"title",
"anthropic/claude-sonnet-4-20250514",
);
expect(model.querySelector("svg")).toBeInTheDocument();
// Verify truncation structure: text is wrapped in a span with truncate class
const textSpan = model.querySelector("span.truncate");
expect(textSpan).toBeInTheDocument();
expect(textSpan).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
});
it("should not render the llm model when not provided", () => {
renderRecentConversation(baseConversation);
expect(
screen.queryByTestId("recent-conversation-llm-model"),
).not.toBeInTheDocument();
});
});
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoutesStub } from "react-router";
import { RecentConversations } from "#/components/features/home/recent-conversations/recent-conversations";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
const renderRecentConversations = () => {
const RouterStub = createRoutesStub([
@@ -29,13 +29,13 @@ const renderRecentConversations = () => {
};
describe("RecentConversations", () => {
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
);
it("should not show empty state when there is an error", async () => {
getUserConversationsSpy.mockRejectedValue(
searchConversationsSpy.mockRejectedValue(
new Error("Failed to fetch conversations"),
);
@@ -335,36 +335,6 @@ describe("Conversation WebSocket Handler", () => {
});
});
it("should show friendly i18n message for budget/credit errors", async () => {
// Create a mock AgentErrorEvent with budget-related error message
const mockBudgetErrorEvent = createMockAgentErrorEvent({
error:
"litellm.BadRequestError: Litellm_proxyException - ExceededBudget: User=xxx over budget.",
});
// Set up MSW to send the budget error event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.send(JSON.stringify(mockBudgetErrorEvent));
}),
);
// Render components that use both WebSocket and error message store
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
// Initially should show "none"
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
// Wait for connection and error event processing
// Should show the i18n key instead of raw error message
await waitFor(() => {
expect(screen.getByTestId("error-message")).toHaveTextContent(
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
);
});
});
it("should update error message store on ServerErrorEvent", async () => {
// ServerErrorEvent represents server-side errors (e.g., MCP configuration errors)
// that should be shown as a banner to the user.
@@ -502,6 +472,97 @@ describe("Conversation WebSocket Handler", () => {
});
});
it("should not clear budget error when non-agent events are received", async () => {
// Regression test: budget/credit error banner used to disappear ~500ms after
// appearing because every subsequent non-error event called removeErrorMessage().
const conversationId = "test-conversation-budget-persist";
const mockBudgetError = createMockConversationErrorEvent({
id: "budget-error-1",
detail:
"Budget has been exceeded! Current cost: 18.51, Max budget: 18.24",
});
// A user MessageEvent (source: "user") should NOT clear the budget error
const mockUserEvent = createMockUserMessageEvent({
id: "user-msg-after-error",
});
mswServer.use(
http.get(
`http://localhost:3000/api/conversations/${conversationId}/events/count`,
() => HttpResponse.json(2),
),
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send budget error, then a non-agent event right after
client.send(JSON.stringify(mockBudgetError));
client.send(JSON.stringify(mockUserEvent));
}),
);
renderWithWebSocketContext(
<ErrorMessageStoreComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Wait for both events to be processed
await waitFor(() => {
expect(useEventStore.getState().events.length).toBe(2);
});
// Budget error should still be visible — not cleared by the user event
expect(useErrorMessageStore.getState().errorMessage).toBe(
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
);
});
it("should clear budget error when an agent event is received", async () => {
// When the agent sends a new event, it means the LLM is working
// (credits are available), so the budget error should be cleared.
const conversationId = "test-conversation-budget-clear";
const mockBudgetError = createMockConversationErrorEvent({
id: "budget-error-2",
detail:
"Budget has been exceeded! Current cost: 18.51, Max budget: 18.24",
});
// An agent MessageEvent (source: "agent") SHOULD clear the budget error
const mockAgentEvent = createMockMessageEvent({
id: "agent-msg-after-credits",
});
mswServer.use(
http.get(
`http://localhost:3000/api/conversations/${conversationId}/events/count`,
() => HttpResponse.json(2),
),
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.send(JSON.stringify(mockBudgetError));
client.send(JSON.stringify(mockAgentEvent));
}),
);
renderWithWebSocketContext(
<ErrorMessageStoreComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Wait for both events to be processed
await waitFor(() => {
expect(useEventStore.getState().events.length).toBe(2);
});
// After both events processed, the budget error should have been cleared
// by the agent event (source: "agent"). Check it's not the budget error.
const currentError = useErrorMessageStore.getState().errorMessage;
expect(currentError).not.toBe("STATUS$ERROR_LLM_OUT_OF_CREDITS");
});
it("should set error message store on WebSocket connection errors", async () => {
// 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.
@@ -1,121 +0,0 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual("@tanstack/react-query");
return {
...actual,
useQuery: vi.fn(),
useQueries: vi.fn(),
};
});
// Mock the dependencies
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
vi.mock("#/hooks/use-runtime-is-ready", () => ({
useRuntimeIsReady: () => true,
}));
vi.mock("#/api/open-hands", () => ({
default: {
getWebHosts: vi
.fn()
.mockResolvedValue(["http://localhost:3000", "http://localhost:3001"]),
},
}));
vi.mock("axios", () => ({
default: {
get: vi.fn().mockResolvedValue({ data: "OK" }),
create: vi.fn(() => ({
get: vi.fn(),
interceptors: {
response: {
use: vi.fn(),
},
},
})),
},
}));
describe("useActiveHost", () => {
let queryClient: QueryClient;
beforeEach(async () => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Mock useQuery to return hosts data
const { useQuery, useQueries } = await import("@tanstack/react-query");
vi.mocked(useQuery).mockReturnValue({
data: { hosts: ["http://localhost:3000", "http://localhost:3001"] },
isLoading: false,
error: null,
} as any);
// Mock useQueries to return empty array of results
vi.mocked(useQueries).mockReturnValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it("should configure refetchInterval for host availability queries", async () => {
// Import the hook after mocks are set up
const { useActiveHost } = await import("#/hooks/query/use-active-host");
const { useQueries } = await import("@tanstack/react-query");
renderHook(() => useActiveHost(), { wrapper });
// Check that useQueries was called
expect(useQueries).toHaveBeenCalled();
// Get the queries configuration passed to useQueries
const queriesConfig = vi.mocked(useQueries).mock.calls[0][0];
// Verify that the queries configuration includes refetchInterval
expect(queriesConfig).toEqual({
queries: expect.arrayContaining([
expect.objectContaining({
refetchInterval: 3000,
}),
]),
});
});
it("should fail if refetchInterval is not configured", async () => {
// Import the hook after mocks are set up
const { useActiveHost } = await import("#/hooks/query/use-active-host");
const { useQueries } = await import("@tanstack/react-query");
renderHook(() => useActiveHost(), { wrapper });
// Check that useQueries was called
expect(useQueries).toHaveBeenCalled();
// Get the queries configuration passed to useQueries
const queriesConfig = vi.mocked(useQueries).mock.calls[0][0];
// This test will fail if refetchInterval is commented out in the hook
// because the queries won't have the refetchInterval property
const hasRefetchInterval = (queriesConfig as any).queries.some(
(query: any) => query.refetchInterval === 3000,
);
expect(hasRefetchInterval).toBe(true);
});
});
@@ -99,71 +99,6 @@ function renderWithProviders(
describe("PostHog Analytics Tracking", () => {
describe("Credit Limit Tracking", () => {
it("should track credit_limit_reached when AgentErrorEvent contains budget error", async () => {
// Create a mock AgentErrorEvent with budget-related error message
const mockBudgetErrorEvent = createMockAgentErrorEvent({
error: "ExceededBudget: Task exceeded maximum budget of $10.00",
});
// Set up MSW to send the budget error event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock budget error event after connection
client.send(JSON.stringify(mockBudgetErrorEvent));
}),
);
// Render with all providers
renderWithProviders(<ConnectionStatusComponent />);
// Wait for connection to be established
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the tracking event to be captured
await waitFor(() => {
expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: "test-conversation-123",
}),
);
});
});
it("should track credit_limit_reached when AgentErrorEvent contains 'credit' keyword", async () => {
// Create error with "credit" keyword (case-insensitive)
const mockCreditErrorEvent = createMockAgentErrorEvent({
error: "Insufficient CREDIT to complete this operation",
});
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.send(JSON.stringify(mockCreditErrorEvent));
}),
);
renderWithProviders(<ConnectionStatusComponent />);
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
await waitFor(() => {
expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: "test-conversation-123",
}),
);
});
});
it("should NOT track credit_limit_reached for non-budget errors", async () => {
// Create a regular error without budget/credit keywords
const mockRegularErrorEvent = createMockAgentErrorEvent({
@@ -190,49 +125,6 @@ describe("PostHog Analytics Tracking", () => {
expect(mockTrackCreditLimitReached).not.toHaveBeenCalled();
});
it("should only track credit_limit_reached once per error event", async () => {
const mockBudgetErrorEvent = createMockAgentErrorEvent({
error: "Budget exceeded: $10.00 limit reached",
});
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the same error event twice
client.send(JSON.stringify(mockBudgetErrorEvent));
client.send(
JSON.stringify({ ...mockBudgetErrorEvent, id: "different-id" }),
);
}),
);
renderWithProviders(<ConnectionStatusComponent />);
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
await waitFor(() => {
expect(mockTrackCreditLimitReached).toHaveBeenCalledTimes(2);
});
// Both calls should be for credit_limit_reached (once per event)
expect(mockTrackCreditLimitReached).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
conversationId: "test-conversation-123",
}),
);
expect(mockTrackCreditLimitReached).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
conversationId: "test-conversation-123",
}),
);
});
it("should track credit_limit_reached when ConversationErrorEvent contains budget error", async () => {
const mockBudgetConversationError = createMockConversationErrorEvent({
detail:
+7 -9
View File
@@ -11905,11 +11905,10 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true
},
"node_modules/lodash.curry": {
"version": "4.1.1",
@@ -13821,10 +13820,9 @@
"license": "MIT"
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
},
"node_modules/path-type": {
"version": "4.0.0",
@@ -194,23 +194,6 @@ class ConversationService {
return data;
}
static async getUserConversations(
limit: number = 20,
pageId?: string,
): Promise<ResultSet<Conversation>> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (pageId) {
params.append("page_id", pageId);
}
const { data } = await openHands.get<ResultSet<Conversation>>(
`/api/conversations?${params.toString()}`,
);
return data;
}
static async searchConversations(
selectedRepository?: string,
conversationTrigger?: string,
@@ -467,6 +467,57 @@ class V1ConversationService {
return data.items;
}
/**
* Search for V1 conversations (general search with pagination)
* Use this to populate the side menu with user's conversations
*
* @param limit Maximum number of results (default: 20)
* @param pageId Optional page ID for pagination
* @returns Paginated list of conversations
*/
static async searchConversations(
limit: number = 20,
pageId?: string,
): Promise<V1AppConversationPage> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (pageId) {
params.append("page_id", pageId);
}
const { data } = await openHands.get<V1AppConversationPage>(
`/api/v1/app-conversations/search?${params.toString()}`,
);
return data;
}
/**
* Delete a V1 conversation
* @param conversationId The conversation ID to delete
* @returns void on success
*/
static async deleteConversation(conversationId: string): Promise<void> {
await openHands.delete(`/api/v1/app-conversations/${conversationId}`);
}
/**
* Update a V1 conversation's title
* @param conversationId The conversation ID
* @param title The new title
* @returns Updated conversation info
*/
static async updateConversationTitle(
conversationId: string,
title: string,
): Promise<V1AppConversation> {
const { data } = await openHands.patch<V1AppConversation>(
`/api/v1/app-conversations/${conversationId}`,
{ title },
);
return data;
}
}
export default V1ConversationService;
+1
View File
@@ -80,6 +80,7 @@ export interface Conversation {
sub_conversation_ids?: string[];
public?: boolean;
sandbox_id?: string | null;
llm_model?: string | null;
}
export interface ResultSet<T> {
@@ -1,6 +1,6 @@
import React from "react";
import { cn } from "#/utils/utils";
import { ConversationStatus } from "#/types/conversation-status";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
import EllipsisIcon from "#/icons/ellipsis.svg?react";
@@ -12,7 +12,7 @@ interface ConversationCardActionsProps {
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
conversationStatus?: ConversationStatus;
sandboxStatus?: V1SandboxStatus;
conversationId?: string;
showOptions?: boolean;
}
@@ -25,11 +25,11 @@ export function ConversationCardActions({
onEdit,
onDownloadViaVSCode,
onDownloadConversation,
conversationStatus,
sandboxStatus,
conversationId,
showOptions,
}: ConversationCardActionsProps) {
const isConversationArchived = conversationStatus === "ARCHIVED";
const isConversationStopped = sandboxStatus === "STOPPED";
return (
<div className="group">
@@ -43,7 +43,7 @@ export function ConversationCardActions({
}}
className={cn(
"cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5",
isConversationArchived && "opacity-60",
isConversationStopped && "opacity-60",
)}
>
<EllipsisIcon />
@@ -60,8 +60,7 @@ export function ConversationCardActions({
onClose={() => onContextMenuToggle(false)}
onDelete={onDelete}
onStop={
conversationStatus === "RUNNING" ||
conversationStatus === "STARTING"
sandboxStatus === "RUNNING" || sandboxStatus === "STARTING"
? onStop
: undefined
}
@@ -3,26 +3,30 @@ import { formatTimeDelta } from "#/utils/format-time-delta";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { RepositorySelection } from "#/api/open-hands.types";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { ConversationRepoLink } from "./conversation-repo-link";
import { NoRepository } from "./no-repository";
import { ConversationStatus } from "#/types/conversation-status";
import CircuitIcon from "#/icons/u-circuit.svg?react";
interface ConversationCardFooterProps {
selectedRepository: RepositorySelection | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
conversationStatus?: ConversationStatus;
sandboxStatus?: V1SandboxStatus;
llmModel?: string | null;
}
export function ConversationCardFooter({
selectedRepository,
lastUpdatedAt,
createdAt,
conversationStatus,
sandboxStatus,
llmModel,
}: ConversationCardFooterProps) {
const { t } = useTranslation();
const isConversationArchived = conversationStatus === "ARCHIVED";
const isConversationArchived =
sandboxStatus === "STOPPED" || sandboxStatus === "MISSING";
return (
<div
@@ -36,13 +40,25 @@ export function ConversationCardFooter({
) : (
<NoRepository />
)}
{(createdAt ?? lastUpdatedAt) && (
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
<time>
{`${formatTimeDelta(lastUpdatedAt ?? createdAt)} ${t(I18nKey.CONVERSATION$AGO)}`}
</time>
</p>
)}
<div className="flex items-center gap-2 flex-1 justify-end">
{llmModel && (
<span
className="text-xs text-[#A3A3A3] max-w-[120px] flex items-center gap-1 overflow-hidden"
title={llmModel}
data-testid="conversation-card-llm-model"
>
<CircuitIcon width={12} height={12} className="shrink-0" />
<span className="truncate">{llmModel}</span>
</span>
)}
{(createdAt ?? lastUpdatedAt) && (
<p className="text-xs text-[#A3A3A3] text-right">
<time>
{`${formatTimeDelta(lastUpdatedAt ?? createdAt)} ${t(I18nKey.CONVERSATION$AGO)}`}
</time>
</p>
)}
</div>
</div>
);
}
@@ -1,51 +1,37 @@
import { ConversationStatus } from "#/types/conversation-status";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { ConversationCardTitle } from "./conversation-card-title";
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
import { ConversationStatusBadges } from "./conversation-status-badges";
import { ConversationVersionBadge } from "./conversation-version-badge";
import { SandboxStatusIndicator } from "../../home/recent-conversations/sandbox-status-indicator";
interface ConversationCardHeaderProps {
title: string;
titleMode: "view" | "edit";
onTitleSave: (title: string) => void;
conversationStatus?: ConversationStatus;
conversationVersion?: "V0" | "V1";
sandboxStatus?: V1SandboxStatus;
}
export function ConversationCardHeader({
title,
titleMode,
onTitleSave,
conversationStatus,
conversationVersion,
sandboxStatus,
}: ConversationCardHeaderProps) {
const isConversationArchived = conversationStatus === "ARCHIVED";
const isConversationArchived =
sandboxStatus === "STOPPED" || sandboxStatus === "MISSING";
return (
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{/* Status Indicator */}
{conversationStatus && (
{/* Status Indicator - use V1 sandbox status directly */}
{sandboxStatus && (
<div className="flex items-center">
<ConversationStatusIndicator
conversationStatus={conversationStatus}
/>
<SandboxStatusIndicator sandboxStatus={sandboxStatus} />
</div>
)}
{/* Version Badge */}
<ConversationVersionBadge
version={conversationVersion}
isConversationArchived={isConversationArchived}
/>
<ConversationCardTitle
title={title}
titleMode={titleMode}
onSave={onTitleSave}
isConversationArchived={isConversationArchived}
/>
{/* Status Badges */}
{conversationStatus && (
<ConversationStatusBadges conversationStatus={conversationStatus} />
)}
</div>
);
}
@@ -3,11 +3,12 @@ import { usePostHog } from "posthog-js/react";
import { cn } from "#/utils/utils";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { ConversationStatus } from "#/types/conversation-status";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { RepositorySelection } from "#/api/open-hands.types";
import { ConversationCardHeader } from "./conversation-card-header";
import { ConversationCardActions } from "./conversation-card-actions";
import { ConversationCardFooter } from "./conversation-card-footer";
import { SandboxStatusBadges } from "./sandbox-status-badges";
import { useDownloadConversation } from "#/hooks/use-download-conversation";
interface ConversationCardProps {
@@ -20,11 +21,11 @@ interface ConversationCardProps {
selectedRepository: RepositorySelection | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
conversationStatus?: ConversationStatus;
sandboxStatus?: V1SandboxStatus;
conversationId?: string; // Optional conversation ID for VS Code URL
conversationVersion?: "V0" | "V1";
contextMenuOpen?: boolean;
onContextMenuToggle?: (isOpen: boolean) => void;
llmModel?: string | null;
}
export function ConversationCard({
@@ -40,10 +41,10 @@ export function ConversationCard({
lastUpdatedAt,
createdAt,
conversationId,
conversationStatus,
conversationVersion,
sandboxStatus,
contextMenuOpen = false,
onContextMenuToggle,
llmModel,
}: ConversationCardProps) {
const posthog = usePostHog();
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
@@ -109,7 +110,7 @@ export function ConversationCard({
event.preventDefault();
event.stopPropagation();
if (conversationId && conversationVersion === "V1") {
if (conversationId) {
await downloadConversation(conversationId);
}
onContextMenuToggle?.(false);
@@ -128,13 +129,15 @@ export function ConversationCard({
)}
>
<div className="flex items-center justify-between w-full">
<ConversationCardHeader
title={title}
titleMode={titleMode}
onTitleSave={onTitleSave}
conversationStatus={conversationStatus}
conversationVersion={conversationVersion}
/>
<div className="flex items-center gap-2 flex-1 min-w-0">
<ConversationCardHeader
title={title}
titleMode={titleMode}
onTitleSave={onTitleSave}
sandboxStatus={sandboxStatus}
/>
<SandboxStatusBadges sandboxStatus={sandboxStatus} />
</div>
{hasContextMenu && (
<ConversationCardActions
@@ -144,12 +147,8 @@ export function ConversationCard({
onStop={onStop && handleStop}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={handleDownloadViaVSCode}
onDownloadConversation={
conversationVersion === "V1"
? handleDownloadConversation
: undefined
}
conversationStatus={conversationStatus}
onDownloadConversation={handleDownloadConversation}
sandboxStatus={sandboxStatus}
conversationId={conversationId}
showOptions={showOptions}
/>
@@ -160,7 +159,8 @@ export function ConversationCard({
selectedRepository={selectedRepository}
lastUpdatedAt={lastUpdatedAt}
createdAt={createdAt}
conversationStatus={conversationStatus}
sandboxStatus={sandboxStatus}
llmModel={llmModel}
/>
</div>
);
@@ -0,0 +1,26 @@
import { FaArchive } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
interface SandboxStatusBadgesProps {
sandboxStatus?: V1SandboxStatus;
}
export function SandboxStatusBadges({
sandboxStatus,
}: SandboxStatusBadgesProps) {
const { t } = useTranslation();
// Only show badge for MISSING (archived) status
if (sandboxStatus !== "MISSING") {
return null;
}
return (
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full opacity-60">
<FaArchive size={10} className="text-white" />
<span>{t(I18nKey.COMMON$ARCHIVED)}</span>
</span>
);
}
@@ -63,8 +63,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
// Fetch in-progress start tasks
const { data: startTasks } = useStartTasks();
// Flatten all pages into a single array of conversations
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
// Flatten all pages into a single array of conversations (V1 uses 'items' instead of 'results')
const conversations = data?.pages.flatMap((page) => page.items) ?? [];
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: pauseConversationSandbox } =
@@ -176,41 +176,41 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
</NavLink>
))}
{/* Then render completed conversations */}
{conversations?.map((project) => (
{conversations?.map((conversation) => (
<NavLink
key={project.conversation_id}
to={`/conversations/${project.conversation_id}`}
key={conversation.id}
to={`/conversations/${conversation.id}`}
onClick={onClose}
>
<ConversationCard
onDelete={() =>
handleDeleteProject(project.conversation_id, project.title)
handleDeleteProject(conversation.id, conversation.title ?? "")
}
onStop={() =>
handleStopConversation(
project.conversation_id,
project.conversation_version,
project.sandbox_id,
conversation.id,
"V1",
conversation.sandbox_id,
)
}
onChangeTitle={(title) =>
handleConversationTitleChange(project.conversation_id, title)
handleConversationTitleChange(conversation.id, title)
}
title={project.title}
title={conversation.title ?? ""}
selectedRepository={{
selected_repository: project.selected_repository,
selected_branch: project.selected_branch,
git_provider: project.git_provider as Provider,
selected_repository: conversation.selected_repository,
selected_branch: conversation.selected_branch,
git_provider: conversation.git_provider as Provider,
}}
lastUpdatedAt={project.last_updated_at}
createdAt={project.created_at}
conversationStatus={project.status}
conversationId={project.conversation_id}
conversationVersion={project.conversation_version}
contextMenuOpen={openContextMenuId === project.conversation_id}
lastUpdatedAt={conversation.updated_at}
createdAt={conversation.created_at}
sandboxStatus={conversation.sandbox_status}
conversationId={conversation.id}
contextMenuOpen={openContextMenuId === conversation.id}
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? project.conversation_id : null)
setOpenContextMenuId(isOpen ? conversation.id : null)
}
llmModel={conversation.llm_model}
/>
</NavLink>
))}
@@ -15,6 +15,7 @@ import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
import { MetricsModal } from "./metrics-modal/metrics-modal";
import { ConversationVersionBadge } from "../conversation-panel/conversation-card/conversation-version-badge";
import CircuitIcon from "#/icons/u-circuit.svg?react";
export function ConversationName() {
const { t } = useTranslation();
@@ -169,6 +170,17 @@ export function ConversationName() {
/>
)}
{titleMode !== "edit" && conversation.llm_model && (
<span
className="text-xs text-[#A3A3A3] max-w-[150px] flex items-center gap-1 overflow-hidden"
title={conversation.llm_model}
data-testid="conversation-name-llm-model"
>
<CircuitIcon width={12} height={12} className="shrink-0" />
<span className="truncate">{conversation.llm_model}</span>
</span>
)}
{titleMode !== "edit" && (
<div className="relative flex items-center">
<EllipsisButton fill="#B1B9D3" onClick={handleEllipsisClick} />
@@ -1,16 +1,17 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import CodeBranchIcon from "#/icons/u-code-branch.svg?react";
import { Conversation } from "#/api/open-hands.types";
import { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { Provider } from "#/types/settings";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { I18nKey } from "#/i18n/declaration";
import { ConversationStatusIndicator } from "./conversation-status-indicator";
import { SandboxStatusIndicator } from "./sandbox-status-indicator";
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
import CircuitIcon from "#/icons/u-circuit.svg?react";
interface RecentConversationProps {
conversation: Conversation;
conversation: V1AppConversation;
}
export function RecentConversation({ conversation }: RecentConversationProps) {
@@ -21,11 +22,11 @@ export function RecentConversation({ conversation }: RecentConversationProps) {
return (
<Link
to={`/conversations/${conversation.conversation_id}`}
to={`/conversations/${conversation.id}`}
className="flex flex-col gap-1 p-[14px] cursor-pointer w-full rounded-lg hover:bg-[#5C5D62] transition-all duration-300 text-left"
>
<div className="flex items-center gap-2 pl-1">
<ConversationStatusIndicator conversationStatus={conversation.status} />
<SandboxStatusIndicator sandboxStatus={conversation.sandbox_status} />
<span className="text-xs text-white leading-6 font-normal">
{conversation.title}
</span>
@@ -64,14 +65,26 @@ export function RecentConversation({ conversation }: RecentConversationProps) {
</div>
) : null}
</div>
{(conversation.created_at || conversation.last_updated_at) && (
<span>
{formatTimeDelta(
conversation.created_at || conversation.last_updated_at,
)}{" "}
{t(I18nKey.CONVERSATION$AGO)}
</span>
)}
<div className="flex items-center gap-2">
{conversation.llm_model && (
<span
className="max-w-[120px] flex items-center gap-1 overflow-hidden"
title={conversation.llm_model}
data-testid="recent-conversation-llm-model"
>
<CircuitIcon width={12} height={12} className="shrink-0" />
<span className="truncate">{conversation.llm_model}</span>
</span>
)}
{(conversation.created_at || conversation.updated_at) && (
<span>
{formatTimeDelta(
conversation.created_at || conversation.updated_at,
)}{" "}
{t(I18nKey.CONVERSATION$AGO)}
</span>
)}
</div>
</div>
</Link>
);
@@ -29,7 +29,7 @@ export function RecentConversations() {
});
const conversations =
conversationsList?.pages.flatMap((page) => page.results) ?? [];
conversationsList?.pages.flatMap((page) => page.items) ?? [];
// Get the conversations to display based on expansion state
const displayLimit = isExpanded ? 10 : 3;
@@ -92,7 +92,7 @@ export function RecentConversations() {
<div ref={scrollContainerRef} className="flex flex-col">
{displayedConversations.map((conversation) => (
<RecentConversation
key={conversation.conversation_id}
key={conversation.id}
conversation={conversation}
/>
))}
@@ -0,0 +1,65 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { cn } from "#/utils/utils";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
interface SandboxStatusIndicatorProps {
sandboxStatus: V1SandboxStatus;
}
// Map V1SandboxStatus to translation keys
const getSandboxStatusLabel = (status: V1SandboxStatus): string => {
switch (status) {
case "RUNNING":
return "COMMON$RUNNING";
case "STARTING":
return "COMMON$STARTING";
case "STOPPED":
return "COMMON$STOPPED";
case "PAUSED":
return "COMMON$PAUSED";
case "MISSING":
return "COMMON$ARCHIVED";
default:
return "COMMON$STOPPED";
}
};
export function SandboxStatusIndicator({
sandboxStatus,
}: SandboxStatusIndicatorProps) {
const { t } = useTranslation();
const sandboxStatusBackgroundColor = useMemo(() => {
switch (sandboxStatus) {
case "RUNNING":
return "bg-[#1FBD53]"; // Running/online - green
case "STARTING":
return "bg-[#FFD43B]"; // Busy/starting - yellow
case "PAUSED":
return "bg-[#A3A3A3]"; // Paused - grey
case "STOPPED":
return "bg-[#3C3C49]"; // Stopped - dark grey
case "MISSING":
return "bg-[#A3A3A3]"; // Missing - grey (archived)
default:
return "bg-[#3C3C49]"; // Default to grey for unknown states
}
}, [sandboxStatus]);
const statusLabel = t(getSandboxStatusLabel(sandboxStatus));
return (
<StyledTooltip
content={statusLabel}
placement="right"
showArrow
tooltipClassName="bg-[#1a1a1a] text-white text-xs shadow-lg"
>
<div
className={cn("w-1.5 h-1.5 rounded-full", sandboxStatusBackgroundColor)}
/>
</StyledTooltip>
);
}
@@ -139,6 +139,25 @@ export function ConversationWebSocketProvider({
const isPlanFilePath = (path: string | null): boolean =>
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
// Helper to handle error clearing logic for non-error events.
// Budget/credit errors persist until an agent event proves the LLM is working.
const handleNonErrorEvent = useCallback(
(event: { source?: string }) => {
const currentError = useErrorMessageStore.getState().errorMessage;
const isBudgetError =
currentError === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS;
const isAgentEvent = event.source === "agent";
// Budget errors persist until agent proves LLM is working
if (isBudgetError && !isAgentEvent) {
return; // Keep budget error visible
}
removeErrorMessage();
},
[removeErrorMessage],
);
// Helper function to update metrics from stats event
const updateMetricsFromStats = useCallback(
(event: ConversationStateUpdateEventStats) => {
@@ -380,8 +399,7 @@ export function ConversationWebSocketProvider({
setErrorMessage(errorEvent.detail);
}
} else {
// Clear error message on any non-ConversationErrorEvent
removeErrorMessage();
handleNonErrorEvent(event);
}
// Track credit limit reached if AgentErrorEvent has budget-related error
@@ -396,15 +414,7 @@ export function ConversationWebSocketProvider({
},
posthog,
});
// Use friendly i18n message for budget/credit errors instead of raw error
if (isBudgetOrCreditError(event.error)) {
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
trackCreditLimitReached({
conversationId: conversationId || "unknown",
});
} else {
setErrorMessage(event.error);
}
setErrorMessage(event.error);
}
// Clear optimistic user message when a user message is confirmed
@@ -546,8 +556,7 @@ export function ConversationWebSocketProvider({
setErrorMessage(errorEvent.detail);
}
} else {
// Clear error message on any non-ConversationErrorEvent
removeErrorMessage();
handleNonErrorEvent(event);
}
// Handle AgentErrorEvent specifically
@@ -562,15 +571,7 @@ export function ConversationWebSocketProvider({
},
posthog,
});
// Use friendly i18n message for budget/credit errors instead of raw error
if (isBudgetOrCreditError(event.error)) {
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
trackCreditLimitReached({
conversationId: conversationId || "unknown",
});
} else {
setErrorMessage(event.error);
}
setErrorMessage(event.error);
}
// Clear optimistic user message when a user message is confirmed
@@ -103,16 +103,22 @@ export const startV0Conversation = async (
/**
* Optimistically updates the conversation status in the cache
*/
export const updateConversationStatusInCache = (
export const updateConversationSandboxStatusInCache = (
queryClient: QueryClient,
conversationId: string,
status: string,
sandbox_status: string,
): void => {
// Update the individual conversation cache
queryClient.setQueryData<{ status: string }>(
["user", "conversation", conversationId],
(oldData) => {
if (!oldData) return oldData;
let status = sandbox_status;
if (status === "PAUSED") {
status = "STOPPED";
} else if (status === "MISSING") {
status = "ARCHIVED";
}
return { ...oldData, status };
},
);
@@ -120,7 +126,7 @@ export const updateConversationStatusInCache = (
// Update the conversations list cache
queryClient.setQueriesData<{
pages: Array<{
results: Array<{ conversation_id: string; status: string }>;
items: Array<{ id: string; sandbox_status: string }>;
}>;
}>({ queryKey: ["user", "conversations"] }, (oldData) => {
if (!oldData) return oldData;
@@ -129,8 +135,8 @@ export const updateConversationStatusInCache = (
...oldData,
pages: oldData.pages.map((page) => ({
...page,
results: page.results.map((conv) =>
conv.conversation_id === conversationId ? { ...conv, status } : conv,
items: page.items.map((conv) =>
conv.id === conversationId ? { ...conv, sandbox_status } : conv,
),
})),
};
@@ -1,43 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Provider } from "#/types/settings";
export const useStartConversation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: {
conversationId: string;
providers?: Provider[];
}) =>
ConversationService.startConversation(
variables.conversationId,
variables.providers,
),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
"user",
"conversations",
]);
return { previousConversations };
},
onError: (_, __, context) => {
if (context?.previousConversations) {
queryClient.setQueryData(
["user", "conversations"],
context.previousConversations,
);
}
},
onSettled: (_, __, variables) => {
// Invalidate the specific conversation query to trigger automatic refetch
queryClient.invalidateQueries({
queryKey: ["user", "conversation", variables.conversationId],
});
// Also invalidate the conversations list for consistency
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
},
});
};
@@ -1,50 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import ConversationService from "#/api/conversation-service/conversation-service.api";
export const useStopConversation = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { conversationId: currentConversationId } = useParams<{
conversationId: string;
}>();
return useMutation({
mutationFn: (variables: { conversationId: string }) =>
ConversationService.stopConversation(variables.conversationId),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
"user",
"conversations",
]);
return { previousConversations };
},
onError: (_, __, context) => {
if (context?.previousConversations) {
queryClient.setQueryData(
["user", "conversations"],
context.previousConversations,
);
}
},
onSettled: (_, __, variables) => {
// Invalidate the specific conversation query to trigger automatic refetch
queryClient.invalidateQueries({
queryKey: ["user", "conversation", variables.conversationId],
});
// Also invalidate the conversations list for consistency
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
},
onSuccess: (_, variables) => {
// Only redirect if we're stopping the conversation we're currently viewing
if (
currentConversationId &&
variables.conversationId === currentConversationId
) {
navigate("/");
}
},
});
};
@@ -5,7 +5,7 @@ import {
getConversationVersionFromQueryCache,
resumeV1ConversationSandbox,
startV0Conversation,
updateConversationStatusInCache,
updateConversationSandboxStatusInCache,
invalidateConversationQueries,
} from "./conversation-mutation-utils";
@@ -85,10 +85,10 @@ export const useUnifiedResumeConversationSandbox = () => {
// Clear error messages when starting/resuming conversation
removeErrorMessage();
updateConversationStatusInCache(
updateConversationSandboxStatusInCache(
queryClient,
variables.conversationId,
"RUNNING",
"STARTING",
);
},
});
@@ -8,7 +8,7 @@ import {
getConversationVersionFromQueryCache,
pauseV1ConversationSandbox,
stopV0Conversation,
updateConversationStatusInCache,
updateConversationSandboxStatusInCache,
} from "./conversation-mutation-utils";
/**
@@ -81,10 +81,10 @@ export const useUnifiedPauseConversationSandbox = () => {
}
toast.success(t(I18nKey.TOAST$CONVERSATION_STOPPED), TOAST_OPTIONS);
updateConversationStatusInCache(
updateConversationSandboxStatusInCache(
queryClient,
variables.conversationId,
"STOPPED",
"PAUSED",
);
// Only redirect if we're stopping the conversation we're currently viewing
@@ -1,14 +1,15 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
export const useUpdateConversation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: { conversationId: string; newTitle: string }) =>
ConversationService.updateConversation(variables.conversationId, {
title: variables.newTitle,
}),
V1ConversationService.updateConversationTitle(
variables.conversationId,
variables.newTitle,
),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
@@ -18,9 +19,9 @@ export const useUpdateConversation = () => {
queryClient.setQueryData(
["user", "conversations"],
(old: { conversation_id: string; title: string }[] | undefined) =>
(old: { id: string; title: string }[] | undefined) =>
old?.map((conv) =>
conv.conversation_id === variables.conversationId
conv.id === variables.conversationId
? { ...conv, title: variables.newTitle }
: conv,
),
@@ -1,52 +0,0 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import axios from "axios";
import React from "react";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
export const useActiveHost = () => {
const [activeHost, setActiveHost] = React.useState<string | null>(null);
const { conversationId } = useConversationId();
const runtimeIsReady = useRuntimeIsReady();
const { data } = useQuery({
queryKey: [conversationId, "hosts"],
queryFn: async () => {
const hosts = await ConversationService.getWebHosts(conversationId);
return { hosts };
},
enabled: runtimeIsReady && !!conversationId,
initialData: { hosts: [] },
meta: {
disableToast: true,
},
});
const apps = useQueries({
queries: data.hosts.map((host) => ({
queryKey: [conversationId, "hosts", host],
queryFn: async () => {
try {
await axios.get(host);
return host;
} catch (e) {
return "";
}
},
refetchInterval: 3000,
meta: {
disableToast: true,
},
})),
});
const appsData = apps.map((app) => app.data);
React.useEffect(() => {
const successfulApp = appsData.find((app) => app);
setActiveHost(successfulApp || "");
}, [appsData]);
return { activeHost };
};
-22
View File
@@ -1,22 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import GitService from "#/api/git-service/git-service.api";
import { GitChangeStatus } from "#/api/open-hands.types";
import { useConversationId } from "#/hooks/use-conversation-id";
type UseGetDiffConfig = {
filePath: string;
type: GitChangeStatus;
enabled: boolean;
};
export const useGitDiff = (config: UseGetDiffConfig) => {
const { conversationId } = useConversationId();
return useQuery({
queryKey: ["file_diff", conversationId, config.filePath, config.type],
queryFn: () => GitService.getGitChangeDiff(conversationId, config.filePath),
enabled: config.enabled,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};
@@ -1,67 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import GitService from "#/api/git-service/git-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { GitChange } from "#/api/open-hands.types";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
export const useGetGitChanges = () => {
const { conversationId } = useConversationId();
const [orderedChanges, setOrderedChanges] = React.useState<GitChange[]>([]);
const previousDataRef = React.useRef<GitChange[]>(null);
const runtimeIsReady = useRuntimeIsReady();
const result = useQuery({
queryKey: ["file_changes", conversationId],
queryFn: () => GitService.getGitChanges(conversationId),
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: runtimeIsReady && !!conversationId,
meta: {
disableToast: true,
},
});
// Latest changes should be on top
React.useEffect(() => {
if (!result.isFetching && result.isSuccess && result.data) {
const currentData = result.data;
// If this is new data (not the same reference as before)
if (currentData !== previousDataRef.current) {
previousDataRef.current = currentData;
// Figure out new items by comparing with what we already have
if (Array.isArray(currentData)) {
const currentIds = new Set(currentData.map((item) => item.path));
const existingIds = new Set(orderedChanges.map((item) => item.path));
// Filter out items that already exist in orderedChanges
const newItems = currentData.filter(
(item) => !existingIds.has(item.path),
);
// Filter out items that no longer exist in the API response
const existingItems = orderedChanges.filter((item) =>
currentIds.has(item.path),
);
// Add new items to the beginning
setOrderedChanges([...newItems, ...existingItems]);
} else {
// If not an array, just use the data directly
setOrderedChanges([currentData]);
}
}
}
}, [result.isFetching, result.isSuccess, result.data]);
return {
data: orderedChanges,
isLoading: result.isLoading,
isSuccess: result.isSuccess,
isError: result.isError,
error: result.error,
};
};
@@ -1,26 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import InvariantService from "#/api/invariant-service";
type ResponseData = string;
interface UseGetPolicyConfig {
onSuccess: (data: ResponseData) => void;
}
export const useGetPolicy = (config?: UseGetPolicyConfig) => {
const data = useQuery<ResponseData>({
queryKey: ["policy"],
queryFn: InvariantService.getPolicy,
});
const { isFetching, isSuccess, data: policy } = data;
React.useEffect(() => {
if (!isFetching && isSuccess && policy) {
config?.onSuccess(policy);
}
}, [isFetching, isSuccess, policy, config?.onSuccess]);
return data;
};
@@ -1,26 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import InvariantService from "#/api/invariant-service";
type ResponseData = number;
interface UseGetRiskSeverityConfig {
onSuccess: (data: ResponseData) => void;
}
export const useGetRiskSeverity = (config?: UseGetRiskSeverityConfig) => {
const data = useQuery<ResponseData>({
queryKey: ["risk_severity"],
queryFn: InvariantService.getRiskSeverity,
});
const { isFetching, isSuccess, data: riskSeverity } = data;
React.useEffect(() => {
if (!isFetching && isSuccess && riskSeverity) {
config?.onSuccess(riskSeverity);
}
}, [isFetching, isSuccess, riskSeverity, config?.onSuccess]);
return data;
};
@@ -1,27 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import InvariantService from "#/api/invariant-service";
type ResponseData = object;
interface UseGetTracesConfig {
onSuccess: (data: ResponseData) => void;
}
export const useGetTraces = (config?: UseGetTracesConfig) => {
const data = useQuery({
queryKey: ["traces"],
queryFn: InvariantService.getTraces,
enabled: false,
});
const { isFetching, isSuccess, data: traces } = data;
React.useEffect(() => {
if (!isFetching && isSuccess && traces) {
config?.onSuccess(traces);
}
}, [isFetching, isSuccess, traces, config?.onSuccess]);
return data;
};
@@ -1,82 +0,0 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useAppInstallations } from "./use-app-installations";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import GitService from "#/api/git-service/git-service.api";
import { shouldUseInstallationRepos } from "#/utils/utils";
export const useInstallationRepositories = (
selectedProvider: Provider | null,
) => {
const { providers } = useUserProviders();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations(selectedProvider);
const repos = useInfiniteQuery({
queryKey: [
"repositories",
providers || [],
selectedProvider,
installations || [],
],
queryFn: async ({
pageParam,
}: {
pageParam: { installationIndex: number | null; repoPage: number | null };
}) => {
const { repoPage, installationIndex } = pageParam;
if (!installations) {
throw new Error("Missing installation list");
}
return GitService.retrieveInstallationRepositories(
selectedProvider!,
installationIndex || 0,
installations,
repoPage || 1,
30,
);
},
initialPageParam: { installationIndex: 0, repoPage: 1 },
getNextPageParam: (lastPage) => {
if (lastPage.nextPage) {
return {
installationIndex: lastPage.installationIndex,
repoPage: lastPage.nextPage,
};
}
if (lastPage.installationIndex !== null) {
return { installationIndex: lastPage.installationIndex, repoPage: 1 };
}
return null;
},
enabled:
(providers || []).length > 0 &&
!!selectedProvider &&
shouldUseInstallationRepos(selectedProvider, config?.app_mode) &&
Array.isArray(installations) &&
installations.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
const onLoadMore = () => {
if (repos.hasNextPage && !repos.isFetchingNextPage) {
repos.fetchNextPage();
}
};
// Return the query result with the scroll ref
return {
data: repos.data,
isLoading: repos.isLoading,
isError: repos.isError,
hasNextPage: repos.hasNextPage,
isFetchingNextPage: repos.isFetchingNextPage,
onLoadMore,
};
};
@@ -1,6 +1,7 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useIsAuthed } from "./use-is-authed";
import { V1AppConversationPage } from "#/api/conversation-service/v1-conversation-service.types";
export const usePaginatedConversations = (limit: number = 20) => {
const { data: userIsAuthenticated } = useIsAuthed();
@@ -8,7 +9,7 @@ export const usePaginatedConversations = (limit: number = 20) => {
return useInfiniteQuery({
queryKey: ["user", "conversations", "paginated", limit],
queryFn: async ({ pageParam }) => {
const result = await ConversationService.getUserConversations(
const result = await V1ConversationService.searchConversations(
limit,
pageParam,
);
@@ -16,7 +17,8 @@ export const usePaginatedConversations = (limit: number = 20) => {
return result;
},
enabled: !!userIsAuthenticated,
getNextPageParam: (lastPage) => lastPage.next_page_id,
getNextPageParam: (lastPage: V1AppConversationPage) =>
lastPage.next_page_id,
initialPageParam: undefined as string | undefined,
});
};
@@ -1,27 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
export const useSearchConversations = (
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 100,
cacheDisabled: boolean = false,
) =>
useQuery({
queryKey: [
"conversations",
"search",
selectedRepository,
conversationTrigger,
limit,
],
queryFn: () =>
ConversationService.searchConversations(
selectedRepository,
conversationTrigger,
limit,
),
enabled: true, // Always enabled since parameters are optional
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
});
@@ -1,41 +0,0 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import GitService from "#/api/git-service/git-service.api";
import { shouldUseInstallationRepos } from "#/utils/utils";
export const useUserRepositories = (selectedProvider: Provider | null) => {
const { providers } = useUserProviders();
const { data: config } = useConfig();
const repos = useInfiniteQuery({
queryKey: ["repositories", providers || [], selectedProvider],
queryFn: async ({ pageParam }) =>
GitService.retrieveUserGitRepositories(selectedProvider!, pageParam, 30),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled:
(providers || []).length > 0 &&
!!selectedProvider &&
!shouldUseInstallationRepos(selectedProvider, config?.app_mode),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
const onLoadMore = () => {
if (repos.hasNextPage && !repos.isFetchingNextPage) {
repos.fetchNextPage();
}
};
// Return the query result with the scroll ref
return {
data: repos.data,
isLoading: repos.isLoading,
isError: repos.isError,
hasNextPage: repos.hasNextPage,
isFetchingNextPage: repos.isFetchingNextPage,
onLoadMore,
};
};
@@ -1,60 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { I18nKey } from "#/i18n/declaration";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
// Define the return type for the VS Code URL query
interface VSCodeUrlResult {
url: string | null;
error: string | null;
}
export const useVSCodeUrl = () => {
const { t } = useTranslation();
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
return useQuery<VSCodeUrlResult>({
queryKey: [
"vscode_url",
conversationId,
isV1Conversation,
conversation?.url,
conversation?.session_api_key,
],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
// Use appropriate API based on conversation version
const data = isV1Conversation
? await V1ConversationService.getVSCodeUrl(
conversationId,
conversation?.url,
conversation?.session_api_key,
)
: await ConversationService.getVSCodeUrl(conversationId);
if (data.vscode_url) {
return {
url: transformVSCodeUrl(data.vscode_url),
error: null,
};
}
return {
url: null,
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
};
},
enabled: runtimeIsReady && !!conversationId,
refetchOnMount: true,
retry: 3,
});
};
+1 -4
View File
@@ -9,7 +9,6 @@ import { InteractiveChip } from "#/ui/interactive-chip";
import { usePermission } from "#/hooks/organizations/use-permissions";
import { createPermissionGuard } from "#/utils/org/permission-guard";
import { isBillingHidden } from "#/utils/org/billing-visibility";
import { ENABLE_ORG_CLAIMS_RESOLVER_ROUTING } from "#/utils/feature-flags";
import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal";
import { GitConversationRouting } from "#/components/features/org/git-conversation-routing";
import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal";
@@ -117,9 +116,7 @@ function ManageOrg() {
</button>
)}
{canManageOrgClaims && ENABLE_ORG_CLAIMS_RESOLVER_ROUTING() && (
<GitConversationRouting />
)}
{canManageOrgClaims && <GitConversationRouting />}
</div>
);
}
@@ -2,5 +2,8 @@ export type ConversationStatus =
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED"
| "AWAITING_USER_INPUT"
| "FINISHED"
| "ARCHIVED"
| "ERROR";
-2
View File
@@ -22,5 +22,3 @@ export const ENABLE_PROJ_USER_JOURNEY = () =>
loadFeatureFlag("PROJ_USER_JOURNEY");
export const ENABLE_SANDBOX_GROUPING = () =>
loadFeatureFlag("SANDBOX_GROUPING");
export const ENABLE_ORG_CLAIMS_RESOLVER_ROUTING = () =>
loadFeatureFlag("ORG_CLAIMS_RESOLVER_ROUTING");
@@ -65,12 +65,12 @@ from openhands.app_server.services.httpx_client_injector import (
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.app_server.utils.docker_utils import (
replace_localhost_hostname_for_docker,
)
from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.dependencies import get_dependencies
# Handle anext compatibility for Python < 3.10
if sys.version_info >= (3, 10):
@@ -644,7 +644,7 @@ async def get_conversation_skills(
skill_type = 'knowledge'
# Extract triggers
triggers = []
triggers: list[str] = []
if isinstance(skill.trigger, (KeywordTrigger, TaskTrigger)):
if hasattr(skill.trigger, 'keywords'):
triggers = skill.trigger.keywords
@@ -5,7 +5,7 @@ import tempfile
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, AsyncGenerator
from typing import TYPE_CHECKING, Any, AsyncGenerator
from uuid import UUID
if TYPE_CHECKING:
@@ -412,14 +412,18 @@ class AppConversationServiceBase(AppConversationService, ABC):
project_dir: Project root directory (repo root when a repo is selected).
"""
command = 'mkdir -p .git/hooks && chmod +x .openhands/pre-commit.sh'
result = await workspace.execute_command(command, project_dir)
if result.exit_code:
pre_commit_command_result = await workspace.execute_command(
command, project_dir
)
if pre_commit_command_result.exit_code:
return
# Check if there's an existing pre-commit hook
with tempfile.TemporaryFile(mode='w+t') as temp_file:
result = await workspace.file_download(PRE_COMMIT_HOOK, str(temp_file))
if result.success:
download_result = await workspace.file_download(
PRE_COMMIT_HOOK, str(temp_file)
)
if download_result.success:
_logger.info('Preserving existing pre-commit hook')
# an existing pre-commit hook exists
if 'This hook was installed by OpenHands' not in temp_file.read():
@@ -428,10 +432,12 @@ class AppConversationServiceBase(AppConversationService, ABC):
f'mv {PRE_COMMIT_HOOK} {PRE_COMMIT_LOCAL} &&'
f'chmod +x {PRE_COMMIT_LOCAL}'
)
result = await workspace.execute_command(command, project_dir)
if result.exit_code != 0:
mv_chmod_result = await workspace.execute_command(
command, project_dir
)
if mv_chmod_result.exit_code != 0:
_logger.error(
f'Failed to preserve existing pre-commit hook: {result.stderr}',
f'Failed to preserve existing pre-commit hook: {mv_chmod_result.stderr}',
)
return
@@ -442,9 +448,11 @@ class AppConversationServiceBase(AppConversationService, ABC):
)
# Make the pre-commit hook executable
result = await workspace.execute_command(f'chmod +x {PRE_COMMIT_HOOK}')
if result.exit_code:
_logger.error(f'Failed to make pre-commit hook executable: {result.stderr}')
chmod_result = await workspace.execute_command(f'chmod +x {PRE_COMMIT_HOOK}')
if chmod_result.exit_code:
_logger.error(
f'Failed to make pre-commit hook executable: {chmod_result.stderr}'
)
return
_logger.info('Git pre-commit hook installed successfully')
@@ -466,7 +474,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
Configured LLMSummarizingCondenser instance
"""
# LLMSummarizingCondenser SDK defaults: max_size=240, keep_first=2
condenser_kwargs = {
condenser_kwargs: dict[str, Any] = {
'llm': llm.model_copy(
update={
'usage_id': (
@@ -92,6 +92,7 @@ from openhands.sdk.llm import LLM
from openhands.sdk.plugin import PluginSource
from openhands.sdk.secret import LookupSecret, SecretValue, StaticSecret
from openhands.sdk.utils.paging import page_iterator
from openhands.sdk.utils.redact import sanitize_dict
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
@@ -323,10 +324,15 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
f'Sending StartConversationRequest with hook_config: '
f'{hook_config_in_request}'
)
headers = (
{'X-Session-API-Key': sandbox.session_api_key}
if sandbox.session_api_key
else {}
)
response = await self.httpx_client.post(
f'{agent_server_url}/api/conversations',
json=body_json,
headers={'X-Session-API-Key': sandbox.session_api_key},
headers=headers,
timeout=self.sandbox_startup_timeout,
)
@@ -874,7 +880,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
static_token = await self.user_context.get_latest_token(provider_type)
if static_token:
secrets[secret_name] = StaticSecret(
value=static_token, description=description
value=SecretStr(static_token), description=description
)
return secrets
@@ -889,7 +895,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
Returns:
Configured LLM instance
"""
model = llm_model or user.llm_model
model: str = llm_model or user.llm_model or LLM.model_fields['model'].default
base_url = user.llm_base_url
if model and (
model.startswith('openhands/') or model.startswith('litellm_proxy/')
@@ -1108,7 +1114,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Wrap in the mcpServers structure required by the SDK
mcp_config = {'mcpServers': mcp_servers} if mcp_servers else {}
_logger.info(f'Final MCP configuration: {mcp_config}')
_logger.info(f'Final MCP configuration: {sanitize_dict(mcp_config)}')
return llm, mcp_config
@@ -1154,7 +1160,6 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
system_prompt_filename='system_prompt_planning.j2',
system_prompt_kwargs={'plan_structure': format_plan_structure()},
condenser=condenser,
security_analyzer=None,
mcp_config=mcp_config,
)
else:
@@ -368,7 +368,7 @@ def _convert_skill_info_to_skill(skill_info: SkillInfo) -> Skill:
Returns:
Skill object
"""
trigger = None
trigger: TaskTrigger | KeywordTrigger | None = None
if skill_info.triggers:
# Determine trigger type based on content
+6 -3
View File
@@ -220,9 +220,12 @@ def config_from_env() -> AppServerConfig:
config.event = AwsEventServiceInjector(bucket_name=bucket_name)
elif provider == StorageProvider.GCP:
# Google Cloud storage configuration
config.event = GoogleCloudEventServiceInjector(
bucket_name=os.environ.get('FILE_STORE_PATH')
)
bucket_name = os.environ.get('FILE_STORE_PATH')
if not bucket_name:
raise ValueError(
'FILE_STORE_PATH environment variable is required for Google Cloud storage'
)
config.event = GoogleCloudEventServiceInjector(bucket_name=bucket_name)
else:
config.event = FilesystemEventServiceInjector()
@@ -0,0 +1,32 @@
"""Config-related models for OpenHands App Server V1 API."""
from pydantic import BaseModel, Field
class LLMModel(BaseModel):
"""LLM Model object for API responses.
Attributes:
name: The model name.
verified: Whether the model is verified by OpenHands.
"""
provider: str | None = Field(
default=None, description='The name of the provider for this model'
)
name: str = Field(description='The name of this model')
verified: bool = Field(
default=False, description='Whether the model is verified by OpenHands'
)
class LLMModelPage(BaseModel):
"""Paginated response for LLM models.
Attributes:
items: List of LLM models in the current page.
next_page_id: ID for the next page, or None if there are no more pages.
"""
items: list[LLMModel]
next_page_id: str | None = None
@@ -0,0 +1,94 @@
"""Config router for OpenHands App Server V1 API.
This module provides V1 API endpoints for configuration, including model search
with pagination support.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from openhands.app_server.config_api.config_models import LLMModel, LLMModelPage
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.app_server.utils.paging_utils import (
paginate_results,
)
from openhands.sdk.llm.utils.verified_models import VERIFIED_MODELS
from openhands.server.routes.public import get_llm_models_dependency
from openhands.utils.llm import ModelsResponse
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
# is protected. The actual protection is provided by SetAuthCookieMiddleware
router = APIRouter(
prefix='/config',
tags=['Config'],
dependencies=get_dependencies(),
)
@router.get('/models/search')
async def search_models(
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, le=100),
] = 50,
query: Annotated[
str | None,
Query(title='Filter models by name (case-insensitive substring match)'),
] = None,
verified__eq: Annotated[
bool | None,
Query(title='Filter by verified status (true/false, omit for all)'),
] = None,
models: ModelsResponse = Depends(get_llm_models_dependency),
) -> LLMModelPage:
"""Search for LLM models with pagination and filtering.
Returns a paginated list of models that can be filtered by name
(contains) and verified status.
"""
filtered_models = _get_all_models_with_verified(models)
if query is not None:
query_lower = query.lower()
filtered_models = [m for m in filtered_models if query_lower in m.name.lower()]
if verified__eq is not None:
filtered_models = [m for m in filtered_models if m.verified == verified__eq]
# Apply pagination
items, next_page_id = paginate_results(filtered_models, page_id, limit)
return LLMModelPage(items=items, next_page_id=next_page_id)
def _get_verified_models() -> set[str]:
verified_models = set()
for provider, models in VERIFIED_MODELS.items():
for name in models:
verified_models.add(f'{provider}/{name}')
return verified_models
def _get_all_models_with_verified(models: ModelsResponse) -> list[LLMModel]:
verified_models = _get_verified_models()
results = []
for model_name in models.models:
verified = model_name in verified_models
parts = model_name.split('/', 1)
if len(parts) == 2:
provider, name = parts
else:
provider = None
name = parts[0]
result = LLMModel(
provider=provider,
name=name,
verified=verified,
)
results.append(result)
return results
+1 -1
View File
@@ -10,8 +10,8 @@ from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.config import depends_event_service
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.sdk import Event
from openhands.server.dependencies import get_dependencies
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
# is protected. The actual protection is provided by SetAuthCookieMiddleware
@@ -68,7 +68,7 @@ class EventServiceBase(EventService, ABC):
conversation_path = await self.get_conversation_path(conversation_id)
path = conversation_path / f'{event_id.hex}.json'
loop = asyncio.get_running_loop()
event: Event = await loop.run_in_executor(None, self._load_event, path)
event: Event = await loop.run_in_executor(None, self._load_event, path) # type: ignore[arg-type]
return event
async def search_events(
@@ -85,8 +85,10 @@ class EventServiceBase(EventService, ABC):
loop = asyncio.get_running_loop()
prefix = await self.get_conversation_path(conversation_id)
paths = await loop.run_in_executor(None, self._search_paths, prefix)
# Type error: run_in_executor expects a return value, but self._load_event is typed return Event | None.
events = await asyncio.gather(
*[loop.run_in_executor(None, self._load_event, path) for path in paths]
*[loop.run_in_executor(None, self._load_event, path) for path in paths] # type: ignore[arg-type]
)
items = []
for event in events:
@@ -94,9 +96,10 @@ class EventServiceBase(EventService, ABC):
continue
if kind__eq and event.kind != kind__eq:
continue
if timestamp__gte and event.timestamp < timestamp__gte:
# TODO: Are these comparison operators valid?
if timestamp__gte and event.timestamp < timestamp__gte: # type: ignore[operator]
continue
if timestamp__lt and event.timestamp >= timestamp__lt:
if timestamp__lt and event.timestamp >= timestamp__lt: # type: ignore[operator]
continue
items.append(event)
@@ -154,7 +157,7 @@ class EventServiceBase(EventService, ABC):
if isinstance(event.id, str):
id_hex = event.id.replace('-', '')
else:
id_hex = event.id.hex
id_hex = event.id.hex # type: ignore[unreachable]
path = (await self.get_conversation_path(conversation_id)) / f'{id_hex}.json'
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._store_event, path, event)
@@ -23,8 +23,8 @@ class FilesystemEventService(EventServiceBase):
def _load_event(self, path: Path) -> Event | None:
try:
content = path.read_text()
content = Event.model_validate_json(content)
return content
content = Event.model_validate_json(content) # type: ignore[assignment]
return content # type: ignore[return-value]
except Exception:
if path.exists():
_logger.exception('Error reading event', stack_info=True)
@@ -22,6 +22,9 @@ from openhands.sdk.utils.models import (
get_known_concrete_subclasses,
)
# TODO(OpenHands/evaluation#418): import from openhands.sdk.utils.redact
from openhands.utils._redact_compat import redact_text_secrets
_logger = logging.getLogger(__name__)
if TYPE_CHECKING:
EventKind = str
@@ -56,7 +59,11 @@ class LoggingCallbackProcessor(EventCallbackProcessor):
callback: EventCallback,
event: Event,
) -> EventCallbackResult:
_logger.info(f'Callback {callback.id} Invoked for event {event}')
_logger.info(
'Callback %s Invoked for event %s',
callback.id,
redact_text_secrets(str(event)),
)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,

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