Compare commits

..

2 Commits

Author SHA1 Message Date
openhands d895197b36 docs: address review comments on bi-directional event loading
- Add comment explaining cursor parameter is exposed for future pagination
- Document 100 event limit and add TODO for cursor-based pagination (#12705)
- Improve comment clarity on oldest timestamp extraction (DESC ordering)
- Document why there's no race condition (server timestamps + >= comparison)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-04 21:28:06 +00:00
openhands 42109ac692 feat(frontend): implement bi-directional event loading for V1 conversations
This PR completes the frontend integration for bi-directional event loading, building on the agent-server work in software-agent-sdk#1880.

Closes #12705
Depends on software-agent-sdk#1880 (already merged)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-04 21:02:01 +00:00
219 changed files with 10430 additions and 5392 deletions
+7
View File
@@ -0,0 +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/ @hieptl
/openhands-ui/ @hieptl
/openhands/ @tofarr @malhotra5 @hieptl
/enterprise/ @chuckbutkus @tofarr @malhotra5 @jlav @aivong-openhands
+27 -35
View File
@@ -1,46 +1,38 @@
<!-- Keep this PR as draft until it is ready for review. -->
<!-- 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 -->
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
## Summary of PR
- [ ] A human has tested these changes.
<!-- Summarize what the PR does -->
---
## Demo Screenshots/Videos
## Why
<!-- 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. -->
<!-- Describe problem, motivation, etc.-->
## Change Type
## 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
<!-- Choose the types that apply to your PR -->
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] New feature
- [ ] Breaking change
- [ ] Docs / chore
- [ ] Refactor
- [ ] Other (dependency update, docs, typo fixes, etc.)
## Notes
## 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. -->
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->
- [ ] 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.
+2 -4
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
fe-e2e-test:
name: FE E2E Tests
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
@@ -26,11 +26,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
+2 -4
View File
@@ -21,7 +21,7 @@ jobs:
# Run frontend unit tests
fe-test:
name: FE Unit Tests
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
@@ -30,11 +30,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
+10 -10
View File
@@ -30,7 +30,7 @@ env:
jobs:
define-matrix:
runs-on: ubuntu-latest
runs-on: blacksmith
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: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
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: ubuntu-22.04
runs-on: blacksmith-8vcpu-ubuntu-2204
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: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
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: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
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: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
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: ubuntu-22.04
runs-on: blacksmith-8vcpu-ubuntu-2204
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: docker/build-push-action@v6
uses: useblacksmith/build-push-action@v1
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: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
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: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v6
+7 -8
View File
@@ -9,7 +9,7 @@ jobs:
lint-fix-frontend:
if: github.event.label.name == 'lint-fix'
name: Fix frontend linting issues
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: write
pull-requests: write
@@ -22,14 +22,13 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 22
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
run: |
cd frontend
npm install --frozen-lockfile
- name: Generate i18n and route types
run: |
cd frontend
@@ -59,7 +58,7 @@ jobs:
lint-fix-python:
if: github.event.label.name == 'lint-fix'
name: Fix Python linting issues
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: write
pull-requests: write
@@ -72,7 +71,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
+10 -11
View File
@@ -19,35 +19,34 @@ jobs:
# Run lint on the frontend code
lint-frontend:
name: Lint frontend
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- name: Install Node.js 22
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
run: |
cd frontend
npm install --frozen-lockfile
- name: Lint, TypeScript compilation, and translation checks
run: |
cd frontend
npm run lint
npm run make-i18n && npx tsc
npm run make-i18n && tsc
npm run check-translation-completeness
# Run lint on the python code
lint-python:
name: Lint python
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
@@ -58,13 +57,13 @@ jobs:
lint-enterprise-python:
name: Lint enterprise python
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
+2 -2
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
check-version:
name: Check if version has changed
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
defaults:
run:
shell: bash
@@ -55,7 +55,7 @@ jobs:
publish:
name: Publish to npm
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
+5 -7
View File
@@ -19,7 +19,7 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
@@ -37,15 +37,13 @@ jobs:
- name: Install tmux
run: sudo apt-get update && sudo apt-get install -y tmux
- name: Setup Node.js
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
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: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -75,7 +73,7 @@ jobs:
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
matrix:
python-version: ["3.12"]
@@ -84,7 +82,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
+2 -2
View File
@@ -17,14 +17,14 @@ on:
jobs:
release:
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
# 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: actions/setup-python@v5
- uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
- name: Install Poetry
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
stale:
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v10
+1 -1
View File
@@ -19,7 +19,7 @@ concurrency:
jobs:
ui-build:
name: Build openhands-ui
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v6
+1 -3
View File
@@ -20,11 +20,9 @@ ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# Pin Poetry version to match the version used to generate poetry.lock
ARG POETRY_VERSION=2.3.3
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential jq gettext \
&& python3 -m pip install "poetry==${POETRY_VERSION}" --break-system-packages
&& python3 -m pip install poetry --break-system-packages
COPY pyproject.toml poetry.lock ./
RUN touch README.md
@@ -58,8 +58,6 @@ repos:
types-Markdown,
pydantic,
lxml,
"openhands-sdk==1.14",
"openhands-tools==1.14",
]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/
-8
View File
@@ -14,11 +14,3 @@ 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
@@ -1,7 +1,6 @@
from types import MappingProxyType
from github import Auth, Github, GithubIntegration
from lmnr import Laminar
from integrations.github.data_collector import GitHubDataCollector
from integrations.github.github_solvability import summarize_issue_solvability
from integrations.github.github_view import (
@@ -23,8 +22,6 @@ from integrations.utils import (
CONVERSATION_URL,
ENABLE_SOLVABILITY_ANALYSIS,
HOST_URL,
LAMINAR_ENABLED,
LAMINAR_PROJECT_API_KEY,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_session_expired_message,
get_user_not_found_message,
@@ -332,16 +329,6 @@ class GithubManager(Manager[GithubViewType]):
GithubCallbackProcessor,
)
# Initialize Laminar if enabled
laminar_span_context = None
if LAMINAR_ENABLED:
try:
Laminar.initialize(project_api_key=LAMINAR_PROJECT_API_KEY)
laminar_span_context = Laminar.get_laminar_span_context()
logger.info('[Github] Laminar initialized for observability')
except Exception as e:
logger.warning(f'[Github] Failed to initialize Laminar: {e}')
try:
msg_info: str = ''
@@ -402,42 +389,12 @@ class GithubManager(Manager[GithubViewType]):
github_view.user_info.keycloak_user_id, self.token_manager
)
# Set up Laminar tracing if enabled
if LAMINAR_ENABLED and laminar_span_context:
try:
with Laminar.start_as_current_span(
name='github-resolver',
parent_span_context=laminar_span_context,
):
Laminar.set_trace_metadata({
'source': 'github',
'repo': github_view.full_repo_name,
'issue_number': str(github_view.issue_number),
'username': user_info.username,
'conversation_id': github_view.conversation_id,
})
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
except Exception as e:
logger.warning(f'[Github] Laminar span error: {e}')
# Fall back to non-Laminar execution
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
else:
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
conversation_id = github_view.conversation_id
@@ -501,10 +458,3 @@ class GithubManager(Manager[GithubViewType]):
await self.data_collector.save_data(github_view)
except Exception:
logger.warning('[Github]: Error saving interaction data', exc_info=True)
# Flush Laminar traces if enabled
if LAMINAR_ENABLED:
try:
Laminar.flush()
except Exception as e:
logger.warning(f'[Github] Error flushing Laminar traces: {e}')
+9 -68
View File
@@ -7,7 +7,6 @@ Views are responsible for:
"""
from dataclasses import dataclass, field
from uuid import uuid4
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
@@ -16,25 +15,18 @@ 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 start_conversation
from openhands.server.services.conversation_service import create_new_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
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
@@ -174,68 +166,20 @@ class JiraNewConversationView(JiraViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
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,
)
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,
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,
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,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
conversation_trigger=ConversationTrigger.JIRA,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
self.conversation_id = conversation_id
self.conversation_id = agent_loop_info.conversation_id
logger.info(
'[Jira] Created conversation',
@@ -243,9 +187,6 @@ 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,
},
)
+9 -68
View File
@@ -1,34 +1,25 @@
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 (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
integration_store = LinearIntegrationStore.get_instance()
@@ -70,70 +61,20 @@ class LinearNewConversationView(LinearViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
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,
)
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,
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,
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,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
conversation_trigger=ConversationTrigger.LINEAR,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
self.conversation_id = conversation_id
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Linear] Created conversation {self.conversation_id}')
+1 -4
View File
@@ -3,7 +3,7 @@ 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, UserGitInfo
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -85,6 +85,3 @@ 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()
+35 -82
View File
@@ -239,14 +239,12 @@ 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 with immediate "No Repository" button and search dropdown.
"""Generate a repo selection form using external_select for dynamic loading.
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.
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)
Args:
message_ts: The message timestamp for tracking
@@ -268,22 +266,12 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': 'Select a repository or continue without one:',
'text': 'Type to search your repositories:',
},
},
{
'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}',
@@ -291,8 +279,8 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'plain_text',
'text': 'Search repositories...',
},
'min_query_length': 0,
},
'min_query_length': 0, # Load initial options immediately
}
],
},
]
@@ -300,11 +288,8 @@ class SlackManager(Manager[SlackViewInterface]):
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
"""Build Slack options list from repositories.
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.
Always includes a "No Repository" option at the top, followed by up to 99
repositories (Slack has a 100 option limit for external_select).
Args:
repos: List of Repository objects
@@ -312,7 +297,13 @@ class SlackManager(Manager[SlackViewInterface]):
Returns:
List of Slack option objects
"""
return [
options: list[dict[str, Any]] = [
{
'text': {'type': 'plain_text', 'text': 'No Repository'},
'value': '-',
}
]
options.extend(
{
'text': {
'type': 'plain_text',
@@ -320,8 +311,9 @@ class SlackManager(Manager[SlackViewInterface]):
},
'value': repo.full_name,
}
for repo in repos[:100]
]
for repo in repos[:99] # Leave room for "No Repository" option
)
return options
async def search_repos_for_slack(
self, user_auth: UserAuth, query: str, per_page: int = 20
@@ -371,69 +363,33 @@ class SlackManager(Manager[SlackViewInterface]):
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
)
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 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).
"""Process a Slack form interaction (repository selection).
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
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.
Args:
slack_payload: The raw Slack interaction payload
"""
# Extract fields from the Slack interaction payload
action = slack_payload['actions'][0]
selected_repository = slack_payload['actions'][0]['selected_option']['value']
if selected_repository == '-':
selected_repository = None
slack_user_id = slack_payload['user']['id']
channel_id = slack_payload['container']['channel_id']
team_id = slack_payload['team']['id']
# 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
# 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
message_ts, thread_ts, selected_value = parsed
# Build partial payload for error handling
# Build partial payload for error handling during Redis retrieval
payload = {
'team_id': team_id,
'channel_id': channel_id,
@@ -442,9 +398,6 @@ 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
View File
@@ -98,10 +98,6 @@ ENABLE_V1_SLACK_RESOLVER = (
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
)
# Laminar observability settings
LAMINAR_ENABLED = os.environ.get('LMNR_PROJECT_API_KEY', '') != ''
LAMINAR_PROJECT_API_KEY = os.environ.get('LMNR_PROJECT_API_KEY', '')
# Toggle for V1 GitLab resolver feature
ENABLE_V1_GITLAB_RESOLVER = (
os.getenv('ENABLE_V1_GITLAB_RESOLVER', 'false').lower() == 'true'
+21 -21
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "agent-client-protocol"
@@ -549,7 +549,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13.0\""
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.15.1", markers = "python_version ~= \"3.12.0\""},
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
{version = ">=0.15.1", markers = "python_version ~= \"3.12.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.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
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 = ">=1.32.0,<4.0.0", markers = "python_version < \"3.13\""},
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.32.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.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
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.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
protobuf = ">=4.25.8,<8.0.0"
@@ -3585,7 +3585,7 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.5.5"
grpcio = ">=1.67.1"
protobuf = ">=5.26.1,<6.0.dev0"
protobuf = ">=5.26.1,<6.0dev"
[[package]]
name = "gspread"
@@ -3906,7 +3906,7 @@ pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
@@ -4348,7 +4348,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
jsonschema-specifications = ">=2023.3.6"
jsonschema-specifications = ">=2023.03.6"
referencing = ">=0.28.4"
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
@@ -4756,7 +4756,7 @@ files = [
]
[package.dependencies]
certifi = ">=14.5.14"
certifi = ">=14.05.14"
durationpy = ">=0.7"
python-dateutil = ">=2.5.3"
pyyaml = ">=5.4.1"
@@ -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\""
markers = "python_version >= \"3.13.0\""
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"},
@@ -6533,7 +6533,7 @@ pexpect = "*"
pg8000 = ">=1.31.5"
pillow = ">=12.1.1"
playwright = ">=1.55"
poetry = ">=2.3.3"
poetry = ">=2.1.2"
prompt-toolkit = ">=3.0.50"
protobuf = ">=5.29.6,<6"
psutil = "*"
@@ -6554,7 +6554,7 @@ pyyaml = ">=6.0.2"
qtconsole = ">=5.6.1"
rapidfuzz = ">=3.9"
redis = ">=5.2,<7"
requests = ">=2.33"
requests = ">=2.33.0"
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.63.2,<2.0.0", markers = "python_version < \"3.13\""},
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
]
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.39.1"
@@ -7140,7 +7140,7 @@ files = [
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
@@ -13111,10 +13111,10 @@ files = [
]
[package.dependencies]
botocore = ">=1.37.4,<2.0a0"
botocore = ">=1.37.4,<2.0a.0"
[package.extras]
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
[[package]]
name = "scantree"
@@ -13730,7 +13730,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13.0\""
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\""
markers = "python_version >= \"3.13.0\""
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"},
@@ -15258,7 +15258,7 @@ files = [
]
[package.extras]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
[metadata]
lock-version = "2.1"
-7
View File
@@ -49,9 +49,6 @@ from server.routes.readiness import readiness_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.routes.users_v1 import ( # noqa: E402
override_users_me_endpoint,
)
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
@@ -126,10 +123,6 @@ base_app.include_router(
# This must happen after all routers are included
override_llm_models_dependency(base_app)
# Override the /api/v1/users/me endpoint to include organization info
# This replaces the OSS endpoint with a SAAS version that adds org_id, org_name, role, permissions
override_users_me_endpoint(base_app)
base_app.include_router(invitation_router) # Add routes for org invitation management
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
add_github_proxy_routes(base_app)
-78
View File
@@ -14,10 +14,6 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.authorization import (
get_role_permissions,
get_user_org_role,
)
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
from server.auth.token_manager import TokenManager
from server.config import get_config
@@ -27,12 +23,10 @@ from sqlalchemy import delete, select
from storage.api_key_store import ApiKeyStore
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
from storage.org_store import OrgStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from storage.user_authorization import UserAuthorizationType
from storage.user_authorization_store import UserAuthorizationStore
from storage.user_store import UserStore
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
from openhands.integrations.provider import (
@@ -70,12 +64,6 @@ class SaasUserAuth(UserAuth):
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
# Organization context fields - populated lazily via get_org_info()
_org_id: str | None = None
_org_name: str | None = None
_role: str | None = None
_permissions: list[str] | None = None
_org_info_loaded: bool = False
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
@@ -254,72 +242,6 @@ class SaasUserAuth(UserAuth):
)
return mcp_api_key
async def get_org_info(self) -> dict | None:
"""Get organization info for the current user.
Lazily loads and caches organization data including:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission names for the role
Returns:
dict with org_id, org_name, role, permissions or None if not available
"""
if self._org_info_loaded:
if self._org_id is None:
return None
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
# Mark as loaded to avoid repeated attempts on failure
self._org_info_loaded = True
try:
# Get user and their current org
user = await UserStore.get_user_by_id(self.user_id)
if not user:
logger.warning(f'User {self.user_id} not found for org info')
return None
# Get the current org
org = await OrgStore.get_org_by_id(user.current_org_id)
if not org:
logger.warning(
f'Organization {user.current_org_id} not found for user {self.user_id}'
)
return None
# Get user's role in the current org
role = await get_user_org_role(self.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]
# Cache the results
self._org_id = str(user.current_org_id)
self._org_name = org.name
self._role = role_name
self._permissions = permissions
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
except Exception as e:
logger.error(f'Error fetching org info for user {self.user_id}: {e}')
return None
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
logger.debug('saas_user_auth_get_instance')
-1
View File
@@ -1 +0,0 @@
# Enterprise server models
-16
View File
@@ -1,16 +0,0 @@
"""SAAS-specific user models that extend OSS UserInfo with organization fields."""
from openhands.app_server.user.user_models import UserInfo
class SaasUserInfo(UserInfo):
"""User info model for SAAS mode with organization context.
Extends the base UserInfo with SAAS-specific fields for organization
membership, role, and permissions.
"""
org_id: str | None = None
org_name: str | None = None
role: str | None = None
permissions: list[str] | None = None
@@ -335,9 +335,6 @@ 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
"""
+3 -14
View File
@@ -45,12 +45,7 @@ saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies()
token_manager = TokenManager()
@saas_user_router.get(
'/installations',
response_model=list[str],
deprecated=True,
description='Deprecated: Use `/api/v1/git/installations` instead.',
)
@saas_user_router.get('/installations', response_model=list[str])
async def saas_get_user_installations(
provider: ProviderType,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
@@ -120,12 +115,7 @@ async def saas_get_user_git_organizations(
}
@saas_user_router.get(
'/repositories',
response_model=list[Repository],
deprecated=True,
description='Deprecated: Use `/api/v1/git/repositories` instead.',
)
@saas_user_router.get('/repositories', response_model=list[Repository])
async def saas_get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
@@ -156,13 +146,12 @@ async def saas_get_user_repositories(
)
@saas_user_router.get('/info', response_model=User, deprecated=True)
@saas_user_router.get('/info', response_model=User)
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(
-106
View File
@@ -1,106 +0,0 @@
"""SAAS-specific extensions for the /api/v1/users endpoints.
This module provides SAAS-specific implementations that extend the OSS
user endpoints with organization context (org_id, org_name, role, permissions).
"""
import logging
from fastapi import APIRouter, FastAPI, Header, HTTPException, Query, status
from fastapi.responses import JSONResponse
from server.auth.saas_user_auth import SaasUserAuth
from server.models.user_models import SaasUserInfo
from openhands.app_server.config import depends_user_context
from openhands.app_server.sandbox.session_auth import validate_session_key_ownership
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.dependencies import get_dependencies
_logger = logging.getLogger(__name__)
saas_users_v1_router = APIRouter(
prefix='/api/v1/users', tags=['User'], dependencies=get_dependencies()
)
user_dependency = depends_user_context()
@saas_users_v1_router.get('/me')
async def get_current_user_saas(
user_context: UserContext = user_dependency,
expose_secrets: bool = Query(
default=False,
description='If true, return unmasked secret values (e.g. llm_api_key). '
'Requires a valid X-Session-API-Key header for an active sandbox '
'owned by the authenticated user.',
),
x_session_api_key: str | None = Header(default=None),
) -> SaasUserInfo:
"""Get the current authenticated user with SAAS-specific org info.
Returns user settings along with organization context:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission strings for the role
"""
# Get base user info from the context
base_user_info = await user_context.get_user_info()
if base_user_info is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
# Build SAAS user info from base settings
user_info_data = base_user_info.model_dump(
mode='json', context={'expose_secrets': True}
)
# Add org info if available (from SaasUserAuth)
org_info = await _get_org_info_from_context(user_context)
if org_info:
user_info_data.update(org_info)
user_info = SaasUserInfo(**user_info_data)
if expose_secrets:
await validate_session_key_ownership(user_context, x_session_api_key)
return JSONResponse( # type: ignore[return-value]
content=user_info.model_dump(mode='json', context={'expose_secrets': True})
)
return user_info
async def _get_org_info_from_context(user_context: UserContext) -> dict | None:
"""Extract org info from the user context if available.
This works by checking if the underlying user_auth is a SaasUserAuth
instance that has the get_org_info method.
"""
# Check if this is an AuthUserContext with a SaasUserAuth
if isinstance(user_context, AuthUserContext):
user_auth = user_context.user_auth
if isinstance(user_auth, SaasUserAuth):
return await user_auth.get_org_info()
return None
def override_users_me_endpoint(app: FastAPI) -> None:
"""Override the OSS /api/v1/users/me endpoint with SAAS version.
This removes the base OSS endpoint and registers the SAAS version
which includes organization context (org_id, org_name, role, permissions).
Must be called after the app is created in saas_server.py.
"""
# Find and remove the OSS /api/v1/users/me route
routes_to_remove = []
for route in app.routes:
if hasattr(route, 'path') and route.path == '/api/v1/users/me':
routes_to_remove.append(route)
for route in routes_to_remove:
app.routes.remove(route)
_logger.debug('Removed OSS route: %s', route.path)
# Add the SAAS version
app.include_router(saas_users_v1_router)
_logger.debug('Added SAAS /api/v1/users/me endpoint')
+1 -7
View File
@@ -182,13 +182,7 @@ class SaasSettingsStore(SettingsStore):
return None
# Check if we need to generate an LLM key.
# 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)
):
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
)
@@ -3,7 +3,6 @@ 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 (
@@ -19,9 +18,6 @@ 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"""
@@ -90,49 +86,29 @@ class TestJiraNewConversationView:
assert 'Test Issue' in user_msg
@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.create_new_conversation')
@patch('integrations.jira.jira_view.integration_store')
async def test_create_or_update_conversation_success(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
mock_store,
mock_create_conversation,
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_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_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
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()
assert result == 'conv-123'
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
@pytest.mark.asyncio
async def test_create_or_update_conversation_no_repo(
@@ -372,125 +348,6 @@ 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,7 +73,6 @@ 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,33 +29,27 @@ class TestLinearNewConversationView:
assert 'Test Issue' in user_msg
assert 'Fix this bug @openhands' in user_msg
@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.create_new_conversation')
@patch('integrations.linear.linear_view.integration_store')
async def test_create_or_update_conversation_success(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_store,
mock_create_conversation,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test successful conversation creation"""
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result is not None
mock_start_convo.assert_called_once()
mock_integration_store.create_conversation.assert_called_once()
assert result == 'conv-123'
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
async def test_create_or_update_conversation_no_repo(
self, new_conversation_view, mock_jinja_env
@@ -66,23 +60,12 @@ 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.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.create_new_conversation')
async def test_create_or_update_conversation_failure(
self,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
self, mock_create_conversation, new_conversation_view, mock_jinja_env
):
"""Test conversation creation failure"""
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.side_effect = Exception('Creation failed')
mock_create_conversation.side_effect = Exception('Creation failed')
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
@@ -317,57 +300,43 @@ class TestLinearFactory:
class TestLinearViewEdgeCases:
"""Tests for edge cases and error scenarios"""
@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.create_new_conversation')
@patch('integrations.linear.linear_view.integration_store')
async def test_conversation_creation_with_no_user_secrets(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_store,
mock_create_conversation,
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 = 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()
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()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result is not None
# Verify start_conversation was called with custom_secrets=None
call_kwargs = mock_start_convo.call_args[1]
assert result == 'conv-123'
# Verify create_new_conversation was called with custom_secrets=None
call_kwargs = mock_create_conversation.call_args[1]
assert call_kwargs['custom_secrets'] is None
@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.create_new_conversation')
@patch('integrations.linear.linear_view.integration_store')
async def test_conversation_creation_store_failure(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_store,
mock_create_conversation,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when store creation fails"""
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')
)
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock(side_effect=Exception('Store error'))
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
@@ -1,351 +0,0 @@
"""Unit tests for SaasUserAuth.get_org_info() using SQLite in-memory database.
These tests exercise the real `get_org_info()` implementation with actual DB queries
to catch regressions in the SAAS org lookup logic.
"""
import uuid
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
from storage.user import User
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
)
return engine
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker bound to the async engine."""
session_maker = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return session_maker
@pytest.fixture
def user_id():
"""Generate a unique user ID for tests."""
return str(uuid.uuid4())
@pytest.fixture
def org_id():
"""Generate a unique org ID for tests."""
return uuid.uuid4()
async def create_role(session_maker, name: str, rank: int) -> Role:
"""Helper to create a role in the test database."""
async with session_maker() as session:
role = Role(name=name, rank=rank)
session.add(role)
await session.commit()
await session.refresh(role)
return role
async def create_org(session_maker, org_id: uuid.UUID, name: str) -> Org:
"""Helper to create an org in the test database."""
async with session_maker() as session:
org = Org(
id=org_id,
name=name,
org_version=1,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
session.add(org)
await session.commit()
await session.refresh(org)
return org
async def create_user(session_maker, user_id: str, current_org_id: uuid.UUID) -> User:
"""Helper to create a user in the test database."""
async with session_maker() as session:
user = User(
id=uuid.UUID(user_id),
current_org_id=current_org_id,
user_consents_to_analytics=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def create_org_member(
session_maker,
org_id: uuid.UUID,
user_id: str,
role_id: int,
status: str = 'active',
llm_api_key: str = 'test-api-key',
) -> OrgMember:
"""Helper to create an org member in the test database."""
async with session_maker() as session:
org_member = OrgMember(
org_id=org_id,
user_id=uuid.UUID(user_id),
role_id=role_id,
status=status,
llm_api_key=llm_api_key,
)
session.add(org_member)
await session.commit()
await session.refresh(org_member)
return org_member
class TestGetOrgInfoWithRealDB:
"""Tests for get_org_info() using in-memory SQLite database."""
@pytest.mark.asyncio
async def test_get_org_info_returns_correct_data_for_owner(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info returns correct data for an owner role."""
# Set up test data
owner_role = await create_role(async_session_maker, 'owner', 1)
await create_org(async_session_maker, org_id, 'Test Organization')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, owner_role.id)
# Create SaasUserAuth instance
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
# Patch the global a_session_maker in all stores that use it
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is not None
assert org_info['org_id'] == str(org_id)
assert org_info['org_name'] == 'Test Organization'
assert org_info['role'] == 'owner'
assert isinstance(org_info['permissions'], list)
# Owner should have many permissions
assert len(org_info['permissions']) > 0
assert 'manage_secrets' in org_info['permissions']
@pytest.mark.asyncio
async def test_get_org_info_returns_correct_data_for_member(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info returns correct data for a member role."""
# Set up test data
member_role = await create_role(async_session_maker, 'member', 3)
await create_org(async_session_maker, org_id, 'Member Org')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, member_role.id)
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is not None
assert org_info['org_id'] == str(org_id)
assert org_info['org_name'] == 'Member Org'
assert org_info['role'] == 'member'
# Member should have limited permissions
assert isinstance(org_info['permissions'], list)
@pytest.mark.asyncio
async def test_get_org_info_returns_correct_data_for_admin(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info returns correct data for an admin role."""
# Set up test data
admin_role = await create_role(async_session_maker, 'admin', 2)
await create_org(async_session_maker, org_id, 'Admin Org')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, admin_role.id)
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is not None
assert org_info['org_id'] == str(org_id)
assert org_info['org_name'] == 'Admin Org'
assert org_info['role'] == 'admin'
assert isinstance(org_info['permissions'], list)
@pytest.mark.asyncio
async def test_get_org_info_returns_none_when_user_not_found(
self, async_session_maker
):
"""Test that get_org_info returns None when user doesn't exist."""
nonexistent_user_id = str(uuid.uuid4())
user_auth = SaasUserAuth(
user_id=nonexistent_user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is None
@pytest.mark.asyncio
async def test_get_org_info_returns_none_when_org_not_found(
self, async_session_maker, user_id
):
"""Test that get_org_info returns None when user's org doesn't exist."""
nonexistent_org_id = uuid.uuid4()
# Create user pointing to nonexistent org
async with async_session_maker() as session:
user = User(
id=uuid.UUID(user_id),
current_org_id=nonexistent_org_id,
user_consents_to_analytics=True,
)
session.add(user)
await session.commit()
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is None
@pytest.mark.asyncio
async def test_get_org_info_caches_result(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info caches the result and doesn't hit DB twice."""
# Set up test data
owner_role = await create_role(async_session_maker, 'owner', 1)
await create_org(async_session_maker, org_id, 'Cached Org')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, owner_role.id)
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
# First call
org_info1 = await user_auth.get_org_info()
assert org_info1 is not None
assert user_auth._org_info_loaded is True
# Second call should return cached result
org_info2 = await user_auth.get_org_info()
assert org_info2 is not None
assert org_info1 == org_info2
@pytest.mark.asyncio
async def test_get_org_info_caches_none_result(self, async_session_maker):
"""Test that get_org_info caches None result for nonexistent user."""
nonexistent_user_id = str(uuid.uuid4())
user_auth = SaasUserAuth(
user_id=nonexistent_user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
# First call
org_info1 = await user_auth.get_org_info()
assert org_info1 is None
assert user_auth._org_info_loaded is True
# Second call should return cached None without hitting DB
org_info2 = await user_auth.get_org_info()
assert org_info2 is None
@pytest.mark.asyncio
async def test_get_org_info_with_unknown_role_returns_empty_permissions(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info returns empty permissions for unknown role."""
# Create a custom role that isn't in the ROLE_PERMISSIONS mapping
custom_role = await create_role(async_session_maker, 'custom_role', 99)
await create_org(async_session_maker, org_id, 'Custom Org')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, custom_role.id)
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is not None
assert org_info['org_id'] == str(org_id)
assert org_info['role'] == 'custom_role'
# Unknown roles should have empty permissions
assert org_info['permissions'] == []
@@ -1,283 +0,0 @@
"""Unit tests for SAAS-specific /api/v1/users endpoints.
Tests:
- SaasUserInfo model with org fields
- get_current_user_saas endpoint with org info
- _get_org_info_from_context helper function
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
class TestSaasUserInfoModel:
"""Test suite for SaasUserInfo model."""
def test_saas_user_info_with_all_org_fields(self):
"""SaasUserInfo should accept all org-related fields."""
from server.models.user_models import SaasUserInfo
user_info = SaasUserInfo(
id='user-123',
org_id='org-456',
org_name='Test Organization',
role='admin',
permissions=['read', 'write', 'delete'],
)
assert user_info.id == 'user-123'
assert user_info.org_id == 'org-456'
assert user_info.org_name == 'Test Organization'
assert user_info.role == 'admin'
assert user_info.permissions == ['read', 'write', 'delete']
def test_saas_user_info_without_org_fields(self):
"""SaasUserInfo should work without org fields (fallback mode)."""
from server.models.user_models import SaasUserInfo
user_info = SaasUserInfo(id='user-123')
assert user_info.id == 'user-123'
assert user_info.org_id is None
assert user_info.org_name is None
assert user_info.role is None
assert user_info.permissions is None
def test_saas_user_info_with_partial_org_fields(self):
"""SaasUserInfo should handle partial org fields (e.g., role is None)."""
from server.models.user_models import SaasUserInfo
user_info = SaasUserInfo(
id='user-123',
org_id='org-456',
org_name='Test Organization',
role=None,
permissions=[],
)
assert user_info.org_id == 'org-456'
assert user_info.org_name == 'Test Organization'
assert user_info.role is None
assert user_info.permissions == []
def test_saas_user_info_model_dump_includes_org_fields(self):
"""SaasUserInfo model_dump should include org fields."""
from server.models.user_models import SaasUserInfo
user_info = SaasUserInfo(
id='user-123',
org_id='org-456',
org_name='Test Organization',
role='member',
permissions=['read'],
)
data = user_info.model_dump()
assert data['org_id'] == 'org-456'
assert data['org_name'] == 'Test Organization'
assert data['role'] == 'member'
assert data['permissions'] == ['read']
def test_saas_user_info_extends_base_user_info(self):
"""SaasUserInfo should inherit from UserInfo base class."""
from server.models.user_models import SaasUserInfo
from openhands.app_server.user.user_models import UserInfo
assert issubclass(SaasUserInfo, UserInfo)
class TestGetOrgInfoFromContext:
"""Test suite for _get_org_info_from_context helper function."""
@pytest.mark.asyncio
async def test_returns_org_info_from_saas_user_auth(self):
"""Should return org info when context has SaasUserAuth."""
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.users_v1 import _get_org_info_from_context
from openhands.app_server.user.auth_user_context import AuthUserContext
# Create a SaasUserAuth with mocked get_org_info
mock_user_auth = MagicMock(spec=SaasUserAuth)
mock_user_auth.get_org_info = AsyncMock(
return_value={
'org_id': 'org-456',
'org_name': 'Test Organization',
'role': 'admin',
'permissions': ['read', 'write'],
}
)
# Create AuthUserContext with the mock
context = MagicMock(spec=AuthUserContext)
context.user_auth = mock_user_auth
org_info = await _get_org_info_from_context(context)
assert org_info is not None
assert org_info['org_id'] == 'org-456'
assert org_info['org_name'] == 'Test Organization'
assert org_info['role'] == 'admin'
mock_user_auth.get_org_info.assert_called_once()
@pytest.mark.asyncio
async def test_returns_none_for_non_auth_user_context(self):
"""Should return None when context is not AuthUserContext."""
from server.routes.users_v1 import _get_org_info_from_context
from openhands.app_server.user.user_context import UserContext
# Create a non-AuthUserContext
mock_context = MagicMock(spec=UserContext)
org_info = await _get_org_info_from_context(mock_context)
assert org_info is None
@pytest.mark.asyncio
async def test_returns_none_for_non_saas_user_auth(self):
"""Should return None when user_auth is not SaasUserAuth."""
from server.routes.users_v1 import _get_org_info_from_context
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.server.user_auth.user_auth import UserAuth
# Create AuthUserContext with a non-SaasUserAuth
mock_user_auth = MagicMock(spec=UserAuth)
mock_context = MagicMock(spec=AuthUserContext)
mock_context.user_auth = mock_user_auth
org_info = await _get_org_info_from_context(mock_context)
assert org_info is None
class TestGetCurrentUserSaasEndpoint:
"""Test suite for get_current_user_saas endpoint."""
@pytest.fixture
def mock_user_context(self):
"""Create a mock user context."""
return AsyncMock()
@pytest.mark.asyncio
async def test_endpoint_returns_saas_user_info_with_org_fields(
self, mock_user_context
):
"""Endpoint should return SaasUserInfo with org fields."""
from unittest.mock import patch
from server.models.user_models import SaasUserInfo
from server.routes.users_v1 import get_current_user_saas
from openhands.app_server.user.user_models import UserInfo
# Mock base user info
base_user_info = UserInfo(id='user-123', llm_model='test-model')
mock_user_context.get_user_info = AsyncMock(return_value=base_user_info)
# Mock _get_org_info_from_context to return org info
org_info = {
'org_id': 'org-456',
'org_name': 'Test Organization',
'role': 'member',
'permissions': ['read', 'write'],
}
with patch(
'server.routes.users_v1._get_org_info_from_context',
return_value=org_info,
):
result = await get_current_user_saas(
user_context=mock_user_context, expose_secrets=False
)
assert isinstance(result, SaasUserInfo)
assert result.id == 'user-123'
assert result.org_id == 'org-456'
assert result.org_name == 'Test Organization'
assert result.role == 'member'
assert result.permissions == ['read', 'write']
@pytest.mark.asyncio
async def test_endpoint_returns_saas_user_info_without_org_fields(
self, mock_user_context
):
"""Endpoint should work when org info is not available."""
from unittest.mock import patch
from server.models.user_models import SaasUserInfo
from server.routes.users_v1 import get_current_user_saas
from openhands.app_server.user.user_models import UserInfo
# Mock base user info
base_user_info = UserInfo(id='user-123', llm_model='test-model')
mock_user_context.get_user_info = AsyncMock(return_value=base_user_info)
# Mock _get_org_info_from_context to return None
with patch(
'server.routes.users_v1._get_org_info_from_context',
return_value=None,
):
result = await get_current_user_saas(
user_context=mock_user_context, expose_secrets=False
)
assert isinstance(result, SaasUserInfo)
assert result.id == 'user-123'
assert result.org_id is None
assert result.org_name is None
assert result.role is None
assert result.permissions is None
@pytest.mark.asyncio
async def test_endpoint_raises_401_when_user_info_is_none(self, mock_user_context):
"""Endpoint should raise 401 when user info is None."""
from fastapi import HTTPException
from server.routes.users_v1 import get_current_user_saas
mock_user_context.get_user_info = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await get_current_user_saas(
user_context=mock_user_context, expose_secrets=False
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == 'Not authenticated'
class TestOverrideUsersEndpoint:
"""Test suite for override_users_me_endpoint function."""
def test_override_removes_oss_route_and_adds_saas_route(self):
"""override_users_me_endpoint should remove OSS route and add SAAS route."""
from fastapi import FastAPI
from server.routes.users_v1 import override_users_me_endpoint
# Create a minimal app with a mock OSS route
app = FastAPI()
@app.get('/api/v1/users/me')
def mock_oss_endpoint():
return {'source': 'oss'}
# Verify OSS route exists
oss_routes = [
r for r in app.routes if hasattr(r, 'path') and r.path == '/api/v1/users/me'
]
assert len(oss_routes) == 1
assert oss_routes[0].endpoint.__name__ == 'mock_oss_endpoint'
# Apply the override
override_users_me_endpoint(app)
# Verify SAAS route exists and OSS route is gone
saas_routes = [
r for r in app.routes if hasattr(r, 'path') and r.path == '/api/v1/users/me'
]
assert len(saas_routes) == 1
assert saas_routes[0].endpoint.__name__ == 'get_current_user_saas'
-347
View File
@@ -1,347 +0,0 @@
"""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
@@ -535,99 +535,6 @@ 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
+51 -122
View File
@@ -135,19 +135,14 @@ class TestRepoVerificationHandling:
@patch('integrations.slack.slack_manager.sio')
@patch.object(SlackManager, 'send_message', new_callable=AsyncMock)
async def test_no_repo_mentioned_shows_button_and_dropdown(
async def test_no_repo_mentioned_shows_external_selector(
self,
mock_send_message,
mock_sio,
slack_manager,
slack_new_conversation_view,
):
"""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
"""
"""Test that when no repo is mentioned, external_select repo selector is shown."""
# Setup Redis mock
mock_redis = AsyncMock()
mock_sio.manager.redis = mock_redis
@@ -167,75 +162,17 @@ class TestRepoVerificationHandling:
mock_send_message.assert_called_once()
call_args = mock_send_message.call_args
# Should be the repo selection form with button + external_select
# Should be the repo selection form with 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', [])
# 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
assert len(elements) > 0
assert elements[0].get('type') == 'external_select'
@patch('integrations.slack.slack_manager.sio')
@patch('integrations.slack.slack_manager.ProviderHandler')
@@ -286,8 +223,8 @@ class TestRepoVerificationHandling:
class TestBuildRepoOptions:
"""Test the _build_repo_options helper method.
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.
Note: _build_repo_options always includes the "No Repository" option at the top.
This is by design for the external_select dropdown.
"""
def test_build_options_with_repos(self, slack_manager):
@@ -310,20 +247,21 @@ class TestBuildRepoOptions:
options = slack_manager._build_repo_options(repos)
# 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'
# 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'
def test_build_options_empty_repos(self, slack_manager):
"""Test building options with empty repo list returns empty list.
Note: "No Repository" is now handled by a separate button in the form.
"""
"""Test building options with empty repo list still includes No Repository."""
options = slack_manager._build_repo_options([])
# Should have 0 options (empty list)
assert len(options) == 0
# Should have 1 option: just "No Repository"
assert len(options) == 1
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
def test_build_options_truncates_long_names(self, slack_manager):
"""Test that repo names longer than 75 chars are truncated."""
@@ -340,12 +278,12 @@ class TestBuildRepoOptions:
options = slack_manager._build_repo_options(repos)
# Should have 1 option (the repo only - "No Repository" is a button)
assert len(options) == 1
# First option is "No Repository", second is the repo
assert len(options) == 2
# Text should be truncated to 75 chars
assert len(options[0]['text']['text']) == 75
assert len(options[1]['text']['text']) == 75
# But value should have full name
assert options[0]['value'] == long_name
assert options[1]['value'] == long_name
class TestSearchRepositories:
@@ -475,23 +413,23 @@ class TestSearchRepositories:
options = slack_manager._build_repo_options(search_results)
# Verify: Options are correctly built from search results
# Note: "No Repository" is now a button, not in the dropdown
assert len(options) == 3 # 3 repos only
assert len(options) == 4 # "No Repository" + 3 repos
# 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'
# 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'
@patch('integrations.slack.slack_manager.ProviderHandler')
async def test_search_with_empty_results_builds_empty_options(
async def test_search_with_empty_results_builds_no_repo_only_option(
self, mock_provider_handler_class, slack_manager, mock_user_auth
):
"""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.
"""
"""Test that when search returns no results, only 'No Repository' option is shown."""
# Setup: No matching repos
mock_provider_handler = MagicMock()
mock_provider_handler.search_repositories = AsyncMock(return_value=[])
@@ -509,8 +447,10 @@ class TestSearchRepositories:
)
options = slack_manager._build_repo_options(search_results)
# Verify: Empty options list (button handles "No Repository")
assert len(options) == 0
# Verify: Only "No Repository" option
assert len(options) == 1
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
class TestUserMsgStorage:
@@ -729,10 +669,7 @@ 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.
Note: 'No Repository' is handled by a separate button in the form.
"""
"""Test that when webhooks are disabled, empty options are returned."""
from server.routes.integration.slack import on_options_load
response = await on_options_load(mock_request, background_tasks)
@@ -746,10 +683,7 @@ 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.
Note: 'No Repository' is handled by a separate button in the form.
"""
"""Test that when no payload is in request, empty options are returned."""
from server.routes.integration.slack import on_options_load
mock_request.body = AsyncMock(return_value=b'')
@@ -797,10 +731,7 @@ 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.
Note: 'No Repository' is handled by a separate button in the form.
"""
"""Test that non-block_suggestion payload returns empty options."""
from server.routes.integration.slack import on_options_load
payload = {
@@ -833,10 +764,7 @@ class TestOnOptionsLoadEndpoint:
background_tasks,
valid_block_suggestion_payload,
):
"""Test that unauthenticated users get empty options and linking message is queued.
Note: 'No Repository' is handled by a separate button in the form.
"""
"""Test that unauthenticated users get empty options and linking message is queued."""
from server.routes.integration.slack import on_options_load
payload_str = json.dumps(valid_block_suggestion_payload)
@@ -889,8 +817,9 @@ class TestOnOptionsLoadEndpoint:
return_value=(mock_slack_user, mock_user_auth)
)
# Expected options from search_repos_for_slack (no "No Repository" - that's a button)
# Expected options from search_repos_for_slack
expected_options = [
{'text': {'type': 'plain_text', 'text': 'No Repository'}, 'value': '-'},
{
'text': {'type': 'plain_text', 'text': 'owner/repo1'},
'value': 'owner/repo1',
@@ -949,8 +878,11 @@ class TestOnOptionsLoadEndpoint:
mock_slack_manager.authenticate_user = AsyncMock(
return_value=(mock_slack_user, mock_user_auth)
)
# Empty search returns empty list (no repos found, and "No Repository" is a button)
mock_slack_manager.search_repos_for_slack = AsyncMock(return_value=[])
mock_slack_manager.search_repos_for_slack = AsyncMock(
return_value=[
{'text': {'type': 'plain_text', 'text': 'No Repository'}, 'value': '-'}
]
)
response = await on_options_load(mock_request, background_tasks)
@@ -975,10 +907,7 @@ class TestOnOptionsLoadEndpoint:
mock_slack_user,
mock_user_auth,
):
"""Test that when search raises an exception, empty options are returned gracefully.
Note: 'No Repository' is handled by a separate button in the form.
"""
"""Test that when search raises an exception, empty options are returned gracefully."""
from server.routes.integration.slack import on_options_load
payload_str = json.dumps(valid_block_suggestion_payload)
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { EventMessage } from "#/components/features/chat/event-message";
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { app_mode: "saas" },
}),
}));
vi.mock("#/hooks/query/use-feedback-exists", () => ({
useFeedbackExists: (eventId: number | undefined) => ({
data: { exists: false },
isLoading: false,
}),
}));
describe("EventMessage", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should render LikertScale for finish action when it's the last message", () => {
const finishEvent = {
id: 123,
source: "agent" as const,
action: "finish" as const,
args: {
final_thought: "Task completed successfully",
outputs: {},
thought: "Task completed successfully",
},
message: "Task completed successfully",
timestamp: new Date().toISOString(),
};
renderWithProviders(
<EventMessage
event={finishEvent}
hasObservationPair={false}
isAwaitingUserConfirmation={false}
isLastMessage={true}
isInLast10Actions={true}
/>
);
expect(screen.getByLabelText("Rate 1 stars")).toBeInTheDocument();
expect(screen.getByLabelText("Rate 5 stars")).toBeInTheDocument();
});
it("should render LikertScale for assistant message when it's the last message", () => {
const assistantMessageEvent = {
id: 456,
source: "agent" as const,
action: "message" as const,
args: {
thought: "I need more information to proceed.",
image_urls: null,
file_urls: [],
wait_for_response: true,
},
message: "I need more information to proceed.",
timestamp: new Date().toISOString(),
};
renderWithProviders(
<EventMessage
event={assistantMessageEvent}
hasObservationPair={false}
isAwaitingUserConfirmation={false}
isLastMessage={true}
isInLast10Actions={true}
/>
);
expect(screen.getByLabelText("Rate 1 stars")).toBeInTheDocument();
expect(screen.getByLabelText("Rate 5 stars")).toBeInTheDocument();
});
it("should render LikertScale for error observation when it's the last message", () => {
const errorEvent = {
id: 789,
source: "user" as const,
observation: "error" as const,
content: "An error occurred",
extras: {
error_id: "test-error-123",
},
message: "An error occurred",
timestamp: new Date().toISOString(),
cause: 123,
};
renderWithProviders(
<EventMessage
event={errorEvent}
hasObservationPair={false}
isAwaitingUserConfirmation={false}
isLastMessage={true}
isInLast10Actions={true}
/>
);
expect(screen.getByLabelText("Rate 1 stars")).toBeInTheDocument();
expect(screen.getByLabelText("Rate 5 stars")).toBeInTheDocument();
});
it("should NOT render LikertScale when not the last message", () => {
const finishEvent = {
id: 101,
source: "agent" as const,
action: "finish" as const,
args: {
final_thought: "Task completed successfully",
outputs: {},
thought: "Task completed successfully",
},
message: "Task completed successfully",
timestamp: new Date().toISOString(),
};
renderWithProviders(
<EventMessage
event={finishEvent}
hasObservationPair={false}
isAwaitingUserConfirmation={false}
isLastMessage={false}
isInLast10Actions={false}
/>
);
expect(screen.queryByLabelText("Rate 1 stars")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Rate 5 stars")).not.toBeInTheDocument();
});
it("should render LikertScale for error observation when in last 10 actions but not last message", () => {
const errorEvent = {
id: 999,
source: "user" as const,
observation: "error" as const,
content: "An error occurred",
extras: {
error_id: "test-error-456",
},
message: "An error occurred",
timestamp: new Date().toISOString(),
cause: 123,
};
renderWithProviders(
<EventMessage
event={errorEvent}
hasObservationPair={false}
isAwaitingUserConfirmation={false}
isLastMessage={false}
isInLast10Actions={true}
/>
);
expect(screen.getByLabelText("Rate 1 stars")).toBeInTheDocument();
expect(screen.getByLabelText("Rate 5 stars")).toBeInTheDocument();
});
it("should NOT render LikertScale for error observation when not in last 10 actions", () => {
const errorEvent = {
id: 888,
source: "user" as const,
observation: "error" as const,
content: "An error occurred",
extras: {
error_id: "test-error-789",
},
message: "An error occurred",
timestamp: new Date().toISOString(),
cause: 123,
};
renderWithProviders(
<EventMessage
event={errorEvent}
hasObservationPair={false}
isAwaitingUserConfirmation={false}
isLastMessage={false}
isInLast10Actions={false}
/>
);
expect(screen.queryByLabelText("Rate 1 stars")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Rate 5 stars")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,165 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
import { I18nKey } from "#/i18n/declaration";
vi.mock("react-router", async () => ({
useParams: vi.fn().mockReturnValue({
conversationId: "123",
}),
}));
// Mock the useHandleRuntimeActive hook
vi.mock("#/hooks/use-handle-runtime-active", () => ({
useHandleRuntimeActive: vi.fn().mockReturnValue({ runtimeActive: true }),
}));
// Mock the useMicroagentPrompt hook
vi.mock("#/hooks/query/use-microagent-prompt", () => ({
useMicroagentPrompt: vi.fn().mockReturnValue({
data: "Generated prompt",
isLoading: false
}),
}));
// Mock the useGetMicroagents hook
vi.mock("#/hooks/query/use-get-microagents", () => ({
useGetMicroagents: vi.fn().mockReturnValue({
data: ["file1", "file2"]
}),
}));
// Mock the useTranslation hook
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
[I18nKey.MICROAGENT$ADD_TO_MICROAGENT]: "Add to Microagent",
[I18nKey.MICROAGENT$WHAT_TO_REMEMBER]: "What would you like your microagent to remember?",
[I18nKey.MICROAGENT$WHERE_TO_PUT]: "Where should we put it?",
[I18nKey.MICROAGENT$ADD_TRIGGERS]: "Add triggers for the microagent",
[I18nKey.MICROAGENT$DESCRIBE_WHAT_TO_ADD]: "Describe what you want to add to the Microagent...",
[I18nKey.MICROAGENT$SELECT_FILE_OR_CUSTOM]: "Select a microagent file or enter a custom value",
[I18nKey.MICROAGENT$TYPE_TRIGGER_SPACE]: "Type a trigger and press Space to add it",
[I18nKey.MICROAGENT$LOADING_PROMPT]: "Loading prompt...",
[I18nKey.MICROAGENT$CANCEL]: "Cancel",
[I18nKey.MICROAGENT$LAUNCH]: "Launch"
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
describe("LaunchMicroagentModal", () => {
const onCloseMock = vi.fn();
const onLaunchMock = vi.fn();
const eventId = 12;
const conversationId = "123";
const renderMicroagentModal = (
{ isLoading }: { isLoading: boolean } = { isLoading: false },
) =>
render(
<LaunchMicroagentModal
onClose={onCloseMock}
onLaunch={onLaunchMock}
eventId={eventId}
selectedRepo="some-repo"
isLoading={isLoading}
/>,
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
},
);
afterEach(() => {
vi.clearAllMocks();
});
it("should render the launch microagent modal", () => {
renderMicroagentModal();
expect(screen.getByTestId("launch-microagent-modal")).toBeInTheDocument();
});
it("should render the form fields", () => {
renderMicroagentModal();
// inputs
screen.getByTestId("query-input");
screen.getByTestId("target-input");
screen.getByTestId("trigger-input");
// action buttons
screen.getByRole("button", { name: "Launch" });
screen.getByRole("button", { name: "Cancel" });
});
it("should call onClose when pressing the cancel button", async () => {
renderMicroagentModal();
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(cancelButton);
expect(onCloseMock).toHaveBeenCalled();
});
it("should display the prompt from the hook", async () => {
renderMicroagentModal();
// Since we're mocking the hook, we just need to verify the UI shows the data
const descriptionInput = screen.getByTestId("query-input");
expect(descriptionInput).toHaveValue("Generated prompt");
});
it("should display the list of microagent files from the hook", async () => {
renderMicroagentModal();
// Since we're mocking the hook, we just need to verify the UI shows the data
const targetInput = screen.getByTestId("target-input");
expect(targetInput).toHaveValue("");
await userEvent.click(targetInput);
expect(screen.getByText("file1")).toBeInTheDocument();
expect(screen.getByText("file2")).toBeInTheDocument();
await userEvent.click(screen.getByText("file1"));
expect(targetInput).toHaveValue("file1");
});
it("should call onLaunch with the form data", async () => {
renderMicroagentModal();
const triggerInput = screen.getByTestId("trigger-input");
await userEvent.type(triggerInput, "trigger1 ");
await userEvent.type(triggerInput, "trigger2 ");
const targetInput = screen.getByTestId("target-input");
await userEvent.click(targetInput);
await userEvent.click(screen.getByText("file1"));
const launchButton = await screen.findByRole("button", { name: "Launch" });
await userEvent.click(launchButton);
expect(onLaunchMock).toHaveBeenCalledWith("Generated prompt", "file1", [
"trigger1",
"trigger2",
]);
});
it("should disable the launch button if isLoading is true", async () => {
renderMicroagentModal({ isLoading: true });
const launchButton = screen.getByRole("button", { name: "Launch" });
expect(launchButton).toBeDisabled();
});
});
@@ -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 { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { ConversationStatus } from "#/types/conversation-status";
// We'll use the actual i18next implementation but override the translation function
@@ -474,23 +474,23 @@ describe("ConversationCard", () => {
).not.toBeInTheDocument();
});
const statusTable: [V1SandboxStatus, boolean][] = [
const statusTable: [ConversationStatus, boolean][] = [
["RUNNING", true],
["STARTING", true],
["STOPPED", false],
["PAUSED", false],
["MISSING", false],
["ARCHIVED", false],
["ERROR", false],
];
it.each(statusTable)(
"should toggle stop button visibility correctly for sandbox status",
(sandboxStatus, shouldShow) => {
"should toggle stop button visibility correctly for status",
(status, shouldShow) => {
renderWithProviders(
<ConversationCardActions
contextMenuOpen={true}
onContextMenuToggle={vi.fn()}
onStop={vi.fn()}
sandboxStatus={sandboxStatus}
conversationStatus={status}
/>,
);
@@ -5,10 +5,8 @@ import { createRoutesStub } from "react-router";
import React from "react";
import { renderWithProviders } from "test-utils";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
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";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
// Mock the unified stop conversation hook
const mockStopConversationMutate = vi.fn();
@@ -18,29 +16,6 @@ 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(),
@@ -74,18 +49,54 @@ describe("ConversationPanel", () => {
}));
});
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" }),
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,
},
];
beforeEach(() => {
vi.clearAllMocks();
mockStopConversationMutate.mockClear();
// Setup default mock for V1 searchConversations
vi.spyOn(V1ConversationService, "searchConversations").mockResolvedValue({
items: [...mockConversations],
// Setup default mock for getUserConversations
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
results: [...mockConversations],
next_page_id: null,
});
});
@@ -100,12 +111,12 @@ describe("ConversationPanel", () => {
});
it("should display an empty state when there are no conversations", async () => {
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
searchConversationsSpy.mockResolvedValue({
items: [],
getUserConversationsSpy.mockResolvedValue({
results: [],
next_page_id: null,
});
@@ -116,11 +127,11 @@ describe("ConversationPanel", () => {
});
it("should handle an error when fetching conversations", async () => {
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
searchConversationsSpy.mockRejectedValue(
getUserConversationsSpy.mockRejectedValue(
new Error("Failed to fetch conversations"),
);
@@ -166,27 +177,63 @@ describe("ConversationPanel", () => {
it("should delete a conversation", async () => {
const user = userEvent.setup();
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 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 searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
searchConversationsSpy.mockImplementation(async () => ({
items: mockData,
getUserConversationsSpy.mockImplementation(async () => ({
results: mockData,
next_page_id: null,
}));
const deleteConversationSpy = vi.spyOn(
V1ConversationService,
"deleteConversation",
const deleteUserConversationSpy = vi.spyOn(
ConversationService,
"deleteUserConversation",
);
deleteConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex((conv) => conv.id === id);
deleteUserConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex((conv) => conv.conversation_id === id);
if (index !== -1) {
mockData.splice(index, 1);
}
@@ -195,7 +242,6 @@ 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");
@@ -209,10 +255,15 @@ 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 () => {
@@ -228,12 +279,12 @@ describe("ConversationPanel", () => {
it("should refetch data on rerenders", async () => {
const user = userEvent.setup();
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
searchConversationsSpy.mockResolvedValue({
items: [...mockConversations],
getUserConversationsSpy.mockResolvedValue({
results: [...mockConversations],
next_page_id: null,
});
@@ -278,18 +329,54 @@ describe("ConversationPanel", () => {
const user = userEvent.setup();
// Create mock data with a RUNNING conversation
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 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 searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
searchConversationsSpy.mockResolvedValue({
items: mockRunningConversations,
getUserConversationsSpy.mockResolvedValue({
results: mockRunningConversations,
next_page_id: null,
});
@@ -325,26 +412,48 @@ describe("ConversationPanel", () => {
it("should stop a conversation", async () => {
const user = userEvent.setup();
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 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 searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
searchConversationsSpy.mockImplementation(async () => ({
items: mockData,
getUserConversationsSpy.mockImplementation(async () => ({
results: mockData,
next_page_id: null,
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Component shows all 3 conversations (no filtering by status)
expect(cards).toHaveLength(3);
expect(cards).toHaveLength(2);
// Click ellipsis on the first card (RUNNING status)
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
@@ -366,7 +475,7 @@ describe("ConversationPanel", () => {
// Verify the mutation was called
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "1",
version: "V1",
version: undefined,
});
expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
});
@@ -374,18 +483,54 @@ describe("ConversationPanel", () => {
it("should only show stop button for STARTING or RUNNING conversations", async () => {
const user = userEvent.setup();
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 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 searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
searchConversationsSpy.mockResolvedValue({
items: mockMixedStatusConversations,
getUserConversationsSpy.mockResolvedValue({
results: mockMixedStatusConversations,
next_page_id: null,
});
@@ -489,12 +634,12 @@ describe("ConversationPanel", () => {
it("should successfully update conversation title", async () => {
const user = userEvent.setup();
// Mock the updateConversationTitle API call
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
// Mock the updateConversation API call
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -516,17 +661,19 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API call was made with correct parameters
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Updated Title");
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Updated Title",
});
});
it("should save title when Enter key is pressed", async () => {
const user = userEvent.setup();
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -546,17 +693,19 @@ describe("ConversationPanel", () => {
await user.keyboard("{Enter}");
// Verify API call was made
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Title Updated via Enter");
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Title Updated via Enter",
});
});
it("should trim whitespace from title", async () => {
const user = userEvent.setup();
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -576,17 +725,19 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API call was made with trimmed title
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Trimmed Title");
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Trimmed Title",
});
});
it("should revert to original title when empty", async () => {
const user = userEvent.setup();
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -605,18 +756,17 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API was not called
expect(updateConversationTitleSpy).not.toHaveBeenCalled();
expect(updateConversationSpy).not.toHaveBeenCalled();
});
it("should handle API error when updating title", async () => {
const user = userEvent.setup();
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
updateConversationTitleSpy.mockRejectedValue(new Error("API Error"));
// Provide return type for mock
updateConversationSpy.mockRejectedValue(new Error("API Error"));
renderConversationPanel();
@@ -636,11 +786,13 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API call was made
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Failed Update");
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Failed Update",
});
// Wait for error handling
await waitFor(() => {
expect(updateConversationTitleSpy).toHaveBeenCalled();
expect(updateConversationSpy).toHaveBeenCalled();
});
});
@@ -676,11 +828,11 @@ describe("ConversationPanel", () => {
it("should not call API when title is unchanged", async () => {
const user = userEvent.setup();
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -697,7 +849,7 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API was NOT called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationTitleSpy).not.toHaveBeenCalledWith("1", {
expect(updateConversationSpy).not.toHaveBeenCalledWith("1", {
title: "Conversation 1",
});
});
@@ -705,11 +857,11 @@ describe("ConversationPanel", () => {
it("should handle special characters in title", async () => {
const user = userEvent.setup();
const updateConversationTitleSpy = vi.spyOn(
V1ConversationService,
"updateConversationTitle",
const updateConversationSpy = vi.spyOn(
ConversationService,
"updateConversation",
);
updateConversationTitleSpy.mockResolvedValue(createMockConversation({ id: "1", title: "Updated Title" }));
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
@@ -729,7 +881,9 @@ describe("ConversationPanel", () => {
await user.tab();
// Verify API call was made with special characters
expect(updateConversationTitleSpy).toHaveBeenCalledWith("1", "Special @#$%^&*()_+ Characters");
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Special @#$%^&*()_+ Characters",
});
});
it("should close delete modal when clicking backdrop", async () => {
@@ -764,14 +918,24 @@ describe("ConversationPanel", () => {
const user = userEvent.setup();
// Create mock data with a RUNNING conversation
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 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,
},
];
vi.spyOn(V1ConversationService, "searchConversations").mockResolvedValue({
items: mockRunningConversations,
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
results: mockRunningConversations,
next_page_id: null,
});
@@ -3,7 +3,7 @@ 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";
import type { Conversation } from "#/api/open-hands.types";
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@@ -24,27 +24,21 @@ vi.mock("react-i18next", async () => {
};
});
const baseConversation: V1AppConversation = {
id: "test-id",
const baseConversation: Conversation = {
conversation_id: "test-id",
title: "Test Conversation",
sandbox_status: "RUNNING",
execution_status: "RUNNING",
updated_at: "2021-10-01T12:00:00Z",
status: "RUNNING",
last_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: [],
runtime_status: null,
url: null,
session_api_key: null,
};
const renderRecentConversation = (conversation: V1AppConversation) =>
const renderRecentConversation = (conversation: Conversation) =>
renderWithProviders(
<BrowserRouter>
<RecentConversation conversation={conversation} />
@@ -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 V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import ConversationService from "#/api/conversation-service/conversation-service.api";
const renderRecentConversations = () => {
const RouterStub = createRoutesStub([
@@ -29,13 +29,13 @@ const renderRecentConversations = () => {
};
describe("RecentConversations", () => {
const searchConversationsSpy = vi.spyOn(
V1ConversationService,
"searchConversations",
const getUserConversationsSpy = vi.spyOn(
ConversationService,
"getUserConversations",
);
it("should not show empty state when there is an error", async () => {
searchConversationsSpy.mockRejectedValue(
getUserConversationsSpy.mockRejectedValue(
new Error("Failed to fetch conversations"),
);
@@ -1,76 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer";
const GFM_TABLE = [
"| Feature | OpenAI Codex | Claude Code |",
"|---------|--------------|-------------|",
"| CLI | ✅ | ✅ |",
"| Mobile | ❌ | ✅ |",
].join("\n");
describe("table (markdown)", () => {
it("should render a GFM pipe table as a <table> element", () => {
render(<MarkdownRenderer>{GFM_TABLE}</MarkdownRenderer>);
const table = screen.getByRole("table");
expect(table).toBeInTheDocument();
// border-collapse + border is what makes columns visually separate
expect(table).toHaveClass("border-collapse");
expect(table).toHaveClass("border");
});
it("should wrap the table in a horizontally scrollable container", () => {
const { container } = render(
<MarkdownRenderer>{GFM_TABLE}</MarkdownRenderer>,
);
// Wide tables must not break chat layout — wrapper enables overflow
const wrapper = container.querySelector(".overflow-x-auto");
expect(wrapper).not.toBeNull();
expect(wrapper?.querySelector("table")).not.toBeNull();
});
it("should render header cells as styled <th> elements", () => {
render(<MarkdownRenderer>{GFM_TABLE}</MarkdownRenderer>);
const headers = screen.getAllByRole("columnheader");
expect(headers).toHaveLength(3);
expect(headers[0]).toHaveTextContent("Feature");
expect(headers[1]).toHaveTextContent("OpenAI Codex");
expect(headers[2]).toHaveTextContent("Claude Code");
// Padding + border is what was missing before the fix
headers.forEach((h) => {
expect(h).toHaveClass("border");
expect(h).toHaveClass("px-3");
expect(h).toHaveClass("py-2");
});
});
it("should render body cells as styled <td> elements", () => {
render(<MarkdownRenderer>{GFM_TABLE}</MarkdownRenderer>);
const cells = screen.getAllByRole("cell");
expect(cells).toHaveLength(6);
expect(cells[0]).toHaveTextContent("CLI");
expect(cells[3]).toHaveTextContent("Mobile");
cells.forEach((c) => {
expect(c).toHaveClass("border");
expect(c).toHaveClass("px-3");
expect(c).toHaveClass("py-2");
});
});
it("should not render table markdown as plain paragraph text", () => {
// Regression guard: before the fix, missing component overrides made the
// table render with no visible borders/padding so columns looked like
// space-separated text. Ensure a real <table> exists now.
const { container } = render(
<MarkdownRenderer>{GFM_TABLE}</MarkdownRenderer>,
);
expect(container.querySelectorAll("table")).toHaveLength(1);
expect(container.querySelectorAll("th")).toHaveLength(3);
expect(container.querySelectorAll("td")).toHaveLength(6);
});
});
@@ -0,0 +1,85 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
describe("TrajectoryActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render correctly", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
});
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
/>,
);
const positiveFeedback = screen.getByTestId("positive-feedback");
await user.click(positiveFeedback);
expect(onPositiveFeedback).toHaveBeenCalled();
});
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
/>,
);
const negativeFeedback = screen.getByTestId("negative-feedback");
await user.click(negativeFeedback);
expect(onNegativeFeedback).toHaveBeenCalled();
});
describe("SaaS mode", () => {
it("should render all buttons when isSaasMode is false", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
isSaasMode={false}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
});
it("should render all buttons when isSaasMode is undefined (default behavior)", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
});
});
});
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock useParams before importing components
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...(actual as object),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
import { I18nKey } from "#/i18n/declaration";
describe("FeedbackForm", () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render correctly", () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
screen.getByLabelText(I18nKey.FEEDBACK$EMAIL_LABEL);
screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
screen.getByRole("button", { name: I18nKey.FEEDBACK$SHARE_LABEL });
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL });
});
it("should switch between private and public permissions", async () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
const privateRadio = screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
const publicRadio = screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
expect(privateRadio).toBeChecked(); // private is the default value
expect(publicRadio).not.toBeChecked();
await user.click(publicRadio);
expect(publicRadio).toBeChecked();
expect(privateRadio).not.toBeChecked();
await user.click(privateRadio);
expect(privateRadio).toBeChecked();
expect(publicRadio).not.toBeChecked();
});
it("should call onClose when the close button is clicked", async () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
await user.click(
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }),
);
expect(onCloseMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { LikertScale } from "#/components/features/feedback/likert-scale";
import { I18nKey } from "#/i18n/declaration";
// Mock the mutation hook
vi.mock("#/hooks/mutation/use-submit-conversation-feedback", () => ({
useSubmitConversationFeedback: () => ({
mutate: vi.fn(),
}),
}));
describe("LikertScale", () => {
const user = userEvent.setup();
afterEach(() => {
vi.clearAllMocks();
});
it("should render with proper localized text for rating prompt", () => {
renderWithProviders(<LikertScale eventId={1} />);
// Check that the rating prompt is displayed with proper translation key
expect(screen.getByText(I18nKey.FEEDBACK$RATE_AGENT_PERFORMANCE)).toBeInTheDocument();
});
it("should show localized feedback reasons when rating is 3 or below", async () => {
renderWithProviders(<LikertScale eventId={1} />);
// Click on a rating of 3 (which should show reasons)
const threeStarButton = screen.getAllByRole("button")[2]; // 3rd button (rating 3)
await user.click(threeStarButton);
// Wait for reasons to appear
await waitFor(() => {
expect(screen.getByText(I18nKey.FEEDBACK$SELECT_REASON)).toBeInTheDocument();
});
// Check that all feedback reasons are properly localized
expect(screen.getByText(I18nKey.FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION)).toBeInTheDocument();
expect(screen.getByText(I18nKey.FEEDBACK$REASON_FORGOT_CONTEXT)).toBeInTheDocument();
expect(screen.getByText(I18nKey.FEEDBACK$REASON_UNNECESSARY_CHANGES)).toBeInTheDocument();
expect(screen.getByText(I18nKey.FEEDBACK$REASON_SHOULD_ASK_FIRST)).toBeInTheDocument();
expect(screen.getByText(I18nKey.FEEDBACK$REASON_DIDNT_FINISH_JOB)).toBeInTheDocument();
expect(screen.getByText(I18nKey.FEEDBACK$REASON_OTHER)).toBeInTheDocument();
});
it("should show countdown message with proper localization", async () => {
renderWithProviders(<LikertScale eventId={1} />);
// Click on a rating of 2 (which should show reasons and countdown)
const twoStarButton = screen.getAllByRole("button")[1]; // 2nd button (rating 2)
await user.click(twoStarButton);
// Wait for countdown to appear
await waitFor(() => {
expect(screen.getByText(I18nKey.FEEDBACK$SELECT_REASON_COUNTDOWN)).toBeInTheDocument();
});
});
it("should show thank you message after submission", () => {
renderWithProviders(
<LikertScale eventId={1} initiallySubmitted={true} initialRating={4} />
);
// Check that thank you message is displayed with proper translation key
expect(screen.getByText(I18nKey.FEEDBACK$THANK_YOU_FOR_FEEDBACK)).toBeInTheDocument();
});
it("should render all 5 star rating buttons", () => {
renderWithProviders(<LikertScale eventId={1} />);
// Check that all 5 star buttons are rendered
const starButtons = screen.getAllByRole("button");
expect(starButtons).toHaveLength(5);
// Check that each button has proper aria-label
for (let i = 1; i <= 5; i++) {
expect(screen.getByLabelText(`Rate ${i} stars`)).toBeInTheDocument();
}
});
it("should not show reasons for ratings above 3", async () => {
renderWithProviders(<LikertScale eventId={1} />);
// Click on a rating of 5 (which should NOT show reasons)
const fiveStarButton = screen.getAllByRole("button")[4]; // 5th button (rating 5)
await user.click(fiveStarButton);
// Wait a bit to ensure reasons don't appear
await waitFor(() => {
expect(screen.queryByText(I18nKey.FEEDBACK$SELECT_REASON)).not.toBeInTheDocument();
});
});
});
@@ -335,6 +335,36 @@ 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.
@@ -472,97 +502,6 @@ 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.
@@ -0,0 +1,228 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi, beforeEach } from "vitest";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
// ---------------------------------------------------------------
// These tests document a race condition where clicking
// "New Conversation" before settings have loaded causes
// the hook to create a V0 (legacy) conversation instead of V1.
//
// Root cause (original code):
// const { data: settings } = useSettings();
// ...
// const useV1 = !!settings?.v1_enabled && !createMicroagent;
//
// When settings haven't loaded yet, `settings` is `undefined`,
// so `!!undefined?.v1_enabled` → false, silently routing through
// the V0 code path even though the backend defaults v1_enabled
// to `true`.
//
// The fix uses `queryClient.ensureQueryData()` inside the mutation
// to wait for settings before deciding V0 vs V1, with a fallback
// to DEFAULT_SETTINGS (v1_enabled: true) on fetch failure.
// ---------------------------------------------------------------
const mockGetSettingsQueryFn = vi.fn();
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<
typeof import("#/hooks/query/use-settings")
>("#/hooks/query/use-settings");
return {
...actual,
getSettingsQueryFn: (...args: unknown[]) =>
mockGetSettingsQueryFn(...args),
};
});
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackConversationCreated: vi.fn(),
}),
}));
vi.mock("#/context/use-selected-organization", () => ({
useSelectedOrganizationId: () => ({ organizationId: null }),
}));
// Shared mock return values
const V1_RESPONSE = {
id: "task-id-123",
created_by_user_id: null,
status: "READY" as const,
detail: null,
app_conversation_id: null,
sandbox_id: null,
agent_server_url: "http://agent-server.local",
request: {
sandbox_id: null,
initial_message: { role: "user" as const, content: [{ type: "text" as const, text: "hello" }] },
processors: [],
llm_model: null,
selected_repository: null,
selected_branch: null,
git_provider: "github" as const,
suggested_task: null,
title: null,
trigger: null,
pr_number: [],
parent_conversation_id: null,
agent_type: "default" as const,
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const V0_RESPONSE = {
conversation_id: "conv-legacy",
session_api_key: null,
url: null,
title: "",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_updated_at: new Date().toISOString(),
status: "RUNNING" as const,
runtime_status: null,
selected_repository: null,
selected_branch: null,
git_provider: null,
};
describe("useCreateConversation V0 race condition", () => {
let v1Spy: ReturnType<typeof vi.spyOn>;
let v0Spy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
v1Spy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue(V1_RESPONSE);
v0Spy = vi
.spyOn(ConversationService, "createConversation")
.mockResolvedValue(V0_RESPONSE);
});
/**
* BUG REPRODUCTION: When the settings API hasn't been called yet
* (no cached data), the hook should wait for settings to load
* rather than defaulting to V0.
*
* The fix uses `ensureQueryData` to fetch/wait for settings before
* deciding V0 vs V1. The mock here resolves with v1_enabled: true,
* proving that the mutation waits for the settings query.
*/
it("should use V1 API even when settings are not yet cached (race condition scenario)", async () => {
// Simulate the race condition: settings haven't been fetched yet.
// With the fix, ensureQueryData will call getSettingsQueryFn to fetch them.
mockGetSettingsQueryFn.mockResolvedValue({ v1_enabled: true });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { result } = renderHook(() => useCreateConversation(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
await result.current.mutateAsync({ query: "hello" });
await waitFor(() => {
// V1 should be used — the fix waits for settings before deciding
expect(v1Spy).toHaveBeenCalled();
});
// V0 should NOT have been called
expect(v0Spy).not.toHaveBeenCalled();
});
/**
* When the settings fetch fails (e.g. 404 for a new user), the hook
* falls back to DEFAULT_SETTINGS where v1_enabled is now `true`,
* still routing through V1.
*/
it("should use V1 API when settings fetch fails (falls back to defaults)", async () => {
// Simulate settings API failure
mockGetSettingsQueryFn.mockRejectedValue(new Error("404 Not Found"));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { result } = renderHook(() => useCreateConversation(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
await result.current.mutateAsync({ query: "hello" });
await waitFor(() => {
// DEFAULT_SETTINGS.v1_enabled is now true, so V1 should be used
expect(v1Spy).toHaveBeenCalled();
});
expect(v0Spy).not.toHaveBeenCalled();
});
/**
* When settings explicitly have v1_enabled: true, V1 API is used.
*/
it("should use V1 API when settings explicitly have v1_enabled: true", async () => {
mockGetSettingsQueryFn.mockResolvedValue({ v1_enabled: true });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { result } = renderHook(() => useCreateConversation(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
await result.current.mutateAsync({ query: "hello" });
await waitFor(() => {
expect(v1Spy).toHaveBeenCalled();
});
expect(v0Spy).not.toHaveBeenCalled();
});
/**
* When v1_enabled is explicitly false, V0 should be used.
*/
it("should use V0 API when v1_enabled is explicitly false", async () => {
mockGetSettingsQueryFn.mockResolvedValue({ v1_enabled: false });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { result } = renderHook(() => useCreateConversation(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
await result.current.mutateAsync({ query: "hello" });
await waitFor(() => {
expect(v0Spy).toHaveBeenCalled();
});
expect(v1Spy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,121 @@
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);
});
});
@@ -26,9 +26,10 @@ function makeConversation(version: "V0" | "V1"): Conversation {
};
}
function makeEvent(): OpenHandsEvent {
function makeEvent(timestamp?: string): OpenHandsEvent {
return {
id: "evt-1",
timestamp: timestamp ?? "2026-01-15T10:00:00Z",
} as OpenHandsEvent;
}
@@ -60,7 +61,7 @@ describe("useConversationHistory", () => {
vi.clearAllMocks();
});
it("calls V1 REST endpoint for V1 conversations", async () => {
it("calls V1 REST endpoint for V1 conversations with TIMESTAMP_DESC", async () => {
const v1SearchEventsSpy = vi.spyOn(EventService, "searchEventsV1");
vi.mocked(useUserConversation).mockReturnValue({
@@ -72,7 +73,10 @@ describe("useConversationHistory", () => {
refetch: vi.fn(),
} as any);
v1SearchEventsSpy.mockResolvedValue([makeEvent()]);
v1SearchEventsSpy.mockResolvedValue({
items: [makeEvent("2026-01-15T12:00:00Z"), makeEvent("2026-01-15T10:00:00Z")],
next_page_id: undefined,
});
const { result } = renderHook(() => useConversationHistory("conv-123"), {
wrapper,
@@ -82,8 +86,45 @@ describe("useConversationHistory", () => {
expect(result.current.data).toBeDefined();
});
expect(EventService.searchEventsV1).toHaveBeenCalledWith("conv-123");
// Should call with TIMESTAMP_DESC for bi-directional loading (newest first)
expect(EventService.searchEventsV1).toHaveBeenCalledWith("conv-123", {
sort_order: "TIMESTAMP_DESC",
limit: 100,
});
expect(EventService.searchEventsV0).not.toHaveBeenCalled();
// Should return events and oldest timestamp for WebSocket handoff
expect(result.current.data?.events).toHaveLength(2);
expect(result.current.data?.oldestTimestamp).toBe("2026-01-15T10:00:00Z");
});
it("returns null oldestTimestamp when no events", async () => {
const v1SearchEventsSpy = vi.spyOn(EventService, "searchEventsV1");
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),
isLoading: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
} as any);
v1SearchEventsSpy.mockResolvedValue({
items: [],
next_page_id: undefined,
});
const { result } = renderHook(() => useConversationHistory("conv-empty"), {
wrapper,
});
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
expect(result.current.data?.events).toHaveLength(0);
expect(result.current.data?.oldestTimestamp).toBeNull();
});
it("calls V0 REST endpoint for V0 conversations", async () => {
@@ -110,6 +151,10 @@ describe("useConversationHistory", () => {
expect(EventService.searchEventsV0).toHaveBeenCalledWith("conv-456");
expect(EventService.searchEventsV1).not.toHaveBeenCalled();
// V0 returns events but null oldestTimestamp (no bi-directional loading)
expect(result.current.data?.events).toHaveLength(1);
expect(result.current.data?.oldestTimestamp).toBeNull();
});
});
@@ -144,7 +189,7 @@ describe("useConversationHistory cache key stability", () => {
it("does not refetch when conversation object changes but version stays the same", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
const conv1 = makeConversation("V1");
vi.mocked(useUserConversation).mockReturnValue({
@@ -200,7 +245,7 @@ describe("useConversationHistory cache key stability", () => {
const v0Spy = vi.spyOn(EventService, "searchEventsV0");
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v0Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
// Start with V0
vi.mocked(useUserConversation).mockReturnValue({
@@ -242,7 +287,7 @@ describe("useConversationHistory cache key stability", () => {
it("treats cached history as never stale (staleTime is Infinity)", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),
@@ -274,7 +319,7 @@ describe("useConversationHistory cache key stability", () => {
it("has gcTime of at least 30 minutes for navigation resilience", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MicroagentStatusIndicator } from "#/components/features/chat/microagent/microagent-status-indicator";
import { MicroagentStatus } from "#/types/microagent-status";
// Mock the translation hook
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("MicroagentStatusIndicator", () => {
it("should show 'View your PR' when status is completed and PR URL is provided", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
prUrl="https://github.com/owner/repo/pull/123"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
"https://github.com/owner/repo/pull/123",
);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("should show default completed message when status is completed but no PR URL", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
/>,
);
const link = screen.getByRole("link", {
name: "MICROAGENT$STATUS_COMPLETED",
});
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/conversations/test-conversation");
});
it("should show creating status without PR URL", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.CREATING}
conversationId="test-conversation"
/>,
);
expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument();
});
it("should show error status", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.ERROR}
conversationId="test-conversation"
/>,
);
expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument();
});
it("should prioritize PR URL over conversation link when both are provided", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
prUrl="https://github.com/owner/repo/pull/123"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toHaveAttribute(
"href",
"https://github.com/owner/repo/pull/123",
);
// Should not link to conversation when PR URL is available
expect(link).not.toHaveAttribute(
"href",
"/conversations/test-conversation",
);
});
it("should work with GitLab MR URLs", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
prUrl="https://gitlab.com/owner/repo/-/merge_requests/456"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toHaveAttribute(
"href",
"https://gitlab.com/owner/repo/-/merge_requests/456",
);
});
});
@@ -99,6 +99,71 @@ 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({
@@ -125,6 +190,49 @@ 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:
@@ -1,5 +1,7 @@
import { AxiosHeaders } from "axios";
import {
Feedback,
FeedbackResponse,
GetVSCodeUrlResponse,
Conversation,
ResultSet,
@@ -13,6 +15,7 @@ import {
import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
import { SuggestedTask } from "#/utils/types";
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
class ConversationService {
private static currentConversation: Conversation | null = null;
@@ -56,6 +59,105 @@ class ConversationService {
return headers;
}
/**
* Send feedback to the server
* @param data Feedback data
* @returns The stored feedback data
*/
static async submitFeedback(
conversationId: string,
feedback: Feedback,
): Promise<FeedbackResponse> {
const url = `/api/conversations/${conversationId}/submit-feedback`;
const { data } = await openHands.post<FeedbackResponse>(url, feedback);
return data;
}
/**
* Submit conversation feedback with rating
* @param conversationId The conversation ID
* @param rating The rating (1-5)
* @param eventId Optional event ID this feedback corresponds to
* @param reason Optional reason for the rating
* @returns Response from the feedback endpoint
*/
static async submitConversationFeedback(
conversationId: string,
rating: number,
eventId?: number,
reason?: string,
): Promise<{ status: string; message: string }> {
const url = `/feedback/conversation`;
const payload = {
conversation_id: conversationId,
event_id: eventId,
rating,
reason,
metadata: { source: "likert-scale" },
};
const { data } = await openHands.post<{ status: string; message: string }>(
url,
payload,
);
return data;
}
/**
* Check if feedback exists for a specific conversation and event
* @param conversationId The conversation ID
* @param eventId The event ID to check
* @returns Feedback data including existence, rating, and reason
*/
static async checkFeedbackExists(
conversationId: string,
eventId: number,
): Promise<{ exists: boolean; rating?: number; reason?: string }> {
try {
const url = `/feedback/conversation/${conversationId}/${eventId}`;
const { data } = await openHands.get<{
exists: boolean;
rating?: number;
reason?: string;
}>(url);
return data;
} catch {
// Error checking if feedback exists
return { exists: false };
}
}
/**
* Get feedback for multiple events in a conversation
* @param conversationId The conversation ID
* @returns Map of event IDs to feedback data including existence, rating, reason and metadata
*/
static async getBatchFeedback(conversationId: string): Promise<
Record<
string,
{
exists: boolean;
rating?: number;
reason?: string;
metadata?: Record<string, BatchFeedbackData>;
}
>
> {
const url = `/feedback/conversation/${conversationId}/batch`;
const { data } = await openHands.get<
Record<
string,
{
exists: boolean;
rating?: number;
reason?: string;
metadata?: Record<string, BatchFeedbackData>;
}
>
>(url);
return data;
}
/**
* Get the web hosts
* @returns Array of web hosts
@@ -92,6 +194,23 @@ 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,57 +467,6 @@ 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;
@@ -65,14 +65,28 @@ class EventService {
}
// V1 conversations — App Server REST endpoint
static async searchEventsV1(conversationId: string, limit = 100) {
// Note: cursor parameter is exposed for future pagination of older events.
// Currently only the initial page is fetched; background pagination is planned.
static async searchEventsV1(
conversationId: string,
options?: {
limit?: number;
sort_order?: "TIMESTAMP_ASC" | "TIMESTAMP_DESC";
cursor?: string;
},
) {
const { data } = await openHands.get<{
items: OpenHandsEvent[];
next_page_id?: string;
}>(`/api/v1/conversation/${conversationId}/events/search`, {
params: { limit },
params: {
limit: options?.limit ?? 100,
sort_order: options?.sort_order,
cursor: options?.cursor,
},
});
return data.items;
return data;
}
// V0 conversations — Legacy REST endpoint
@@ -2,7 +2,12 @@ import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
import { GitRepository, PaginatedBranchesResponse, Branch } from "#/types/git";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { GitChange, GitChangeDiff } from "../open-hands.types";
import { RepositoryMicroagent } from "#/types/microagent-management";
import {
MicroagentContentResponse,
GitChange,
GitChangeDiff,
} from "../open-hands.types";
import ConversationService from "../conversation-service/conversation-service.api";
/**
@@ -171,6 +176,43 @@ class GitService {
return data;
}
/**
* Get the available microagents for a repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
/**
* Get the content of a specific microagent from a repository
* @param owner The repository owner
* @param repo The repository name
* @param filePath The path to the microagent file within the repository
* @returns The microagent content and metadata
*/
static async getRepositoryMicroagentContent(
owner: string,
repo: string,
filePath: string,
): Promise<MicroagentContentResponse> {
const { data } = await openHands.get<MicroagentContentResponse>(
`/api/user/repository/${owner}/${repo}/microagents/content`,
{
params: { file_path: filePath },
},
);
return data;
}
/**
* Get the user installation IDs
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
@@ -0,0 +1,34 @@
import { openHands } from "#/api/open-hands-axios";
import { Conversation, ResultSet } from "#/api/open-hands.types";
class MicroagentManagementService {
/**
* Get conversations for microagent management
* @param selectedRepository The selected repository
* @param pageId Optional page ID for pagination
* @param limit Maximum number of conversations to return
* @returns List of conversations
*/
static async getMicroagentManagementConversations(
selectedRepository: string,
pageId?: string,
limit: number = 100,
): Promise<Conversation[]> {
const params: Record<string, string | number> = {
limit,
selected_repository: selectedRepository,
};
if (pageId) {
params.page_id = pageId;
}
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/microagent-management/conversations",
{ params },
);
return data.results;
}
}
export default MicroagentManagementService;
@@ -3,10 +3,12 @@ import { usePostHog } from "posthog-js/react";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { AgentState } from "#/types/agent-state";
import { useFilteredEvents } from "#/hooks/use-filtered-events";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
@@ -27,6 +29,7 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
import { ErrorMessageBanner } from "./error-message-banner";
import { Messages as V1Messages } from "#/components/v1/chat";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/stores/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
@@ -78,6 +81,7 @@ export function ChatInterface() {
setAutoScroll,
setHitBottom,
} = useScrollToBottom(scrollRef);
const { data: config } = useConfig();
const {
mutate: newConversationCommand,
isPending: isNewConversationPending,
@@ -116,6 +120,10 @@ export function ChatInterface() {
};
}, [isAgentRunning, handleBuildPlanClick, scrollDomToBottom]);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const { selectedRepository, replayJson } = useInitialQueryStore();
const params = useParams();
const { mutateAsync: uploadFiles } = useUnifiedUploadFiles();
@@ -220,6 +228,13 @@ export function ChatInterface() {
setMessageToSend("");
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
setFeedbackPolarity(polarity);
};
// Auto-scroll to bottom when new messages arrive
React.useEffect(() => {
if (autoScroll) {
@@ -323,6 +338,17 @@ export function ChatInterface() {
status={serverStatusText}
/>
)}
{totalEvents > 0 && !isV1Conversation && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
isSaasMode={config?.app_mode === "saas"}
/>
)}
</div>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
@@ -344,6 +370,14 @@ export function ChatInterface() {
disabled={isNewConversationPending}
/>
</div>
{config?.app_mode !== "saas" && !isV1Conversation && (
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
polarity={feedbackPolarity}
/>
)}
</div>
</ScrollProvider>
);
@@ -1,20 +1,68 @@
import React from "react";
import { OpenHandsObservation } from "#/types/core/observations";
import { isErrorObservation } from "#/types/core/guards";
import { ErrorMessage } from "../error-message";
import { MicroagentStatusWrapper } from "./microagent-status-wrapper";
import { LikertScaleWrapper } from "./likert-scale-wrapper";
import { MicroagentStatus } from "#/types/microagent-status";
interface ErrorEventMessageProps {
event: OpenHandsObservation;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
isLastMessage: boolean;
isInLast10Actions: boolean;
config?: { app_mode?: string } | null;
isCheckingFeedback: boolean;
feedbackData: {
exists: boolean;
rating?: number;
reason?: string;
};
}
export function ErrorEventMessage({ event }: ErrorEventMessageProps) {
export function ErrorEventMessage({
event,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isLastMessage,
isInLast10Actions,
config,
isCheckingFeedback,
feedbackData,
}: ErrorEventMessageProps) {
if (!isErrorObservation(event)) {
return null;
}
return (
<ErrorMessage
errorId={event.extras.error_id}
defaultMessage={event.message}
/>
<div>
<ErrorMessage
errorId={event.extras.error_id}
defaultMessage={event.message}
/>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
<LikertScaleWrapper
event={event}
isLastMessage={isLastMessage}
isInLast10Actions={isInLast10Actions}
config={config}
isCheckingFeedback={isCheckingFeedback}
feedbackData={feedbackData}
/>
</div>
);
}
@@ -2,16 +2,69 @@ import React from "react";
import { OpenHandsAction } from "#/types/core/actions";
import { isFinishAction } from "#/types/core/guards";
import { ChatMessage } from "../chat-message";
import { MicroagentStatusWrapper } from "./microagent-status-wrapper";
import { LikertScaleWrapper } from "./likert-scale-wrapper";
import { getEventContent } from "../event-content-helpers/get-event-content";
import { MicroagentStatus } from "#/types/microagent-status";
interface FinishEventMessageProps {
event: OpenHandsAction;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
isLastMessage: boolean;
isInLast10Actions: boolean;
config?: { app_mode?: string } | null;
isCheckingFeedback: boolean;
feedbackData: {
exists: boolean;
rating?: number;
reason?: string;
};
}
export function FinishEventMessage({ event }: FinishEventMessageProps) {
export function FinishEventMessage({
event,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isLastMessage,
isInLast10Actions,
config,
isCheckingFeedback,
feedbackData,
}: FinishEventMessageProps) {
if (!isFinishAction(event)) {
return null;
}
return <ChatMessage type="agent" message={getEventContent(event).details} />;
return (
<>
<ChatMessage
type="agent"
message={getEventContent(event).details}
actions={actions}
/>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
<LikertScaleWrapper
event={event}
isLastMessage={isLastMessage}
isInLast10Actions={isInLast10Actions}
config={config}
isCheckingFeedback={isCheckingFeedback}
feedbackData={feedbackData}
/>
</>
);
}
@@ -6,4 +6,6 @@ export { McpEventMessage } from "./mcp-event-message";
export { TaskTrackingEventMessage } from "./task-tracking-event-message";
export { ObservationPairEventMessage } from "./observation-pair-event-message";
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
export { MicroagentStatusWrapper } from "./microagent-status-wrapper";
export { LikertScaleWrapper } from "./likert-scale-wrapper";
export { HookExecutionEventMessage } from "./hook-execution-event-message";
@@ -0,0 +1,50 @@
import React from "react";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { isErrorObservation } from "#/types/core/guards";
import { LikertScale } from "../../feedback/likert-scale";
interface LikertScaleWrapperProps {
event: OpenHandsAction | OpenHandsObservation;
isLastMessage: boolean;
isInLast10Actions: boolean;
config?: { app_mode?: string } | null;
isCheckingFeedback: boolean;
feedbackData: {
exists: boolean;
rating?: number;
reason?: string;
};
}
export function LikertScaleWrapper({
event,
isLastMessage,
isInLast10Actions,
config,
isCheckingFeedback,
feedbackData,
}: LikertScaleWrapperProps) {
if (config?.app_mode !== "saas" || isCheckingFeedback) {
return null;
}
// For error observations, show if in last 10 actions
// For other events, show only if it's the last message
const shouldShow = isErrorObservation(event)
? isInLast10Actions
: isLastMessage;
if (!shouldShow) {
return null;
}
return (
<LikertScale
eventId={event.id}
initiallySubmitted={feedbackData.exists}
initialRating={feedbackData.rating}
initialReason={feedbackData.reason}
/>
);
}
@@ -0,0 +1,33 @@
import React from "react";
import { MicroagentStatus } from "#/types/microagent-status";
import { MicroagentStatusIndicator } from "../microagent/microagent-status-indicator";
interface MicroagentStatusWrapperProps {
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
export function MicroagentStatusWrapper({
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
}: MicroagentStatusWrapperProps) {
if (!microagentStatus || !actions) {
return null;
}
return (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
);
}
@@ -2,6 +2,8 @@ import React from "react";
import { OpenHandsAction } from "#/types/core/actions";
import { isOpenHandsAction } from "#/types/core/guards";
import { ChatMessage } from "../chat-message";
import { MicroagentStatusWrapper } from "./microagent-status-wrapper";
import { MicroagentStatus } from "#/types/microagent-status";
const hasThoughtProperty = (
obj: Record<string, unknown>,
@@ -9,10 +11,22 @@ const hasThoughtProperty = (
interface ObservationPairEventMessageProps {
event: OpenHandsAction;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
export function ObservationPairEventMessage({
event,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
}: ObservationPairEventMessageProps) {
if (!isOpenHandsAction(event)) {
return null;
@@ -21,10 +35,27 @@ export function ObservationPairEventMessage({
if (hasThoughtProperty(event.args) && event.action !== "think") {
return (
<div>
<ChatMessage type="agent" message={event.args.thought} />
<ChatMessage
type="agent"
message={event.args.thought}
actions={actions}
/>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
</div>
);
}
return null;
return (
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
);
}
@@ -1,19 +1,49 @@
import React from "react";
import { OpenHandsAction } from "#/types/core/actions";
import { isUserMessage, isAssistantMessage } from "#/types/core/guards";
import { ChatMessage } from "../chat-message";
import { ImageCarousel } from "../../images/image-carousel";
import { FileList } from "../../files/file-list";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { MicroagentStatusWrapper } from "./microagent-status-wrapper";
import { LikertScaleWrapper } from "./likert-scale-wrapper";
import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event";
import { MicroagentStatus } from "#/types/microagent-status";
interface UserAssistantEventMessageProps {
event: OpenHandsAction;
shouldShowConfirmationButtons: boolean;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
isLastMessage: boolean;
isInLast10Actions: boolean;
config?: { app_mode?: string } | null;
isCheckingFeedback: boolean;
feedbackData: {
exists: boolean;
rating?: number;
reason?: string;
};
}
export function UserAssistantEventMessage({
event,
shouldShowConfirmationButtons,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isLastMessage,
isInLast10Actions,
config,
isCheckingFeedback,
feedbackData,
}: UserAssistantEventMessageProps) {
if (!isUserMessage(event) && !isAssistantMessage(event)) {
return null;
@@ -22,14 +52,32 @@ export function UserAssistantEventMessage({
const message = parseMessageFromEvent(event);
return (
<ChatMessage type={event.source} message={message}>
{event.args.image_urls && event.args.image_urls.length > 0 && (
<ImageCarousel size="small" images={event.args.image_urls} />
<>
<ChatMessage type={event.source} message={message} actions={actions}>
{event.args.image_urls && event.args.image_urls.length > 0 && (
<ImageCarousel size="small" images={event.args.image_urls} />
)}
{event.args.file_urls && event.args.file_urls.length > 0 && (
<FileList files={event.args.file_urls} />
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
{isAssistantMessage(event) && event.action === "message" && (
<LikertScaleWrapper
event={event}
isLastMessage={isLastMessage}
isInLast10Actions={isInLast10Actions}
config={config}
isCheckingFeedback={isCheckingFeedback}
feedbackData={feedbackData}
/>
)}
{event.args.file_urls && event.args.file_urls.length > 0 && (
<FileList files={event.args.file_urls} />
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
</>
);
}
@@ -1,3 +1,4 @@
import React from "react";
import { OpenHandsAction } from "#/types/core/actions";
import {
isUserMessage,
@@ -10,6 +11,9 @@ import {
isTaskTrackingObservation,
} from "#/types/core/guards";
import { OpenHandsObservation } from "#/types/core/observations";
import { MicroagentStatus } from "#/types/microagent-status";
import { useConfig } from "#/hooks/query/use-config";
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
import {
ErrorEventMessage,
UserAssistantEventMessage,
@@ -26,6 +30,15 @@ interface EventMessageProps {
hasObservationPair: boolean;
isAwaitingUserConfirmation: boolean;
isLastMessage: boolean;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
isInLast10Actions: boolean;
}
/* eslint-disable react/jsx-props-no-spreading */
@@ -34,23 +47,56 @@ export function EventMessage({
hasObservationPair,
isAwaitingUserConfirmation,
isLastMessage,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isInLast10Actions,
}: EventMessageProps) {
const shouldShowConfirmationButtons =
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
const { data: config } = useConfig();
const {
data: feedbackData = { exists: false },
isLoading: isCheckingFeedback,
} = useFeedbackExists(event.id);
// Common props for components that need them
const commonProps = {
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isLastMessage,
isInLast10Actions,
config,
isCheckingFeedback,
feedbackData,
};
// Error observations
if (isErrorObservation(event)) {
return <ErrorEventMessage event={event} />;
return <ErrorEventMessage event={event} {...commonProps} />;
}
// Observation pairs with OpenHands actions
if (hasObservationPair && isOpenHandsAction(event)) {
return <ObservationPairEventMessage event={event} />;
return (
<ObservationPairEventMessage
event={event}
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
);
}
// Finish actions
if (isFinishAction(event)) {
return <FinishEventMessage event={event} />;
return <FinishEventMessage event={event} {...commonProps} />;
}
// User and assistant messages
@@ -59,6 +105,7 @@ export function EventMessage({
<UserAssistantEventMessage
event={event}
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
{...commonProps}
/>
);
}
@@ -1,10 +1,41 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards";
import {
isOpenHandsAction,
isOpenHandsObservation,
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { EventMessage } from "./event-message";
import { ChatMessage } from "./chat-message";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { LaunchMicroagentModal } from "./microagent/launch-microagent-modal";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import {
MicroagentStatus,
EventMicroagentStatus,
} from "#/types/microagent-status";
import { AgentState } from "#/types/agent-state";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import MemoryIcon from "#/icons/memory_icon.svg?react";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
interface MessagesProps {
messages: (OpenHandsAction | OpenHandsObservation)[];
@@ -13,9 +44,33 @@ interface MessagesProps {
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
const {
createConversationAndSubscribe,
isPending,
unsubscribeFromConversation,
} = useCreateConversationAndSubscribeMultiple();
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId);
const { data: activeConversation } = useActiveConversation();
// TODO: Hide microagent actions for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = activeConversation?.conversation_version === "V1";
const optimisticUserMessage = getOptimisticUserMessage();
const [selectedEventId, setSelectedEventId] = React.useState<number | null>(
null,
);
const [showLaunchMicroagentModal, setShowLaunchMicroagentModal] =
React.useState(false);
const [microagentStatuses, setMicroagentStatuses] = React.useState<
EventMicroagentStatus[]
>([]);
const { t } = useTranslation();
const actionHasObservationPair = React.useCallback(
(event: OpenHandsAction | OpenHandsObservation): boolean => {
if (isOpenHandsAction(event)) {
@@ -29,6 +84,148 @@ export const Messages: React.FC<MessagesProps> = React.memo(
[messages],
);
const getMicroagentStatusForEvent = React.useCallback(
(eventId: number): MicroagentStatus | null => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.status || null;
},
[microagentStatuses],
);
const getMicroagentConversationIdForEvent = React.useCallback(
(eventId: number): string | undefined => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.conversationId || undefined;
},
[microagentStatuses],
);
const getMicroagentPRUrlForEvent = React.useCallback(
(eventId: number): string | undefined => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.prUrl || undefined;
},
[microagentStatuses],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown, microagentConversationId: string) => {
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.ERROR }
: statusEntry,
),
);
} else if (
isOpenHandsEvent(socketEvent) &&
isAgentStateChangeObservation(socketEvent)
) {
// Handle completion states
if (
socketEvent.extras.agent_state === AgentState.FINISHED ||
socketEvent.extras.agent_state === AgentState.AWAITING_USER_INPUT
) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.COMPLETED }
: statusEntry,
),
);
unsubscribeFromConversation(microagentConversationId);
}
} else if (
isOpenHandsEvent(socketEvent) &&
isFinishAction(socketEvent)
) {
// Check if the finish action contains a PR URL
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
if (prUrl) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? {
...statusEntry,
status: MicroagentStatus.COMPLETED,
prUrl,
}
: statusEntry,
),
);
}
unsubscribeFromConversation(microagentConversationId);
} else {
// For any other event, transition from WAITING to CREATING if still waiting
setMicroagentStatuses((prev) => {
const currentStatus = prev.find(
(entry) => entry.conversationId === microagentConversationId,
)?.status;
if (currentStatus === MicroagentStatus.WAITING) {
return prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.CREATING }
: statusEntry,
);
}
return prev; // No change needed
});
}
},
[setMicroagentStatuses, unsubscribeFromConversation],
);
const handleLaunchMicroagent = (
query: string,
target: string,
triggers: string[],
) => {
const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`;
if (
!conversation?.selected_repository ||
!conversation.selected_branch ||
!conversation.git_provider ||
!selectedEventId
) {
return;
}
createConversationAndSubscribe({
query,
conversationInstructions,
repository: {
name: conversation.selected_repository,
branch: conversation.selected_branch,
gitProvider: conversation.git_provider,
},
onSuccessCallback: (newConversationId: string) => {
setShowLaunchMicroagentModal(false);
// Update status with conversation ID - start with WAITING
setMicroagentStatuses((prev) => [
...prev.filter((status) => status.eventId !== selectedEventId),
{
eventId: selectedEventId,
conversationId: newConversationId,
status: MicroagentStatus.WAITING,
},
]);
},
onEventCallback: (socketEvent: unknown, newConversationId: string) => {
handleMicroagentEvent(socketEvent, newConversationId);
},
});
};
return (
<>
{messages.map((message, index) => (
@@ -38,12 +235,50 @@ export const Messages: React.FC<MessagesProps> = React.memo(
hasObservationPair={actionHasObservationPair(message)}
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
isLastMessage={messages.length - 1 === index}
microagentStatus={getMicroagentStatusForEvent(message.id)}
microagentConversationId={getMicroagentConversationIdForEvent(
message.id,
)}
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
actions={
conversation?.selected_repository && !isV1Conversation
? [
{
icon: (
<MemoryIcon className="w-[14px] h-[14px] text-white" />
),
onClick: () => {
setSelectedEventId(message.id);
setShowLaunchMicroagentModal(true);
},
tooltip: t("MICROAGENT$ADD_TO_MEMORY"),
},
]
: undefined
}
isInLast10Actions={messages.length - 1 - index < 10}
/>
))}
{optimisticUserMessage && (
<ChatMessage type="user" message={optimisticUserMessage} />
)}
{conversation?.selected_repository &&
!isV1Conversation &&
showLaunchMicroagentModal &&
selectedEventId &&
createPortal(
<LaunchMicroagentModal
onClose={() => setShowLaunchMicroagentModal(false)}
onLaunch={handleLaunchMicroagent}
selectedRepo={
conversation.selected_repository.split("/").pop() || ""
}
eventId={selectedEventId}
isLoading={isPending}
/>,
document.getElementById("modal-portal-exit") || document.body,
)}
</>
);
},
@@ -0,0 +1,179 @@
import React from "react";
import { FaCircleInfo } from "react-icons/fa6";
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../../settings/brand-button";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { cn } from "#/utils/utils";
import CloseIcon from "#/icons/close.svg?react";
import { useMicroagentPrompt } from "#/hooks/query/use-microagent-prompt";
import { useHandleRuntimeActive } from "#/hooks/use-handle-runtime-active";
import { LoadingMicroagentBody } from "./loading-microagent-body";
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
import { Typography } from "#/ui/typography";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface LaunchMicroagentModalProps {
onClose: () => void;
onLaunch: (query: string, target: string, triggers: string[]) => void;
eventId: number;
isLoading: boolean;
selectedRepo: string;
}
export function LaunchMicroagentModal({
onClose,
onLaunch,
eventId,
isLoading,
selectedRepo,
}: LaunchMicroagentModalProps) {
const { t } = useTranslation();
const { runtimeActive } = useHandleRuntimeActive();
const { data: conversation } = useActiveConversation();
const { data: prompt, isLoading: promptIsLoading } =
useMicroagentPrompt(eventId);
const { data: microagents, isLoading: microagentsIsLoading } =
useGetMicroagents(`${selectedRepo}/.openhands/microagents`);
const [triggers, setTriggers] = React.useState<string[]>([]);
// TODO: Hide LaunchMicroagentModal for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
const formAction = (formData: FormData) => {
const query = formData.get("query-input")?.toString();
const target = formData.get("target-input")?.toString();
if (query && target) {
onLaunch(query, target, triggers);
}
};
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
formAction(formData);
};
return (
<ModalBackdrop onClose={onClose}>
{!runtimeActive && <LoadingMicroagentBody />}
{runtimeActive && (
<ModalBody className="items-start w-[728px]">
<div className="flex items-center justify-between w-full">
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
{t("MICROAGENT$ADD_TO_MICROAGENT")}
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
target="_blank"
rel="noopener noreferrer"
>
<FaCircleInfo className="text-primary" />
</a>
</h2>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
<Typography.Text className="text-sm text-[#A3A3A3] font-normal leading-5">
{t("MICROAGENT$DEFINITION")}
</Typography.Text>
<form
data-testid="launch-microagent-modal"
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
<label
htmlFor="query-input"
className="flex flex-col gap-2.5 w-full text-sm"
>
{t("MICROAGENT$WHAT_TO_REMEMBER")}
{promptIsLoading && <LoadingMicroagentTextarea />}
{!promptIsLoading && (
<textarea
required
data-testid="query-input"
name="query-input"
defaultValue={prompt}
placeholder={t("MICROAGENT$DESCRIBE_WHAT_TO_ADD")}
rows={6}
className={cn(
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
)}
</label>
<SettingsDropdownInput
testId="target-input"
name="target-input"
label={t("MICROAGENT$WHERE_TO_PUT")}
placeholder={t("MICROAGENT$SELECT_FILE_OR_CUSTOM")}
required
allowsCustomValue
isLoading={microagentsIsLoading}
items={
microagents?.map((item) => ({
key: item,
label: item,
})) || []
}
/>
<label
htmlFor="trigger-input"
className="flex flex-col gap-2.5 w-full text-sm"
>
<div className="flex items-center gap-2">
{t("MICROAGENT$ADD_TRIGGERS")}
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-keyword"
target="_blank"
rel="noopener noreferrer"
>
<FaCircleInfo className="text-primary" />
</a>
</div>
<BadgeInput
name="trigger-input"
value={triggers}
placeholder={t("MICROAGENT$TYPE_TRIGGER_SPACE")}
onChange={setTriggers}
/>
</label>
<div className="flex items-center justify-end gap-2">
<BrandButton type="button" variant="secondary" onClick={onClose}>
{t("MICROAGENT$CANCEL")}
</BrandButton>
<BrandButton
type="submit"
variant="primary"
isDisabled={
isLoading || promptIsLoading || microagentsIsLoading
}
>
{t("MICROAGENT$LAUNCH")}
</BrandButton>
</div>
</form>
</ModalBody>
)}
</ModalBackdrop>
);
}
@@ -0,0 +1,17 @@
import { Spinner } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { Typography } from "#/ui/typography";
export function LoadingMicroagentBody() {
const { t } = useTranslation();
return (
<ModalBody>
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
{t("MICROAGENT$ADD_TO_MICROAGENT")}
</h2>
<Spinner size="lg" />
<Typography.Text>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</Typography.Text>
</ModalBody>
);
}
@@ -0,0 +1,20 @@
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
export function LoadingMicroagentTextarea() {
const { t } = useTranslation();
return (
<textarea
required
disabled
defaultValue=""
placeholder={t("MICROAGENT$LOADING_PROMPT")}
rows={6}
className={cn(
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
);
}
@@ -0,0 +1,96 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { MicroagentStatus } from "#/types/microagent-status";
import { SuccessIndicator } from "../success-indicator";
import { Typography } from "#/ui/typography";
interface MicroagentStatusIndicatorProps {
status: MicroagentStatus;
conversationId?: string;
prUrl?: string;
}
export function MicroagentStatusIndicator({
status,
conversationId,
prUrl,
}: MicroagentStatusIndicatorProps) {
const { t } = useTranslation();
const getStatusText = () => {
switch (status) {
case MicroagentStatus.WAITING:
return t("MICROAGENT$STATUS_WAITING");
case MicroagentStatus.CREATING:
return t("MICROAGENT$STATUS_CREATING");
case MicroagentStatus.COMPLETED:
// If there's a PR URL, show "View your PR" instead of the default completed message
return prUrl
? t("MICROAGENT$VIEW_YOUR_PR")
: t("MICROAGENT$STATUS_COMPLETED");
case MicroagentStatus.ERROR:
return t("MICROAGENT$STATUS_ERROR");
default:
return "";
}
};
const getStatusIcon = () => {
switch (status) {
case MicroagentStatus.WAITING:
return <Spinner size="sm" />;
case MicroagentStatus.CREATING:
return <Spinner size="sm" />;
case MicroagentStatus.COMPLETED:
return <SuccessIndicator status="success" />;
case MicroagentStatus.ERROR:
return <SuccessIndicator status="error" />;
default:
return null;
}
};
const statusText = getStatusText();
const shouldShowAsLink = !!conversationId;
const shouldShowPRLink = !!prUrl;
const renderStatusText = () => {
if (shouldShowPRLink) {
return (
<a
href={prUrl}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{statusText}
</a>
);
}
if (shouldShowAsLink) {
return (
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{statusText}
</a>
);
}
return (
<Typography.Text className="underline">{statusText}</Typography.Text>
);
};
return (
<div className="flex items-center gap-2 mt-2 p-2 text-sm">
{getStatusIcon()}
{renderStatusText()}
</div>
);
}
@@ -0,0 +1,193 @@
import toast from "react-hot-toast";
import { Spinner } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
import CloseIcon from "#/icons/close.svg?react";
import { SuccessIndicator } from "../success-indicator";
interface ConversationCreatedToastProps {
conversationId: string;
onClose: () => void;
}
interface ConversationStartingToastProps {
conversationId: string;
onClose: () => void;
}
function ConversationCreatedToast({
conversationId,
onClose,
}: ConversationCreatedToastProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2">
<Spinner size="sm" />
<div>
{t("MICROAGENT$ADDING_CONTEXT")}
<br />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t("MICROAGENT$VIEW_CONVERSATION")}
</a>
</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
function ConversationStartingToast({
conversationId,
onClose,
}: ConversationStartingToastProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2">
<Spinner size="sm" />
<div>
{t("MICROAGENT$CONVERSATION_STARTING")}
<br />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t("MICROAGENT$VIEW_CONVERSATION")}
</a>
</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
interface ConversationFinishedToastProps {
conversationId: string;
onClose: () => void;
}
function ConversationFinishedToast({
conversationId,
onClose,
}: ConversationFinishedToastProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2">
<SuccessIndicator status="success" />
<div>
{t("MICROAGENT$SUCCESS_PR_READY")}
<br />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t("MICROAGENT$VIEW_CONVERSATION")}
</a>
</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
interface ConversationErroredToastProps {
errorMessage: string;
onClose: () => void;
}
function ConversationErroredToast({
errorMessage,
onClose,
}: ConversationErroredToastProps) {
const { t } = useTranslation();
// Check if the error message is a translation key
const displayMessage =
errorMessage === "MICROAGENT$UNKNOWN_ERROR"
? t(errorMessage)
: errorMessage;
return (
<div className="flex items-start gap-2">
<SuccessIndicator status="error" />
<div>{displayMessage}</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
export const renderConversationCreatedToast = (conversationId: string) =>
toast(
(t) => (
<ConversationCreatedToast
conversationId={conversationId}
onClose={() => toast.dismiss(t.id)}
/>
),
{
...TOAST_OPTIONS,
id: `status-${conversationId}`,
duration: 5000,
},
);
export const renderConversationFinishedToast = (conversationId: string) =>
toast(
(t) => (
<ConversationFinishedToast
conversationId={conversationId}
onClose={() => toast.dismiss(t.id)}
/>
),
{
...TOAST_OPTIONS,
id: `status-${conversationId}`,
duration: 5000,
},
);
export const renderConversationErroredToast = (
conversationId: string,
errorMessage: string,
) =>
toast(
(t) => (
<ConversationErroredToast
errorMessage={errorMessage}
onClose={() => toast.dismiss(t.id)}
/>
),
{
...TOAST_OPTIONS,
id: `status-${conversationId}`,
duration: 5000,
},
);
export const renderConversationStartingToast = (conversationId: string) =>
toast(
(toastInstance) => (
<ConversationStartingToast
conversationId={conversationId}
onClose={() => toast.dismiss(toastInstance.id)}
/>
),
{
...TOAST_OPTIONS,
id: `starting-${conversationId}`,
duration: 10000, // Show for 10 seconds or until dismissed
},
);
@@ -1,6 +1,6 @@
import React from "react";
import { cn } from "#/utils/utils";
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { ConversationStatus } from "#/types/conversation-status";
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;
sandboxStatus?: V1SandboxStatus;
conversationStatus?: ConversationStatus;
conversationId?: string;
showOptions?: boolean;
}
@@ -25,11 +25,11 @@ export function ConversationCardActions({
onEdit,
onDownloadViaVSCode,
onDownloadConversation,
sandboxStatus,
conversationStatus,
conversationId,
showOptions,
}: ConversationCardActionsProps) {
const isConversationStopped = sandboxStatus === "STOPPED";
const isConversationArchived = conversationStatus === "ARCHIVED";
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",
isConversationStopped && "opacity-60",
isConversationArchived && "opacity-60",
)}
>
<EllipsisIcon />
@@ -60,7 +60,8 @@ export function ConversationCardActions({
onClose={() => onContextMenuToggle(false)}
onDelete={onDelete}
onStop={
sandboxStatus === "RUNNING" || sandboxStatus === "STARTING"
conversationStatus === "RUNNING" ||
conversationStatus === "STARTING"
? onStop
: undefined
}
@@ -3,16 +3,16 @@ 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
sandboxStatus?: V1SandboxStatus;
conversationStatus?: ConversationStatus;
llmModel?: string | null;
}
@@ -20,13 +20,12 @@ export function ConversationCardFooter({
selectedRepository,
lastUpdatedAt,
createdAt,
sandboxStatus,
conversationStatus,
llmModel,
}: ConversationCardFooterProps) {
const { t } = useTranslation();
const isConversationArchived =
sandboxStatus === "STOPPED" || sandboxStatus === "MISSING";
const isConversationArchived = conversationStatus === "ARCHIVED";
return (
<div
@@ -1,37 +1,51 @@
import { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { ConversationStatus } from "#/types/conversation-status";
import { ConversationCardTitle } from "./conversation-card-title";
import { SandboxStatusIndicator } from "../../home/recent-conversations/sandbox-status-indicator";
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
import { ConversationStatusBadges } from "./conversation-status-badges";
import { ConversationVersionBadge } from "./conversation-version-badge";
interface ConversationCardHeaderProps {
title: string;
titleMode: "view" | "edit";
onTitleSave: (title: string) => void;
sandboxStatus?: V1SandboxStatus;
conversationStatus?: ConversationStatus;
conversationVersion?: "V0" | "V1";
}
export function ConversationCardHeader({
title,
titleMode,
onTitleSave,
sandboxStatus,
conversationStatus,
conversationVersion,
}: ConversationCardHeaderProps) {
const isConversationArchived =
sandboxStatus === "STOPPED" || sandboxStatus === "MISSING";
const isConversationArchived = conversationStatus === "ARCHIVED";
return (
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{/* Status Indicator - use V1 sandbox status directly */}
{sandboxStatus && (
{/* Status Indicator */}
{conversationStatus && (
<div className="flex items-center">
<SandboxStatusIndicator sandboxStatus={sandboxStatus} />
<ConversationStatusIndicator
conversationStatus={conversationStatus}
/>
</div>
)}
{/* Version Badge */}
<ConversationVersionBadge
version={conversationVersion}
isConversationArchived={isConversationArchived}
/>
<ConversationCardTitle
title={title}
titleMode={titleMode}
onSave={onTitleSave}
isConversationArchived={isConversationArchived}
/>
{/* Status Badges */}
{conversationStatus && (
<ConversationStatusBadges conversationStatus={conversationStatus} />
)}
</div>
);
}
@@ -3,12 +3,11 @@ 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 { V1SandboxStatus } from "#/api/sandbox-service/sandbox-service.types";
import { ConversationStatus } from "#/types/conversation-status";
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 {
@@ -21,8 +20,9 @@ interface ConversationCardProps {
selectedRepository: RepositorySelection | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
sandboxStatus?: V1SandboxStatus;
conversationStatus?: ConversationStatus;
conversationId?: string; // Optional conversation ID for VS Code URL
conversationVersion?: "V0" | "V1";
contextMenuOpen?: boolean;
onContextMenuToggle?: (isOpen: boolean) => void;
llmModel?: string | null;
@@ -41,7 +41,8 @@ export function ConversationCard({
lastUpdatedAt,
createdAt,
conversationId,
sandboxStatus,
conversationStatus,
conversationVersion,
contextMenuOpen = false,
onContextMenuToggle,
llmModel,
@@ -110,7 +111,7 @@ export function ConversationCard({
event.preventDefault();
event.stopPropagation();
if (conversationId) {
if (conversationId && conversationVersion === "V1") {
await downloadConversation(conversationId);
}
onContextMenuToggle?.(false);
@@ -129,15 +130,13 @@ export function ConversationCard({
)}
>
<div className="flex items-center justify-between w-full">
<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>
<ConversationCardHeader
title={title}
titleMode={titleMode}
onTitleSave={onTitleSave}
conversationStatus={conversationStatus}
conversationVersion={conversationVersion}
/>
{hasContextMenu && (
<ConversationCardActions
@@ -147,8 +146,12 @@ export function ConversationCard({
onStop={onStop && handleStop}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={handleDownloadViaVSCode}
onDownloadConversation={handleDownloadConversation}
sandboxStatus={sandboxStatus}
onDownloadConversation={
conversationVersion === "V1"
? handleDownloadConversation
: undefined
}
conversationStatus={conversationStatus}
conversationId={conversationId}
showOptions={showOptions}
/>
@@ -159,7 +162,7 @@ export function ConversationCard({
selectedRepository={selectedRepository}
lastUpdatedAt={lastUpdatedAt}
createdAt={createdAt}
sandboxStatus={sandboxStatus}
conversationStatus={conversationStatus}
llmModel={llmModel}
/>
</div>
@@ -1,26 +0,0 @@
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 (V1 uses 'items' instead of 'results')
const conversations = data?.pages.flatMap((page) => page.items) ?? [];
// Flatten all pages into a single array of conversations
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: pauseConversationSandbox } =
@@ -176,41 +176,42 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
</NavLink>
))}
{/* Then render completed conversations */}
{conversations?.map((conversation) => (
{conversations?.map((project) => (
<NavLink
key={conversation.id}
to={`/conversations/${conversation.id}`}
key={project.conversation_id}
to={`/conversations/${project.conversation_id}`}
onClick={onClose}
>
<ConversationCard
onDelete={() =>
handleDeleteProject(conversation.id, conversation.title ?? "")
handleDeleteProject(project.conversation_id, project.title)
}
onStop={() =>
handleStopConversation(
conversation.id,
"V1",
conversation.sandbox_id,
project.conversation_id,
project.conversation_version,
project.sandbox_id,
)
}
onChangeTitle={(title) =>
handleConversationTitleChange(conversation.id, title)
handleConversationTitleChange(project.conversation_id, title)
}
title={conversation.title ?? ""}
title={project.title}
selectedRepository={{
selected_repository: conversation.selected_repository,
selected_branch: conversation.selected_branch,
git_provider: conversation.git_provider as Provider,
selected_repository: project.selected_repository,
selected_branch: project.selected_branch,
git_provider: project.git_provider as Provider,
}}
lastUpdatedAt={conversation.updated_at}
createdAt={conversation.created_at}
sandboxStatus={conversation.sandbox_status}
conversationId={conversation.id}
contextMenuOpen={openContextMenuId === conversation.id}
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}
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? conversation.id : null)
setOpenContextMenuId(isOpen ? project.conversation_id : null)
}
llmModel={conversation.llm_model}
llmModel={project.llm_model}
/>
</NavLink>
))}
@@ -0,0 +1,163 @@
import React from "react";
import hotToast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { Feedback } from "#/api/open-hands.types";
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
import { BrandButton } from "../settings/brand-button";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
const FEEDBACK_VERSION = "1.0";
const VIEWER_PAGE = "https://www.all-hands.dev/share";
interface FeedbackFormProps {
onClose: () => void;
polarity: "positive" | "negative";
}
export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const copiedToClipboardToast = () => {
hotToast(t(I18nKey.FEEDBACK$PASSWORD_COPIED_MESSAGE), {
icon: "📋",
position: "bottom-right",
});
};
const onPressToast = (password: string) => {
navigator.clipboard.writeText(password);
copiedToClipboardToast();
};
const shareFeedbackToast = (
message: string,
link: string,
password: string,
) => {
hotToast(
<div className="flex flex-col gap-1">
<span>{message}</span>
<a
data-testid="toast-share-url"
className="text-blue-500 underline"
onClick={() => onPressToast(password)}
href={link}
target="_blank"
rel="noreferrer"
>
{t(I18nKey.FEEDBACK$GO_TO_FEEDBACK)}
</a>
<span onClick={() => onPressToast(password)} className="cursor-pointer">
{t(I18nKey.FEEDBACK$PASSWORD)}: {password}{" "}
<span className="text-gray-500">
({t(I18nKey.FEEDBACK$COPY_LABEL)})
</span>
</span>
</div>,
{ duration: 10000 },
);
};
const { mutate: submitFeedback, isPending } = useSubmitFeedback();
// TODO: Hide FeedbackForm for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const formData = new FormData(event.currentTarget);
const email = formData.get("email")?.toString() || "";
const permissions = (formData.get("permissions")?.toString() ||
"private") as "private" | "public";
const feedback: Feedback = {
version: FEEDBACK_VERSION,
email,
polarity,
permissions,
trajectory: [],
token: "",
};
submitFeedback(
{ feedback },
{
onSuccess: (data) => {
const { message, feedback_id, password } = data.body; // eslint-disable-line
const link = `${VIEWER_PAGE}?share_id=${feedback_id}`;
shareFeedbackToast(message, link, password);
onClose();
},
},
);
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6 w-full">
<label className="flex flex-col gap-2">
<span className="text-xs text-neutral-400">
{t(I18nKey.FEEDBACK$EMAIL_LABEL)}
</span>
<input
required
name="email"
type="email"
placeholder={t(I18nKey.FEEDBACK$EMAIL_PLACEHOLDER)}
className="bg-[#27272A] px-3 py-[10px] rounded-sm"
/>
</label>
<div className="flex gap-4 text-neutral-400">
<label className="flex gap-2 cursor-pointer">
<input
name="permissions"
value="private"
type="radio"
defaultChecked
/>
{t(I18nKey.FEEDBACK$PRIVATE_LABEL)}
</label>
<label className="flex gap-2 cursor-pointer">
<input name="permissions" value="public" type="radio" />
{t(I18nKey.FEEDBACK$PUBLIC_LABEL)}
</label>
</div>
<div className="flex gap-2">
<BrandButton
type="submit"
variant="primary"
className="grow"
isDisabled={isPending}
>
{isPending
? t(I18nKey.FEEDBACK$SUBMITTING_LABEL)
: t(I18nKey.FEEDBACK$SHARE_LABEL)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={onClose}
isDisabled={isPending}
>
{t(I18nKey.FEEDBACK$CANCEL_LABEL)}
</BrandButton>
</div>
{isPending && (
<p className="text-sm text-center text-neutral-400">
{t(I18nKey.FEEDBACK$SUBMITTING_MESSAGE)}
</p>
)}
</form>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import {
BaseModalTitle,
BaseModalDescription,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { FeedbackForm } from "./feedback-form";
interface FeedbackModalProps {
onClose: () => void;
isOpen: boolean;
polarity: "positive" | "negative";
}
export function FeedbackModal({
onClose,
isOpen,
polarity,
}: FeedbackModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<ModalBackdrop onClose={onClose}>
<ModalBody className="border border-tertiary">
<BaseModalTitle title={t(I18nKey.FEEDBACK$TITLE)} />
<BaseModalDescription description={t(I18nKey.FEEDBACK$DESCRIPTION)} />
<FeedbackForm onClose={onClose} polarity={polarity} />
</ModalBody>
</ModalBackdrop>
);
}
@@ -0,0 +1,271 @@
import React, { useState, useEffect, useContext } from "react";
import { useTranslation } from "react-i18next";
import { FaStar } from "react-icons/fa";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { useSubmitConversationFeedback } from "#/hooks/mutation/use-submit-conversation-feedback";
import { ScrollContext } from "#/context/scroll-context";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
// Global timeout duration in milliseconds
const AUTO_SUBMIT_TIMEOUT = 10000;
interface LikertScaleProps {
eventId?: number;
initiallySubmitted?: boolean;
initialRating?: number;
initialReason?: string;
}
export function LikertScale({
eventId,
initiallySubmitted = false,
initialRating,
initialReason,
}: LikertScaleProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const [selectedRating, setSelectedRating] = useState<number | null>(
initialRating || null,
);
const [selectedReason, setSelectedReason] = useState<string | null>(
initialReason || null,
);
const [showReasons, setShowReasons] = useState(false);
const [reasonTimeout, setReasonTimeout] = useState<NodeJS.Timeout | null>(
null,
);
const [isSubmitted, setIsSubmitted] = useState(initiallySubmitted);
const [countdown, setCountdown] = useState<number>(0);
// Get scroll context
const scrollContext = useContext(ScrollContext);
// Define feedback reasons using the translation hook
const FEEDBACK_REASONS = [
t(I18nKey.FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION),
t(I18nKey.FEEDBACK$REASON_FORGOT_CONTEXT),
t(I18nKey.FEEDBACK$REASON_UNNECESSARY_CHANGES),
t(I18nKey.FEEDBACK$REASON_SHOULD_ASK_FIRST),
t(I18nKey.FEEDBACK$REASON_DIDNT_FINISH_JOB),
t(I18nKey.FEEDBACK$REASON_OTHER),
];
// If scrollContext is undefined, we're not inside a ScrollProvider
const scrollToBottom = scrollContext?.scrollDomToBottom;
const autoScroll = scrollContext?.autoScroll;
// Use our mutation hook
const { mutate: submitConversationFeedback } =
useSubmitConversationFeedback();
// Update isSubmitted if initiallySubmitted changes
useEffect(() => {
setIsSubmitted(initiallySubmitted);
}, [initiallySubmitted]);
// Update selectedRating if initialRating changes
useEffect(() => {
if (initialRating) {
setSelectedRating(initialRating);
}
}, [initialRating]);
// Update selectedReason if initialReason changes
useEffect(() => {
if (initialReason) {
setSelectedReason(initialReason);
}
}, [initialReason]);
// Countdown effect
useEffect(() => {
if (countdown > 0 && showReasons && !isSubmitted) {
const timer = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
return () => clearTimeout(timer);
}
return () => {};
}, [countdown, showReasons, isSubmitted]);
// Clean up timeout on unmount
useEffect(
() => () => {
if (reasonTimeout) {
clearTimeout(reasonTimeout);
}
},
[reasonTimeout],
);
// Scroll to bottom when component mounts, but only if user is already at the bottom
useEffect(() => {
if (scrollToBottom && autoScroll && !isSubmitted) {
// Small delay to ensure the component is fully rendered
setTimeout(() => {
scrollToBottom();
}, 100);
}
}, [scrollToBottom, autoScroll, isSubmitted]);
// Scroll to bottom when reasons are shown, but only if user is already at the bottom
useEffect(() => {
if (scrollToBottom && autoScroll && showReasons) {
// Small delay to ensure the reasons are fully rendered
setTimeout(() => {
scrollToBottom();
}, 100);
}
}, [scrollToBottom, autoScroll, showReasons]);
// TODO: Hide LikertScale for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
// Submit feedback and disable the component
const submitFeedback = (rating: number, reason?: string) => {
submitConversationFeedback(
{
rating,
eventId,
reason,
},
{
onSuccess: () => {
setSelectedReason(reason || null);
setShowReasons(false);
setIsSubmitted(true);
},
},
);
};
// Handle star rating selection
const handleRatingClick = (rating: number) => {
if (isSubmitted) return; // Prevent changes after submission
setSelectedRating(rating);
// Only show reasons if rating is 3 or less (1, 2, or 3 stars)
// For ratings > 3 (4 or 5 stars), submit immediately without showing reasons
if (rating <= 3) {
setShowReasons(true);
setCountdown(Math.ceil(AUTO_SUBMIT_TIMEOUT / 1000));
// Set a timeout to auto-submit if no reason is selected
const timeout = setTimeout(() => {
submitFeedback(rating);
}, AUTO_SUBMIT_TIMEOUT);
setReasonTimeout(timeout);
// Only scroll to bottom if the user is already at the bottom (autoScroll is true)
if (scrollToBottom && autoScroll) {
// Small delay to ensure the reasons are fully rendered
setTimeout(() => {
scrollToBottom();
}, 100);
}
} else {
// For ratings > 3 (4 or 5 stars), submit immediately without showing reasons
setShowReasons(false);
submitFeedback(rating);
}
};
// Handle reason selection
const handleReasonClick = (reason: string) => {
if (selectedRating && reasonTimeout && !isSubmitted) {
clearTimeout(reasonTimeout);
setCountdown(0);
submitFeedback(selectedRating, reason);
}
};
// Helper function to get button class based on state
const getButtonClass = (rating: number) => {
if (isSubmitted) {
return selectedRating && selectedRating >= rating
? "text-yellow-400 cursor-not-allowed"
: "text-gray-300 opacity-50 cursor-not-allowed";
}
return selectedRating && selectedRating >= rating
? "text-yellow-400"
: "text-gray-300";
};
return (
<div className="mt-3 flex flex-col gap-1">
<div className="text-sm text-gray-500 mb-1">
{isSubmitted
? t(I18nKey.FEEDBACK$THANK_YOU_FOR_FEEDBACK)
: t(I18nKey.FEEDBACK$RATE_AGENT_PERFORMANCE)}
</div>
<div className="flex flex-col gap-1">
<span className="flex gap-2 items-center flex-wrap">
{[1, 2, 3, 4, 5].map((rating) => (
<button
type="button"
key={rating}
onClick={() => handleRatingClick(rating)}
disabled={isSubmitted}
className={cn(
"oh-star text-xl transition-all",
getButtonClass(rating),
!isSubmitted &&
"hover:text-yellow-400 [&:has(~.oh-star:hover)]:text-yellow-400",
)}
aria-label={`Rate ${rating} stars`}
>
<FaStar />
</button>
))}
{/* Show selected reason inline with stars when submitted (only for ratings <= 3) */}
{isSubmitted &&
selectedReason &&
selectedRating &&
selectedRating <= 3 && (
<span className="text-sm text-gray-500 italic">
{selectedReason}
</span>
)}
</span>
</div>
{showReasons && !isSubmitted && (
<div className="mt-1 flex flex-col gap-1">
<div className="text-xs text-gray-500 mb-1">
{t(I18nKey.FEEDBACK$SELECT_REASON)}
</div>
{countdown > 0 && (
<div className="text-xs text-gray-400 mb-1 italic">
{t(I18nKey.FEEDBACK$SELECT_REASON_COUNTDOWN, {
countdown,
})}
</div>
)}
<div className="flex flex-col gap-0.5">
{FEEDBACK_REASONS.map((reason) => (
<button
type="button"
key={reason}
onClick={() => handleReasonClick(reason)}
className="text-sm text-left py-1 px-2 rounded hover:bg-gray-700 transition-colors"
>
{reason}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -1,17 +1,17 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import CodeBranchIcon from "#/icons/u-code-branch.svg?react";
import { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
import { Conversation } from "#/api/open-hands.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 { SandboxStatusIndicator } from "./sandbox-status-indicator";
import { ConversationStatusIndicator } from "./conversation-status-indicator";
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
import CircuitIcon from "#/icons/u-circuit.svg?react";
interface RecentConversationProps {
conversation: V1AppConversation;
conversation: Conversation;
}
export function RecentConversation({ conversation }: RecentConversationProps) {
@@ -22,11 +22,11 @@ export function RecentConversation({ conversation }: RecentConversationProps) {
return (
<Link
to={`/conversations/${conversation.id}`}
to={`/conversations/${conversation.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">
<SandboxStatusIndicator sandboxStatus={conversation.sandbox_status} />
<ConversationStatusIndicator conversationStatus={conversation.status} />
<span className="text-xs text-white leading-6 font-normal">
{conversation.title}
</span>
@@ -76,10 +76,10 @@ export function RecentConversation({ conversation }: RecentConversationProps) {
<span className="truncate">{conversation.llm_model}</span>
</span>
)}
{(conversation.created_at || conversation.updated_at) && (
{(conversation.created_at || conversation.last_updated_at) && (
<span>
{formatTimeDelta(
conversation.created_at || conversation.updated_at,
conversation.created_at || conversation.last_updated_at,
)}{" "}
{t(I18nKey.CONVERSATION$AGO)}
</span>
@@ -29,7 +29,7 @@ export function RecentConversations() {
});
const conversations =
conversationsList?.pages.flatMap((page) => page.items) ?? [];
conversationsList?.pages.flatMap((page) => page.results) ?? [];
// 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.id}
key={conversation.conversation_id}
conversation={conversation}
/>
))}
@@ -1,65 +0,0 @@
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>
);
}
@@ -6,7 +6,6 @@ import { ul, ol } from "./list";
import { paragraph } from "./paragraph";
import { anchor } from "./anchor";
import { h1, h2, h3, h4, h5, h6 } from "./headings";
import { table, th, td } from "./table";
interface MarkdownRendererProps {
/**
@@ -56,9 +55,6 @@ export function MarkdownRenderer({
code,
ul,
ol,
table,
th,
td,
...(includeStandard && {
a: anchor,
p: paragraph,
@@ -1,43 +0,0 @@
import React from "react";
import { ExtraProps } from "react-markdown";
// Custom component to render <table> in markdown
export function table({
children,
}: React.ClassAttributes<HTMLTableElement> &
React.TableHTMLAttributes<HTMLTableElement> &
ExtraProps) {
return (
<div className="my-4 w-full overflow-x-auto">
<table className="w-full border-collapse border border-neutral-600 text-sm">
{children}
</table>
</div>
);
}
// Custom component to render <th> in markdown
export function th({
children,
}: React.ClassAttributes<HTMLTableCellElement> &
React.ThHTMLAttributes<HTMLTableCellElement> &
ExtraProps) {
return (
<th className="border border-neutral-600 bg-neutral-800 px-3 py-2 text-left font-semibold text-white">
{children}
</th>
);
}
// Custom component to render <td> in markdown
export function td({
children,
}: React.ClassAttributes<HTMLTableCellElement> &
React.TdHTMLAttributes<HTMLTableCellElement> &
ExtraProps) {
return (
<td className="border border-neutral-600 px-3 py-2 align-top">
{children}
</td>
);
}
@@ -0,0 +1,31 @@
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { GitRepository } from "#/types/git";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
interface MicroagentManagementAccordionTitleProps {
repository: GitRepository;
}
export function MicroagentManagementAccordionTitle({
repository,
}: MicroagentManagementAccordionTitleProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitProviderIcon gitProvider={repository.git_provider} />
<StyledTooltip content={repository.full_name} placement="bottom">
<span
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[194px] translate-y-[-1px]"
data-testid="repository-name-tooltip"
>
{repository.full_name}
</span>
</StyledTooltip>
</div>
<MicroagentManagementAddMicroagentButton repository={repository} />
</div>
);
}
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
import { GitRepository } from "#/types/git";
interface MicroagentManagementAddMicroagentButtonProps {
repository: GitRepository;
}
export function MicroagentManagementAddMicroagentButton({
repository,
}: MicroagentManagementAddMicroagentButtonProps) {
const { t } = useTranslation();
const {
addMicroagentModalVisible,
setAddMicroagentModalVisible,
setSelectedRepository,
} = useMicroagentManagementStore();
const handleClick = (e: React.MouseEvent<HTMLSpanElement>) => {
e.stopPropagation();
e.preventDefault();
setAddMicroagentModalVisible(!addMicroagentModalVisible);
setSelectedRepository(repository);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.preventDefault();
setAddMicroagentModalVisible(!addMicroagentModalVisible);
setSelectedRepository(repository);
}
};
return (
<span
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className="translate-y-[-1px] text-sm font-normal leading-5 text-[#8480FF] cursor-pointer hover:text-[#6C63FF] transition-colors duration-200"
data-testid="add-microagent-button"
>
{t(I18nKey.COMMON$ADD_MICROAGENT)}
</span>
);
}
@@ -0,0 +1,338 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
import { MicroagentManagementMain } from "./microagent-management-main";
import { MicroagentManagementUpsertMicroagentModal } from "./microagent-management-upsert-microagent-modal";
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import {
LearnThisRepoFormData,
MicroagentFormData,
} from "#/types/microagent-management";
import { AgentState } from "#/types/agent-state";
import { getPR, getProviderName, getPRShort } from "#/utils/utils";
import {
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { GitRepository } from "#/types/git";
import { queryClient } from "#/query-client-config";
import { Provider } from "#/types/settings";
import { MicroagentManagementLearnThisRepoModal } from "./microagent-management-learn-this-repo-modal";
import {
displaySuccessToast,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useBreakpoint } from "#/hooks/use-breakpoint";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
const shouldInvalidateConversationsList = (currentSocketEvent: unknown) => {
const hasError =
isErrorEvent(currentSocketEvent) || isAgentStatusError(currentSocketEvent);
const hasStateChanged =
isOpenHandsEvent(currentSocketEvent) &&
isAgentStateChangeObservation(currentSocketEvent);
const hasFinished =
isOpenHandsEvent(currentSocketEvent) && isFinishAction(currentSocketEvent);
return hasError || hasStateChanged || hasFinished;
};
const getConversationInstructions = (
repositoryName: string,
formData: MicroagentFormData,
pr: string,
prShort: string,
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered). This is the instructions about what the microagent should do: ${formData.query}. ${
formData.triggers && formData.triggers.length > 0
? `This is the triggers of the microagent: ${formData.triggers.join(", ")}`
: "Please be noted that the microagent doesn't have any triggers."
}
- Step 2: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
- Step 3: Please push the changes to your branch on ${getProviderName(gitProvider)} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.
`;
const getUpdateConversationInstructions = (
repositoryName: string,
formData: MicroagentFormData,
pr: string,
prShort: string,
gitProvider: Provider,
) => `Update the microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Update the microagent. This is the path of the microagent: ${formData.microagentPath} (The updated microagent must be in the .openhands/microagents folder and should be able to perform the described task when triggered). This is the updated instructions about what the microagent should do: ${formData.query}. ${
formData.triggers && formData.triggers.length > 0
? `This is the triggers of the microagent: ${formData.triggers.join(", ")}`
: "Please be noted that the microagent doesn't have any triggers."
}
- Step 2: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
- Step 3: Please push the changes to your branch on ${getProviderName(gitProvider)} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.
`;
export function MicroagentManagementContent() {
const isMobile = useBreakpoint();
const {
addMicroagentModalVisible,
updateMicroagentModalVisible,
selectedRepository,
learnThisRepoModalVisible,
setAddMicroagentModalVisible,
setUpdateMicroagentModalVisible,
setLearnThisRepoModalVisible,
} = useMicroagentManagementStore();
const { providers } = useUserProviders();
const { t } = useTranslation();
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
const hideUpsertMicroagentModal = (isUpdate: boolean = false) => {
if (isUpdate) {
setUpdateMicroagentModalVisible(false);
} else {
setAddMicroagentModalVisible(false);
}
};
// Reusable function to invalidate conversations list for a repository
const invalidateConversationsList = React.useCallback(
(repositoryName: string) => {
queryClient.invalidateQueries({
queryKey: [
"conversations",
"search",
repositoryName,
"microagent_management",
],
});
},
[],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown) => {
// Get repository name from selectedRepository for invalidation
const repositoryName =
selectedRepository && typeof selectedRepository === "object"
? (selectedRepository as GitRepository).full_name
: "";
// Check if agent is running and ready to work
if (
isOpenHandsEvent(socketEvent) &&
isAgentStateChangeObservation(socketEvent) &&
socketEvent.extras.agent_state === AgentState.RUNNING
) {
displaySuccessToast(
t(I18nKey.MICROAGENT_MANAGEMENT$OPENING_PR_TO_CREATE_MICROAGENT),
);
}
// Check if agent has finished and we have a PR
if (isOpenHandsEvent(socketEvent) && isFinishAction(socketEvent)) {
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
if (!prUrl) {
// Agent finished but no PR found
displaySuccessToast(t(I18nKey.MICROAGENT_MANAGEMENT$PR_NOT_CREATED));
}
}
// Handle error events
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
displayErrorToast(
t(I18nKey.MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT),
);
}
if (shouldInvalidateConversationsList(socketEvent)) {
invalidateConversationsList(repositoryName);
}
},
[invalidateConversationsList, selectedRepository],
);
const handleUpsertMicroagent = (
formData: MicroagentFormData,
isUpdate: boolean = false,
) => {
if (!selectedRepository || typeof selectedRepository !== "object") {
return;
}
// Use the GitRepository properties
const repository = selectedRepository as GitRepository;
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const isGitLab = gitProvider === "gitlab";
const pr = getPR(isGitLab);
const prShort = getPRShort(isGitLab);
// Create conversation instructions for microagent generation or update
const conversationInstructions = isUpdate
? getUpdateConversationInstructions(
repositoryName,
formData,
pr,
prShort,
gitProvider,
)
: getConversationInstructions(
repositoryName,
formData,
pr,
prShort,
gitProvider,
);
// Create the CreateMicroagent object
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
createConversationAndSubscribe({
query: conversationInstructions,
conversationInstructions,
repository: {
name: repositoryName,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
// Invalidate conversations list to fetch the latest conversations for this repository
invalidateConversationsList(repositoryName);
// Also invalidate microagents list to fetch the latest microagents
// Extract owner and repo from full_name (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
queryClient.invalidateQueries({
queryKey: ["repository-microagents", owner, repo],
});
hideUpsertMicroagentModal(isUpdate);
},
onEventCallback: (event: unknown) => {
// Handle conversation events for real-time status updates
handleMicroagentEvent(event);
},
});
};
const hideLearnThisRepoModal = () => {
setLearnThisRepoModalVisible(false);
};
const handleLearnThisRepoConfirm = (formData: LearnThisRepoFormData) => {
if (!selectedRepository || typeof selectedRepository !== "object") {
return;
}
const repository = selectedRepository as GitRepository;
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
// Launch a new conversation to help the user understand the repo
createConversationAndSubscribe({
query: formData.query,
conversationInstructions: formData.query,
repository: {
name: repositoryName,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideLearnThisRepoModal();
},
});
};
const renderModals = () => (
<>
{(addMicroagentModalVisible || updateMicroagentModalVisible) && (
<MicroagentManagementUpsertMicroagentModal
onConfirm={(formData) =>
handleUpsertMicroagent(formData, updateMicroagentModalVisible)
}
onCancel={() =>
hideUpsertMicroagentModal(updateMicroagentModalVisible)
}
isLoading={isPending}
isUpdate={updateMicroagentModalVisible}
/>
)}
{learnThisRepoModalVisible && (
<MicroagentManagementLearnThisRepoModal
onCancel={hideLearnThisRepoModal}
onConfirm={handleLearnThisRepoConfirm}
isLoading={isPending}
/>
)}
</>
);
const providersAreSet = providers.length > 0;
if (isMobile) {
return (
<div className="w-full h-full flex flex-col gap-6">
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
{providersAreSet && (
<MicroagentManagementSidebar
isSmallerScreen
providers={providers}
/>
)}
</div>
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
<MicroagentManagementMain />
</div>
{renderModals()}
</div>
);
}
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
{providersAreSet && <MicroagentManagementSidebar providers={providers} />}
<div className="flex-1">
<MicroagentManagementMain />
</div>
{renderModals()}
</div>
);
}
@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
export function MicroagentManagementConversationStopped() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useMicroagentManagementStore();
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}
@@ -0,0 +1,19 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementDefault() {
const { t } = useTranslation();
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
export function MicroagentManagementError() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useMicroagentManagementStore();
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}
@@ -0,0 +1,149 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FaCircleInfo } from "react-icons/fa6";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
import XIcon from "#/icons/x.svg?react";
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
import { LearnThisRepoFormData } from "#/types/microagent-management";
interface MicroagentManagementLearnThisRepoModalProps {
onConfirm: (formData: LearnThisRepoFormData) => void;
onCancel: () => void;
isLoading: boolean;
}
export function MicroagentManagementLearnThisRepoModal({
onConfirm,
onCancel,
isLoading = false,
}: MicroagentManagementLearnThisRepoModalProps) {
const { t } = useTranslation();
const [query, setQuery] = useState<string>("");
const { selectedRepository } = useMicroagentManagementStore();
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const finalQuery = getRepoMdCreatePrompt(
selectedRepository?.git_provider || "github",
query.trim(),
);
onConfirm({
query: finalQuery,
});
};
const handleConfirm = () => {
const finalQuery = getRepoMdCreatePrompt(
selectedRepository?.git_provider || "github",
query.trim(),
);
onConfirm({
query: finalQuery,
});
};
return (
<ModalBackdrop onClose={onCancel}>
<ModalBody
className="items-start rounded-[12px] p-6 min-w-[611px]"
data-testid="learn-this-repo-modal"
>
<div className="flex flex-col gap-2 w-full">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h2
className="text-white text-xl font-medium"
data-testid="modal-title"
>
{t(I18nKey.MICROAGENT_MANAGEMENT$LEARN_THIS_REPO_MODAL_TITLE)}
</h2>
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
target="_blank"
rel="noopener noreferrer"
data-testid="modal-info-link"
>
<FaCircleInfo className="text-primary" />
</a>
</div>
<button
type="button"
onClick={onCancel}
className="cursor-pointer"
data-testid="modal-close-button"
>
<XIcon width={24} height={24} color="#F9FBFE" />
</button>
</div>
<span
className="text-white text-sm font-normal"
data-testid="modal-description"
>
{t(I18nKey.MICROAGENT_MANAGEMENT$LEARN_THIS_REPO_MODAL_DESCRIPTION)}
</span>
</div>
<form
data-testid="learn-this-repo-form"
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
<label
htmlFor="query-input"
className="flex flex-col gap-2 w-full text-sm font-normal"
>
{t(
I18nKey.MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO,
)}
<textarea
required
data-testid="query-input"
name="query-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(
I18nKey.MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO,
)}
rows={6}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</label>
</form>
<div
className="flex items-center justify-end gap-2 w-full"
onClick={(event) => event.stopPropagation()}
data-testid="modal-actions"
>
<BrandButton
type="button"
variant="secondary"
onClick={onCancel}
testId="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={handleConfirm}
testId="confirm-button"
isDisabled={isLoading}
>
{isLoading ? t(I18nKey.HOME$LOADING) : t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
import { GitRepository } from "#/types/git";
interface MicroagentManagementLearnThisRepoProps {
repository: GitRepository;
}
export function MicroagentManagementLearnThisRepo({
repository,
}: MicroagentManagementLearnThisRepoProps) {
const { setLearnThisRepoModalVisible, setSelectedRepository } =
useMicroagentManagementStore();
const { t } = useTranslation();
const handleClick = () => {
setLearnThisRepoModalVisible(true);
setSelectedRepository(repository);
};
return (
<div
className="flex items-center justify-center rounded-lg bg-[#ffffff0d] border border-dashed border-[#ffffff4d] p-4 hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300 cursor-pointer"
onClick={handleClick}
data-testid="learn-this-repo-trigger"
>
<span className="text-[16px] font-normal text-[#8480FF]">
{t(I18nKey.MICROAGENT_MANAGEMENT$LEARN_THIS_REPO)}
</span>
</div>
);
}
@@ -0,0 +1,49 @@
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
import { MicroagentManagementDefault } from "./microagent-management-default";
import { MicroagentManagementOpeningPr } from "./microagent-management-opening-pr";
import { MicroagentManagementReviewPr } from "./microagent-management-review-pr";
import { MicroagentManagementViewMicroagent } from "./microagent-management-view-microagent";
import { MicroagentManagementError } from "./microagent-management-error";
import { MicroagentManagementConversationStopped } from "./microagent-management-conversation-stopped";
export function MicroagentManagementMain() {
const { selectedMicroagentItem } = useMicroagentManagementStore();
const { microagent, conversation } = selectedMicroagentItem ?? {};
if (microagent) {
return <MicroagentManagementViewMicroagent />;
}
if (conversation) {
if (conversation.pr_number && conversation.pr_number.length > 0) {
return <MicroagentManagementReviewPr />;
}
const isConversationStarting =
conversation.status === "STARTING" ||
conversation.runtime_status === "STATUS$STARTING_RUNTIME";
const isConversationOpeningPr =
conversation.status === "RUNNING" &&
conversation.runtime_status === "STATUS$READY";
if (isConversationStarting || isConversationOpeningPr) {
return <MicroagentManagementOpeningPr />;
}
if (conversation.runtime_status === "STATUS$ERROR") {
return <MicroagentManagementError />;
}
if (
conversation.status === "STOPPED" ||
conversation.runtime_status === "STATUS$STOPPED"
) {
return <MicroagentManagementConversationStopped />;
}
return <MicroagentManagementDefault />;
}
return <MicroagentManagementDefault />;
}
@@ -0,0 +1,118 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
import { cn } from "#/utils/utils";
import { GitRepository } from "#/types/git";
interface MicroagentManagementMicroagentCardProps {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
repository: GitRepository;
}
export function MicroagentManagementMicroagentCard({
microagent,
conversation,
repository,
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
const {
selectedMicroagentItem,
setSelectedMicroagentItem,
setSelectedRepository,
} = useMicroagentManagementStore();
const {
status: conversationStatus,
runtime_status: runtimeStatus,
pr_number: prNumber,
} = conversation ?? {};
const hasPr = !!(prNumber && prNumber.length > 0);
// Helper function to get status text
const statusText = useMemo(() => {
if (hasPr) {
return t(I18nKey.COMMON$READY_FOR_REVIEW);
}
if (
conversationStatus === "STARTING" ||
runtimeStatus === "STATUS$STARTING_RUNTIME"
) {
return t(I18nKey.COMMON$STARTING);
}
if (
conversationStatus === "STOPPED" ||
runtimeStatus === "STATUS$STOPPED"
) {
return t(I18nKey.COMMON$STOPPED);
}
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (conversationStatus === "RUNNING") {
return runtimeStatus === "STATUS$READY"
? t(I18nKey.MICROAGENT$STATUS_OPENING_PR)
: t(I18nKey.COMMON$STARTING);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);
const cardTitle = microagent?.name ?? conversation?.title;
const isCardSelected = useMemo(() => {
if (microagent && selectedMicroagentItem?.microagent) {
return selectedMicroagentItem.microagent.name === microagent.name;
}
if (conversation && selectedMicroagentItem?.conversation) {
return (
selectedMicroagentItem.conversation.conversation_id ===
conversation.conversation_id
);
}
return false;
}, [microagent, conversation, selectedMicroagentItem]);
const onMicroagentCardClicked = () => {
setSelectedMicroagentItem(
microagent
? {
microagent,
conversation: undefined,
}
: {
microagent: undefined,
conversation,
},
);
setSelectedRepository(repository);
};
return (
<div
className={cn(
"rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300",
isCardSelected && "bg-[#ffffff33] border-[#C9B974]",
)}
onClick={onMicroagentCardClicked}
>
<div className="flex flex-col items-start gap-2">
{statusText && (
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
{statusText}
</div>
)}
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
{!!microagent && (
<div className="text-white text-sm font-normal">
{microagent.path}
</div>
)}
</div>
</div>
);
}

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