mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d01c74e985 | |||
| 371b226bcd | |||
| 754a96e7f3 | |||
| 211b73a088 | |||
| 54041dd093 | |||
| f271346724 | |||
| d6a0dd7fe4 | |||
| e46bcfa82f | |||
| 2eefa5edfd | |||
| 54858c0fc0 | |||
| 384c324652 | |||
| 4e68f57807 | |||
| 649ebc4078 | |||
| e3246c27d4 | |||
| 72194f19db | |||
| 0c5e30ab33 | |||
| b8f2932b02 | |||
| 62673c028a | |||
| 7af2285fe6 | |||
| 69d281c6be | |||
| 8ce3089a68 | |||
| b9b10ebf5e | |||
| ce6d5b77c4 | |||
| a458c9b785 | |||
| a65ddc3db6 | |||
| 732a1c1991 | |||
| d058323a87 | |||
| 7d04cffe4e | |||
| 6ad27b77bb | |||
| 2739fc8fbe | |||
| 38b7e10252 | |||
| 7b7d1c0c55 | |||
| e38eda4ac9 | |||
| 99c19b6ef0 | |||
| 0731e8c68a |
+3
-4
@@ -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
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
[](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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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}')
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Generated
+12
-12
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'] == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
+47
-7
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
+138
-302
@@ -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:
|
||||
|
||||
Generated
+7
-9
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+6
-7
@@ -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
|
||||
}
|
||||
|
||||
+27
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-23
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-20
@@ -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>
|
||||
);
|
||||
|
||||
+26
@@ -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} />
|
||||
|
||||
+26
-13
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
+65
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user