mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
5 Commits
feature/gi
...
openhands/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d97ae6c5d | ||
|
|
eeb81ecc49 | ||
|
|
4d0f2e7a6d | ||
|
|
2c6d1e97e8 | ||
|
|
180557265f |
12
.github/CODEOWNERS
vendored
12
.github/CODEOWNERS
vendored
@@ -1,8 +1,12 @@
|
||||
# CODEOWNERS file for OpenHands repository
|
||||
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
/frontend/ @amanape @hieptl
|
||||
/openhands-ui/ @amanape @hieptl
|
||||
/openhands/ @tofarr @malhotra5 @hieptl
|
||||
/enterprise/ @chuckbutkus @tofarr @malhotra5
|
||||
# Frontend code owners
|
||||
/frontend/ @amanape
|
||||
/openhands-ui/ @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
# Documentation code owners
|
||||
/docs/ @mamoodi
|
||||
|
||||
47
.github/workflows/fe-e2e-tests.yml
vendored
47
.github/workflows/fe-e2e-tests.yml
vendored
@@ -1,47 +0,0 @@
|
||||
# Workflow that runs frontend e2e tests with Playwright
|
||||
name: Run Frontend E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/fe-e2e-tests.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fe-e2e-test:
|
||||
name: FE E2E Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
- name: Install Playwright browsers
|
||||
working-directory: ./frontend
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test --project=chromium
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
@@ -63,7 +63,7 @@ Frontend:
|
||||
- We use TanStack Query (fka React Query) for data fetching and cache management
|
||||
- Data Access Layer: API client methods are located in `frontend/src/api` and should never be called directly from UI components - they must always be wrapped with TanStack Query
|
||||
- Custom hooks are located in `frontend/src/hooks/query/` and `frontend/src/hooks/mutation/`
|
||||
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
|
||||
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationMicroagents`)
|
||||
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
|
||||
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.0-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/OpenHands/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/badge/LICENSE-MIT-20B2AA?style=for-the-badge" alt="MIT License"></a>
|
||||
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=811504672#gid=811504672"><img src="https://img.shields.io/badge/SWEBench-77.6-00cc00?logoColor=FFE165&style=for-the-badge" alt="Benchmark Score"></a>
|
||||
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=811504672#gid=811504672"><img src="https://img.shields.io/badge/SWEBench-72.8-00cc00?logoColor=FFE165&style=for-the-badge" alt="Benchmark Score"></a>
|
||||
<br/>
|
||||
<a href="https://docs.openhands.dev/sdk"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Check out the documentation"></a>
|
||||
<a href="https://arxiv.org/abs/2511.03690"><img src="https://img.shields.io/badge/Paper-000?logoColor=FFE165&logo=arxiv&style=for-the-badge" alt="Tech Report"></a>
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.0-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.0-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -31,8 +31,9 @@ RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gsprea
|
||||
"pillow>=11.3.0"
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=openhands:openhands --chmod=770 enterprise .
|
||||
COPY enterprise .
|
||||
|
||||
RUN chown -R openhands:openhands /app && chmod -R 770 /app
|
||||
USER openhands
|
||||
|
||||
# Command will be overridden by Kubernetes deployment template
|
||||
|
||||
@@ -721,7 +721,6 @@
|
||||
"https://$WEB_HOST/oauth/keycloak/callback",
|
||||
"https://$WEB_HOST/oauth/keycloak/offline/callback",
|
||||
"https://$WEB_HOST/slack/keycloak-callback",
|
||||
"https://$WEB_HOST/oauth/device/keycloak-callback",
|
||||
"https://$WEB_HOST/api/email/verified",
|
||||
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
|
||||
],
|
||||
|
||||
@@ -116,7 +116,7 @@ lines.append('POSTHOG_CLIENT_KEY=test')
|
||||
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
|
||||
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
|
||||
lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev')
|
||||
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-opus-4-5-20251101')
|
||||
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514')
|
||||
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
|
||||
lines.append('LOCAL_DEPLOYMENT=true')
|
||||
lines.append('DB_HOST=localhost')
|
||||
|
||||
@@ -22,7 +22,6 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
@@ -165,13 +164,8 @@ class GithubManager(Manager):
|
||||
)
|
||||
|
||||
if await self.is_job_requested(message):
|
||||
payload = message.message.get('payload', {})
|
||||
user_id = payload['sender']['id']
|
||||
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
|
||||
user_id, ProviderType.GITHUB
|
||||
)
|
||||
github_view = await GithubFactory.create_github_view_from_payload(
|
||||
message, keycloak_user_id
|
||||
message, self.token_manager
|
||||
)
|
||||
logger.info(
|
||||
f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}'
|
||||
@@ -288,15 +282,8 @@ class GithubManager(Manager):
|
||||
f'[Github]: Error summarizing issue solvability: {str(e)}'
|
||||
)
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
github_view.user_info.keycloak_user_id, self.token_manager
|
||||
)
|
||||
|
||||
await github_view.create_new_conversation(
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
convo_metadata,
|
||||
saas_user_auth,
|
||||
self.jinja_env, secret_store.provider_tokens, convo_metadata
|
||||
)
|
||||
|
||||
conversation_id = github_view.conversation_id
|
||||
@@ -305,7 +292,14 @@ class GithubManager(Manager):
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
if not github_view.v1:
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, github_view.user_info.keycloak_user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
if metadata.conversation_version != 'v1':
|
||||
# Create a GithubCallbackProcessor
|
||||
processor = GithubCallbackProcessor(
|
||||
github_view=github_view,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from github import Github, GithubIntegration
|
||||
@@ -9,17 +8,16 @@ from integrations.github.github_types import (
|
||||
WorkflowRunStatus,
|
||||
)
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
ENABLE_V1_GITHUB_RESOLVER,
|
||||
HOST,
|
||||
HOST_URL,
|
||||
get_oh_labels,
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from pydantic.dataclasses import dataclass
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
@@ -36,16 +34,18 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.sdk.conversation.secret_source import SecretSource
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
@@ -55,6 +55,52 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
class GithubUserContext(UserContext):
|
||||
"""User context for GitHub integration that provides user info without web request."""
|
||||
|
||||
def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE):
|
||||
self.keycloak_user_id = keycloak_user_id
|
||||
self.git_provider_tokens = git_provider_tokens
|
||||
self.settings_store = SaasSettingsStore(
|
||||
user_id=self.keycloak_user_id,
|
||||
session_maker=session_maker,
|
||||
config=get_config(),
|
||||
)
|
||||
|
||||
self.secrets_store = SaasSecretsStore(
|
||||
self.keycloak_user_id, session_maker, get_config()
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.keycloak_user_id
|
||||
|
||||
async def get_user_info(self) -> UserInfo:
|
||||
user_settings = await self.settings_store.load()
|
||||
return UserInfo(
|
||||
id=self.keycloak_user_id,
|
||||
**user_settings.model_dump(context={'expose_secrets': True}),
|
||||
)
|
||||
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
# This would need to be implemented based on the git provider tokens
|
||||
# For now, return a basic HTTPS URL
|
||||
return f'https://github.com/{repository}.git'
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
# Return the appropriate token from git_provider_tokens
|
||||
if provider_type == ProviderType.GITHUB and self.git_provider_tokens:
|
||||
return self.git_provider_tokens.get(ProviderType.GITHUB)
|
||||
return None
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
# Return empty dict for now - GitHub integration handles secrets separately
|
||||
user_secrets = await self.secrets_store.load()
|
||||
return dict(user_secrets.custom_secrets) if user_secrets else {}
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's proactive conversation setting.
|
||||
|
||||
@@ -88,7 +134,7 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
return settings.enable_proactive_conversation_starters
|
||||
|
||||
|
||||
async def get_user_v1_enabled_setting(user_id: str) -> bool:
|
||||
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's V1 conversation API setting.
|
||||
|
||||
Args:
|
||||
@@ -96,13 +142,10 @@ async def get_user_v1_enabled_setting(user_id: str) -> bool:
|
||||
|
||||
Returns:
|
||||
True if V1 conversations are enabled for this user, False otherwise
|
||||
|
||||
Note:
|
||||
This function checks both the global environment variable kill switch AND
|
||||
the user's individual setting. Both must be true for the function to return true.
|
||||
"""
|
||||
# Check the global environment variable first
|
||||
if not ENABLE_V1_GITHUB_RESOLVER:
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
config = get_config()
|
||||
@@ -140,7 +183,6 @@ class GithubIssue(ResolverViewInterface):
|
||||
title: str
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
v1: bool
|
||||
|
||||
async def _load_resolver_context(self):
|
||||
github_service = GithubServiceImpl(
|
||||
@@ -187,19 +229,6 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
return ConversationMetadata(
|
||||
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
@@ -216,17 +245,14 @@ class GithubIssue(ResolverViewInterface):
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
|
||||
if v1_enabled:
|
||||
try:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
)
|
||||
return
|
||||
|
||||
@@ -245,7 +271,6 @@ class GithubIssue(ResolverViewInterface):
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the legacy V0 system."""
|
||||
logger.info('[GitHub]: Creating V0 conversation')
|
||||
custom_secrets = await self._get_user_secrets()
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
@@ -267,12 +292,10 @@ class GithubIssue(ResolverViewInterface):
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
saas_user_auth: UserAuth,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitHub V1]: Creating V1 conversation')
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
@@ -303,7 +326,10 @@ class GithubIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitHub user context for the V1 system
|
||||
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
github_user_context = GithubUserContext(
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -318,8 +344,6 @@ class GithubIssue(ResolverViewInterface):
|
||||
f'Failed to start V1 conversation: {task.detail}'
|
||||
)
|
||||
|
||||
self.v1 = True
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from openhands.app_server.event_callback.github_v1_callback_processor import (
|
||||
@@ -391,18 +415,7 @@ class GithubPRComment(GithubIssueComment):
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
return ConversationMetadata(
|
||||
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
|
||||
)
|
||||
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
@@ -793,7 +806,7 @@ class GithubFactory:
|
||||
|
||||
@staticmethod
|
||||
async def create_github_view_from_payload(
|
||||
message: Message, keycloak_user_id: str
|
||||
message: Message, token_manager: TokenManager
|
||||
) -> ResolverViewInterface:
|
||||
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
|
||||
Also return metadata about the event (e.g., action type).
|
||||
@@ -803,10 +816,17 @@ class GithubFactory:
|
||||
user_id = payload['sender']['id']
|
||||
username = payload['sender']['login']
|
||||
|
||||
keyloak_user_id = await token_manager.get_user_id_from_idp_user_id(
|
||||
user_id, ProviderType.GITHUB
|
||||
)
|
||||
|
||||
if keyloak_user_id is None:
|
||||
logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ')
|
||||
|
||||
selected_repo = GithubFactory.get_full_repo_name(repo_obj)
|
||||
is_public_repo = not repo_obj.get('private', True)
|
||||
user_info = UserData(
|
||||
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
|
||||
user_id=user_id, username=username, keycloak_user_id=keyloak_user_id
|
||||
)
|
||||
|
||||
installation_id = message.message['installation']
|
||||
@@ -830,7 +850,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_issue_comment(message):
|
||||
@@ -856,7 +875,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_pr_comment(message):
|
||||
@@ -898,7 +916,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_inline_pr_comment(message):
|
||||
@@ -932,7 +949,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class ResolverUserContext(UserContext):
|
||||
"""User context for resolver operations that inherits from UserContext."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
self.saas_user_auth = saas_user_auth
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return await self.saas_user_auth.get_user_id()
|
||||
|
||||
async def get_user_info(self) -> UserInfo:
|
||||
user_settings = await self.saas_user_auth.get_user_settings()
|
||||
user_id = await self.saas_user_auth.get_user_id()
|
||||
if user_settings:
|
||||
return UserInfo(
|
||||
id=user_id,
|
||||
**user_settings.model_dump(context={'expose_secrets': True}),
|
||||
)
|
||||
|
||||
return UserInfo(id=user_id)
|
||||
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
# This would need to be implemented based on the git provider tokens
|
||||
# For now, return a basic HTTPS URL
|
||||
return f'https://github.com/{repository}.git'
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
# Return the appropriate token from git_provider_tokens
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if provider_tokens:
|
||||
return provider_tokens.get(provider_type)
|
||||
return None
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
return await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
"""Get secrets for the user, including custom secrets."""
|
||||
secrets = await self.saas_user_auth.get_secrets()
|
||||
if secrets:
|
||||
# Convert custom secrets to StaticSecret objects for SDK compatibility
|
||||
# secrets.custom_secrets is of type Mapping[str, CustomSecret]
|
||||
converted_secrets = {}
|
||||
for key, custom_secret in secrets.custom_secrets.items():
|
||||
# Extract the secret value from CustomSecret and convert to StaticSecret
|
||||
secret_value = custom_secret.secret.get_secret_value()
|
||||
converted_secrets[key] = StaticSecret(value=secret_value)
|
||||
return converted_secrets
|
||||
return {}
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
return await self.saas_user_auth.get_mcp_api_key()
|
||||
@@ -19,7 +19,7 @@ class PRStatus(Enum):
|
||||
class UserData(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
keycloak_user_id: str
|
||||
keycloak_user_id: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -51,11 +51,6 @@ ENABLE_SOLVABILITY_ANALYSIS = (
|
||||
os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
# Toggle for V1 GitHub resolver feature
|
||||
ENABLE_V1_GITHUB_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/'
|
||||
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
async def get_saas_user_auth(
|
||||
keycloak_user_id: str, token_manager: TokenManager
|
||||
) -> UserAuth:
|
||||
offline_token = await token_manager.load_offline_token(keycloak_user_id)
|
||||
if offline_token is None:
|
||||
logger.info('no_offline_token_found')
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
user_id=keycloak_user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
)
|
||||
return user_auth
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Create device_codes table for OAuth 2.0 Device Flow
|
||||
|
||||
Revision ID: 084
|
||||
Revises: 083
|
||||
Create Date: 2024-12-10 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '084'
|
||||
down_revision = '083'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create device_codes table for OAuth 2.0 Device Flow."""
|
||||
op.create_table(
|
||||
'device_codes',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('device_code', sa.String(length=128), nullable=False),
|
||||
sa.Column('user_code', sa.String(length=16), nullable=False),
|
||||
sa.Column('status', sa.String(length=32), nullable=False),
|
||||
sa.Column('keycloak_user_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('authorized_at', sa.DateTime(timezone=True), nullable=True),
|
||||
# Rate limiting fields for RFC 8628 section 3.5 compliance
|
||||
sa.Column('last_poll_time', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('current_interval', sa.Integer(), nullable=False, default=5),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# Create indexes for efficient lookups
|
||||
op.create_index(
|
||||
'ix_device_codes_device_code', 'device_codes', ['device_code'], unique=True
|
||||
)
|
||||
op.create_index(
|
||||
'ix_device_codes_user_code', 'device_codes', ['user_code'], unique=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Drop device_codes table."""
|
||||
op.drop_index('ix_device_codes_user_code', table_name='device_codes')
|
||||
op.drop_index('ix_device_codes_device_code', table_name='device_codes')
|
||||
op.drop_table('device_codes')
|
||||
293
enterprise/poetry.lock
generated
293
enterprise/poetry.lock
generated
@@ -201,14 +201,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.75.0"
|
||||
version = "0.72.0"
|
||||
description = "The official Python library for the anthropic API"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b"},
|
||||
{file = "anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb"},
|
||||
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
|
||||
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -682,37 +682,37 @@ crt = ["awscrt (==0.27.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "browser-use"
|
||||
version = "0.10.1"
|
||||
version = "0.9.5"
|
||||
description = "Make websites accessible for AI agents"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "browser_use-0.10.1-py3-none-any.whl", hash = "sha256:96e603bfc71098175342cdcb0592519e6f244412e740f0254e4389fdd82a977f"},
|
||||
{file = "browser_use-0.10.1.tar.gz", hash = "sha256:5f211ecfdf1f9fd186160f10df70dedd661821231e30f1bce40939787abab223"},
|
||||
{file = "browser_use-0.9.5-py3-none-any.whl", hash = "sha256:4a2e92847204d1ded269026a99cb0cc0e60e38bd2751fa3f58aedd78f00b4e67"},
|
||||
{file = "browser_use-0.9.5.tar.gz", hash = "sha256:f8285fe253b149d01769a7084883b4cf4db351e2f38e26302c157bcbf14a703f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = "3.12.15"
|
||||
anthropic = ">=0.72.1,<1.0.0"
|
||||
anthropic = ">=0.68.1,<1.0.0"
|
||||
anyio = ">=4.9.0"
|
||||
authlib = ">=1.6.0"
|
||||
bubus = ">=1.5.6"
|
||||
cdp-use = ">=1.4.4"
|
||||
cdp-use = ">=1.4.0"
|
||||
click = ">=8.1.8"
|
||||
cloudpickle = ">=3.1.1"
|
||||
google-api-core = ">=2.25.0"
|
||||
google-api-python-client = ">=2.174.0"
|
||||
google-auth = ">=2.40.3"
|
||||
google-auth-oauthlib = ">=1.2.2"
|
||||
google-genai = ">=1.50.0,<2.0.0"
|
||||
google-genai = ">=1.29.0,<2.0.0"
|
||||
groq = ">=0.30.0"
|
||||
httpx = ">=0.28.1"
|
||||
inquirerpy = ">=0.3.4"
|
||||
markdownify = ">=1.2.0"
|
||||
mcp = ">=1.10.1"
|
||||
ollama = ">=0.5.1"
|
||||
openai = ">=2.7.2,<3.0.0"
|
||||
openai = ">=1.99.2,<2.0.0"
|
||||
pillow = ">=11.2.1"
|
||||
portalocker = ">=2.7.0,<3.0.0"
|
||||
posthog = ">=3.7.0"
|
||||
@@ -721,7 +721,6 @@ pydantic = ">=2.11.5"
|
||||
pyobjc = {version = ">=11.0", markers = "platform_system == \"darwin\""}
|
||||
pyotp = ">=2.9.0"
|
||||
pypdf = ">=5.7.0"
|
||||
python-docx = ">=1.2.0"
|
||||
python-dotenv = ">=1.0.1"
|
||||
reportlab = ">=4.0.0"
|
||||
requests = ">=2.32.3"
|
||||
@@ -851,14 +850,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "cdp-use"
|
||||
version = "1.4.4"
|
||||
version = "1.4.3"
|
||||
description = "Type safe generator/client library for CDP"
|
||||
optional = false
|
||||
python-versions = ">=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cdp_use-1.4.4-py3-none-any.whl", hash = "sha256:e37e80e067db2653d6fdf953d4ff9e5d80d75daa27b7c6d48c0261cccbef73e1"},
|
||||
{file = "cdp_use-1.4.4.tar.gz", hash = "sha256:330a848b517006eb9ad1dc468aa6434d913cf0c6918610760c36c3fdfdba0fab"},
|
||||
{file = "cdp_use-1.4.3-py3-none-any.whl", hash = "sha256:c48664604470c2579aa1e677c3e3e7e24c4f300c54804c093d935abb50479ecd"},
|
||||
{file = "cdp_use-1.4.3.tar.gz", hash = "sha256:9029c04bdc49fbd3939d2bf1988ad8d88e260729c7d5e35c2f6c87591f5a10e9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2979,29 +2978,28 @@ testing = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "google-genai"
|
||||
version = "1.53.0"
|
||||
version = "1.32.0"
|
||||
description = "GenAI Python SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_genai-1.53.0-py3-none-any.whl", hash = "sha256:65a3f99e5c03c372d872cda7419f5940e723374bb12a2f3ffd5e3e56e8eb2094"},
|
||||
{file = "google_genai-1.53.0.tar.gz", hash = "sha256:938a26d22f3fd32c6eeeb4276ef204ef82884e63af9842ce3eac05ceb39cbd8d"},
|
||||
{file = "google_genai-1.32.0-py3-none-any.whl", hash = "sha256:c0c4b1d45adf3aa99501050dd73da2f0dea09374002231052d81a6765d15e7f6"},
|
||||
{file = "google_genai-1.32.0.tar.gz", hash = "sha256:349da3f5ff0e981066bd508585fcdd308d28fc4646f318c8f6d1aa6041f4c7e3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=4.8.0,<5.0.0"
|
||||
google-auth = {version = ">=2.14.1,<3.0.0", extras = ["requests"]}
|
||||
google-auth = ">=2.14.1,<3.0.0"
|
||||
httpx = ">=0.28.1,<1.0.0"
|
||||
pydantic = ">=2.9.0,<3.0.0"
|
||||
pydantic = ">=2.0.0,<3.0.0"
|
||||
requests = ">=2.28.1,<3.0.0"
|
||||
tenacity = ">=8.2.3,<9.2.0"
|
||||
typing-extensions = ">=4.11.0,<5.0.0"
|
||||
websockets = ">=13.0.0,<15.1.0"
|
||||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp (<3.13.3)"]
|
||||
local-tokenizer = ["protobuf", "sentencepiece (>=0.2.0)"]
|
||||
aiohttp = ["aiohttp (<4.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "google-resumable-media"
|
||||
@@ -3057,8 +3055,6 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
|
||||
@@ -3068,8 +3064,6 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
|
||||
@@ -3079,8 +3073,6 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
|
||||
@@ -3090,8 +3082,6 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
|
||||
@@ -3099,8 +3089,6 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
|
||||
@@ -3110,8 +3098,6 @@ files = [
|
||||
{file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
|
||||
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
|
||||
@@ -3180,87 +3166,83 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.67.1"
|
||||
version = "1.74.0"
|
||||
description = "HTTP/2-based RPC framework"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
|
||||
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:85bd5cdf4ed7b2d6438871adf6afff9af7096486fcf51818a81b77ef4dd30907"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:68c8ebcca945efff9d86d8d6d7bfb0841cf0071024417e2d7f45c5e46b5b08eb"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:e154d230dc1bbbd78ad2fdc3039fa50ad7ffcf438e4eb2fa30bce223a70c7486"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8978003816c7b9eabe217f88c78bc26adc8f9304bf6a594b02e5a49b2ef9c11"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3d7bd6e3929fd2ea7fbc3f562e4987229ead70c9ae5f01501a46701e08f1ad9"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:136b53c91ac1d02c8c24201bfdeb56f8b3ac3278668cbb8e0ba49c88069e1bdc"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fe0f540750a13fd8e5da4b3eaba91a785eea8dca5ccd2bc2ffe978caa403090e"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4e4181bfc24413d1e3a37a0b7889bea68d973d4b45dd2bc68bb766c140718f82"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-win32.whl", hash = "sha256:1733969040989f7acc3d94c22f55b4a9501a30f6aaacdbccfaba0a3ffb255ab7"},
|
||||
{file = "grpcio-1.74.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e912d3c993a29df6c627459af58975b2e5c897d93287939b9d5065f000249b5"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b"},
|
||||
{file = "grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c"},
|
||||
{file = "grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa"},
|
||||
{file = "grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:4bc5fca10aaf74779081e16c2bcc3d5ec643ffd528d9e7b1c9039000ead73bae"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:6bab67d15ad617aff094c382c882e0177637da73cbc5532d52c07b4ee887a87b"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:655726919b75ab3c34cdad39da5c530ac6fa32696fb23119e36b64adcfca174a"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a2b06afe2e50ebfd46247ac3ba60cac523f54ec7792ae9ba6073c12daf26f0a"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f251c355167b2360537cf17bea2cf0197995e551ab9da6a0a59b3da5e8704f9"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8f7b5882fb50632ab1e48cb3122d6df55b9afabc265582808036b6e51b9fd6b7"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:834988b6c34515545b3edd13e902c1acdd9f2465d386ea5143fb558f153a7176"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22b834cef33429ca6cc28303c9c327ba9a3fafecbf62fae17e9a7b7163cc43ac"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-win32.whl", hash = "sha256:7d95d71ff35291bab3f1c52f52f474c632db26ea12700c2ff0ea0532cb0b5854"},
|
||||
{file = "grpcio-1.74.0-cp39-cp39-win_amd64.whl", hash = "sha256:ecde9ab49f58433abe02f9ed076c7b5be839cf0153883a6d23995937a82392fa"},
|
||||
{file = "grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
protobuf = ["grpcio-tools (>=1.67.1)"]
|
||||
protobuf = ["grpcio-tools (>=1.74.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio-status"
|
||||
version = "1.67.1"
|
||||
version = "1.71.2"
|
||||
description = "Status proto mapping for gRPC"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "grpcio_status-1.67.1-py3-none-any.whl", hash = "sha256:16e6c085950bdacac97c779e6a502ea671232385e6e37f258884d6883392c2bd"},
|
||||
{file = "grpcio_status-1.67.1.tar.gz", hash = "sha256:2bf38395e028ceeecfd8866b081f61628114b384da7d51ae064ddc8d766a5d11"},
|
||||
{file = "grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3"},
|
||||
{file = "grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.5.5"
|
||||
grpcio = ">=1.67.1"
|
||||
grpcio = ">=1.71.2"
|
||||
protobuf = ">=5.26.1,<6.0dev"
|
||||
|
||||
[[package]]
|
||||
@@ -4558,39 +4540,42 @@ valkey = ["valkey (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.80.7"
|
||||
version = "1.77.7"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
python-versions = ">=3.8.1,<4.0, !=3.9.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "litellm-1.80.7-py3-none-any.whl", hash = "sha256:f7d993f78c1e0e4e1202b2a925cc6540b55b6e5fb055dd342d88b145ab3102ed"},
|
||||
{file = "litellm-1.80.7.tar.gz", hash = "sha256:3977a8d195aef842d01c18bf9e22984829363c6a4b54daf9a43c9dd9f190b42c"},
|
||||
]
|
||||
files = []
|
||||
develop = false
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.10"
|
||||
click = "*"
|
||||
fastuuid = ">=0.13.0"
|
||||
grpcio = ">=1.62.3,<1.68.0"
|
||||
httpx = ">=0.23.0"
|
||||
importlib-metadata = ">=6.8.0"
|
||||
jinja2 = ">=3.1.2,<4.0.0"
|
||||
jsonschema = ">=4.22.0,<5.0.0"
|
||||
openai = ">=2.8.0"
|
||||
pydantic = ">=2.5.0,<3.0.0"
|
||||
jinja2 = "^3.1.2"
|
||||
jsonschema = "^4.22.0"
|
||||
openai = ">=1.99.5"
|
||||
pydantic = "^2.5.0"
|
||||
python-dotenv = ">=0.2.0"
|
||||
tiktoken = ">=0.7.0"
|
||||
tokenizers = "*"
|
||||
|
||||
[package.extras]
|
||||
caching = ["diskcache (>=5.6.1,<6.0.0)"]
|
||||
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
|
||||
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
|
||||
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
|
||||
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.22)", "litellm-proxy-extras (==0.4.9)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
|
||||
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
|
||||
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.20)", "litellm-proxy-extras (==0.2.25)", "mcp (>=1.10.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=13.1.0,<14.0.0)"]
|
||||
semantic-router = ["semantic-router ; python_version >= \"3.9\""]
|
||||
utils = ["numpydoc"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/BerriAI/litellm.git"
|
||||
reference = "v1.77.7.dev9"
|
||||
resolved_reference = "763d2f8ccdd8412dbe6d4ac0e136d9ac34dcd4c0"
|
||||
|
||||
[[package]]
|
||||
name = "llvmlite"
|
||||
version = "0.44.0"
|
||||
@@ -4624,14 +4609,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "lmnr"
|
||||
version = "0.7.24"
|
||||
version = "0.7.20"
|
||||
description = "Python SDK for Laminar"
|
||||
optional = false
|
||||
python-versions = "<4,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "lmnr-0.7.24-py3-none-any.whl", hash = "sha256:ad780d4a62ece897048811f3368639c240a9329ab31027da8c96545137a3a08a"},
|
||||
{file = "lmnr-0.7.24.tar.gz", hash = "sha256:aa6973f46fc4ba95c9061c1feceb58afc02eb43c9376c21e32545371ff6123d7"},
|
||||
{file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
|
||||
{file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4654,15 +4639,14 @@ tqdm = ">=4.0"
|
||||
|
||||
[package.extras]
|
||||
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
|
||||
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
|
||||
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
|
||||
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
|
||||
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
|
||||
claude-agent-sdk = ["lmnr-claude-code-proxy (>=0.1.0a5)"]
|
||||
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
|
||||
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
|
||||
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
|
||||
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
|
||||
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)"]
|
||||
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
|
||||
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
|
||||
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
|
||||
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
|
||||
@@ -5660,28 +5644,28 @@ pydantic = ">=2.9"
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.8.0"
|
||||
version = "1.99.9"
|
||||
description = "The official Python library for the openai API"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "openai-2.8.0-py3-none-any.whl", hash = "sha256:ba975e347f6add2fe13529ccb94d54a578280e960765e5224c34b08d7e029ddf"},
|
||||
{file = "openai-2.8.0.tar.gz", hash = "sha256:4851908f6d6fcacbd47ba659c5ac084f7725b752b6bfa1e948b6fbfc111a6bad"},
|
||||
{file = "openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a"},
|
||||
{file = "openai-1.99.9.tar.gz", hash = "sha256:f2082d155b1ad22e83247c3de3958eb4255b20ccf4a1de2e6681b6957b554e92"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.5.0,<5"
|
||||
distro = ">=1.7.0,<2"
|
||||
httpx = ">=0.23.0,<1"
|
||||
jiter = ">=0.10.0,<1"
|
||||
jiter = ">=0.4.0,<1"
|
||||
pydantic = ">=1.9.0,<3"
|
||||
sniffio = "*"
|
||||
tqdm = ">4"
|
||||
typing-extensions = ">=4.11,<5"
|
||||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
|
||||
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
|
||||
realtime = ["websockets (>=13,<16)"]
|
||||
voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
|
||||
@@ -5836,14 +5820,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.6.0"
|
||||
version = "1.3.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.6.0-py3-none-any.whl", hash = "sha256:e6ae865ac3e7a96b234e10a0faad23f6210e025bbf7721cb66bc7a71d160848c"},
|
||||
{file = "openhands_agent_server-1.6.0.tar.gz", hash = "sha256:44ce7694ae2d4bb0666d318ef13e6618bd4dc73022c60354839fe6130e67d02a"},
|
||||
{file = "openhands_agent_server-1.3.0-py3-none-any.whl", hash = "sha256:2f87f790c740dc3fb81821c5f9fa375af875fbb937ebca3baa6dc5c035035b3c"},
|
||||
{file = "openhands_agent_server-1.3.0.tar.gz", hash = "sha256:0a83ae77373f5c41d0ba0e22d8f0f6144d54d55784183a50b7c098c96cd5135c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5851,7 +5835,6 @@ aiosqlite = ">=0.19"
|
||||
alembic = ">=1.13"
|
||||
docker = ">=7.1,<8"
|
||||
fastapi = ">=0.104"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2"
|
||||
sqlalchemy = ">=2"
|
||||
uvicorn = ">=0.31.1"
|
||||
@@ -5860,7 +5843,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.0.0-post.5687+7853b41ad"
|
||||
version = "0.62.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -5896,15 +5879,15 @@ json-repair = "*"
|
||||
jupyter_kernel_gateway = "*"
|
||||
kubernetes = "^33.1.0"
|
||||
libtmux = ">=0.46.2"
|
||||
litellm = ">=1.74.3, <=1.80.7, !=1.64.4, !=1.67.*"
|
||||
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
|
||||
lmnr = "^0.7.20"
|
||||
memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "2.8.0"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = "1.6.0"
|
||||
openhands-sdk = "1.6.0"
|
||||
openhands-tools = "1.6.0"
|
||||
openhands-agent-server = "1.3.0"
|
||||
openhands-sdk = "1.3.0"
|
||||
openhands-tools = "1.3.0"
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5960,22 +5943,22 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.6.0"
|
||||
version = "1.3.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.6.0-py3-none-any.whl", hash = "sha256:94d2f87fb35406373da6728ae2d88584137f9e9b67fa0e940444c72f2e44e7d3"},
|
||||
{file = "openhands_sdk-1.6.0.tar.gz", hash = "sha256:f45742350e3874a7f5b08befc4a9d5adc7e4454f7ab5f8391c519eee3116090f"},
|
||||
{file = "openhands_sdk-1.3.0-py3-none-any.whl", hash = "sha256:feee838346f8e60ea3e4d3391de7cb854314eb8b3c9e3dbbb56f98a784aadc56"},
|
||||
{file = "openhands_sdk-1.3.0.tar.gz", hash = "sha256:2d060803a78de462121b56dea717a66356922deb02276f37b29fae8af66343fb"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
deprecation = ">=2.1.0"
|
||||
fastmcp = ">=2.11.3"
|
||||
httpx = ">=0.27.0"
|
||||
litellm = ">=1.80.7"
|
||||
lmnr = ">=0.7.24"
|
||||
litellm = ">=1.77.7.dev9"
|
||||
lmnr = ">=0.7.20"
|
||||
pydantic = ">=2.11.7"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
python-json-logger = ">=3.3.0"
|
||||
@@ -5987,14 +5970,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.6.0"
|
||||
version = "1.3.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.6.0-py3-none-any.whl", hash = "sha256:176556d44186536751b23fe052d3505492cc2afb8d52db20fb7a2cc0169cd57a"},
|
||||
{file = "openhands_tools-1.6.0.tar.gz", hash = "sha256:d07ba31050fd4a7891a4c48388aa53ce9f703e17064ddbd59146d6c77e5980b3"},
|
||||
{file = "openhands_tools-1.3.0-py3-none-any.whl", hash = "sha256:f31056d87c3058ac92709f9161c7c602daeee3ed0cb4439097b43cda105ed03e"},
|
||||
{file = "openhands_tools-1.3.0.tar.gz", hash = "sha256:3da46f09e28593677d3e17252ce18584fcc13caab1a73213e66bd7edca2cebe0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6006,7 +5989,6 @@ func-timeout = ">=4.3.5"
|
||||
libtmux = ">=0.46.2"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2.11.7"
|
||||
tom-swe = ">=1.0.3"
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
@@ -13323,31 +13305,6 @@ dev = ["tokenizers[testing]"]
|
||||
docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"]
|
||||
testing = ["black (==22.3)", "datasets", "numpy", "pytest", "pytest-asyncio", "requests", "ruff"]
|
||||
|
||||
[[package]]
|
||||
name = "tom-swe"
|
||||
version = "1.0.3"
|
||||
description = "Theory of Mind modeling for Software Engineering assistants"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "tom_swe-1.0.3-py3-none-any.whl", hash = "sha256:7b1172b29eb5c8fb7f1975016e7b6a238511b9ac2a7a980bd400dcb4e29773f2"},
|
||||
{file = "tom_swe-1.0.3.tar.gz", hash = "sha256:57c97d0104e563f15bd39edaf2aa6ac4c3e9444afd437fb92458700d22c6c0f5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
jinja2 = ">=3.0.0"
|
||||
json-repair = ">=0.1.0"
|
||||
litellm = ">=1.0.0"
|
||||
pydantic = ">=2.0.0"
|
||||
python-dotenv = ">=1.0.0"
|
||||
tiktoken = ">=0.8.0"
|
||||
tqdm = ">=4.65.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["aiofiles (>=23.0.0)", "black (>=22.0.0)", "datasets (>=2.0.0)", "fastapi (>=0.104.0)", "httpx (>=0.25.0)", "huggingface-hub (>=0.0.0)", "isort (>=5.0.0)", "mypy (>=1.0.0)", "numpy (>=1.24.0)", "pandas (>=2.0.0)", "pre-commit (>=3.6.0)", "pytest (>=7.0.0)", "pytest-cov (>=6.2.1)", "rich (>=13.0.0)", "ruff (>=0.3.0)", "typing-extensions (>=4.0.0)", "uvicorn (>=0.24.0)"]
|
||||
search = ["bm25s (>=0.2.0)", "pystemmer (>=2.2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.10.2"
|
||||
|
||||
@@ -34,7 +34,6 @@ from server.routes.integration.jira_dc import jira_dc_integration_router # noqa
|
||||
from server.routes.integration.linear import linear_integration_router # noqa: E402
|
||||
from server.routes.integration.slack import slack_router # noqa: E402
|
||||
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
|
||||
from server.routes.oauth_device import oauth_device_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
|
||||
@@ -61,7 +60,6 @@ base_app.mount('/internal/metrics', metrics_app())
|
||||
base_app.include_router(readiness_router) # Add routes for readiness checks
|
||||
base_app.include_router(api_router) # Add additional route for github auth
|
||||
base_app.include_router(oauth_router) # Add additional route for oauth callback
|
||||
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
|
||||
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
|
||||
base_app.include_router(
|
||||
billing_router
|
||||
|
||||
@@ -25,7 +25,6 @@ USER_SETTINGS_VERSION_TO_MODEL = {
|
||||
2: 'claude-3-7-sonnet-20250219',
|
||||
3: 'claude-sonnet-4-20250514',
|
||||
4: 'claude-sonnet-4-20250514',
|
||||
5: 'claude-opus-4-5-20251101',
|
||||
}
|
||||
|
||||
LITELLM_DEFAULT_MODEL = os.getenv('LITELLM_DEFAULT_MODEL')
|
||||
|
||||
331
enterprise/server/legacy_conversation_manager.py
Normal file
331
enterprise/server/legacy_conversation_manager.py
Normal file
@@ -0,0 +1,331 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import socketio
|
||||
from server.clustered_conversation_manager import ClusteredConversationManager
|
||||
from server.saas_nested_conversation_manager import SaasNestedConversationManager
|
||||
|
||||
from openhands.core.config import LLMConfig, OpenHandsConfig
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
)
|
||||
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.utils.async_utils import wait_all
|
||||
|
||||
_LEGACY_ENTRY_TIMEOUT_SECONDS = 3600
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegacyCacheEntry:
|
||||
"""Cache entry for legacy mode status."""
|
||||
|
||||
is_legacy: bool
|
||||
timestamp: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegacyConversationManager(ConversationManager):
|
||||
"""
|
||||
Conversation manager for use while migrating - since existing conversations are not nested!
|
||||
Separate class from SaasNestedConversationManager so it can be easliy removed in a few weeks.
|
||||
(As of 2025-07-23)
|
||||
"""
|
||||
|
||||
sio: socketio.AsyncServer
|
||||
config: OpenHandsConfig
|
||||
server_config: ServerConfig
|
||||
file_store: FileStore
|
||||
conversation_manager: SaasNestedConversationManager
|
||||
legacy_conversation_manager: ClusteredConversationManager
|
||||
_legacy_cache: dict[str, LegacyCacheEntry] = field(default_factory=dict)
|
||||
|
||||
async def __aenter__(self):
|
||||
await wait_all(
|
||||
[
|
||||
self.conversation_manager.__aenter__(),
|
||||
self.legacy_conversation_manager.__aenter__(),
|
||||
]
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await wait_all(
|
||||
[
|
||||
self.conversation_manager.__aexit__(exc_type, exc_value, traceback),
|
||||
self.legacy_conversation_manager.__aexit__(
|
||||
exc_type, exc_value, traceback
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def request_llm_completion(
|
||||
self,
|
||||
sid: str,
|
||||
service_id: str,
|
||||
llm_config: LLMConfig,
|
||||
messages: list[dict[str, str]],
|
||||
) -> str:
|
||||
session = self.get_agent_session(sid)
|
||||
llm_registry = session.llm_registry
|
||||
return llm_registry.request_extraneous_completion(
|
||||
service_id, llm_config, messages
|
||||
)
|
||||
|
||||
async def attach_to_conversation(
|
||||
self, sid: str, user_id: str | None = None
|
||||
) -> ServerConversation | None:
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.attach_to_conversation(
|
||||
sid, user_id
|
||||
)
|
||||
return await self.conversation_manager.attach_to_conversation(sid, user_id)
|
||||
|
||||
async def detach_from_conversation(self, conversation: ServerConversation):
|
||||
if await self.should_start_in_legacy_mode(conversation.sid):
|
||||
return await self.legacy_conversation_manager.detach_from_conversation(
|
||||
conversation
|
||||
)
|
||||
return await self.conversation_manager.detach_from_conversation(conversation)
|
||||
|
||||
async def join_conversation(
|
||||
self,
|
||||
sid: str,
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
) -> AgentLoopInfo:
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.join_conversation(
|
||||
sid, connection_id, settings, user_id
|
||||
)
|
||||
return await self.conversation_manager.join_conversation(
|
||||
sid, connection_id, settings, user_id
|
||||
)
|
||||
|
||||
def get_agent_session(self, sid: str):
|
||||
session = self.legacy_conversation_manager.get_agent_session(sid)
|
||||
if session is None:
|
||||
session = self.conversation_manager.get_agent_session(sid)
|
||||
return session
|
||||
|
||||
async def get_running_agent_loops(
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
) -> set[str]:
|
||||
if filter_to_sids and len(filter_to_sids) == 1:
|
||||
sid = next(iter(filter_to_sids))
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.get_running_agent_loops(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
return await self.conversation_manager.get_running_agent_loops(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
|
||||
# Get all running agent loops from both managers
|
||||
agent_loops, legacy_agent_loops = await wait_all(
|
||||
[
|
||||
self.conversation_manager.get_running_agent_loops(
|
||||
user_id, filter_to_sids
|
||||
),
|
||||
self.legacy_conversation_manager.get_running_agent_loops(
|
||||
user_id, filter_to_sids
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Combine the results
|
||||
result = set()
|
||||
for sid in legacy_agent_loops:
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
result.add(sid)
|
||||
|
||||
for sid in agent_loops:
|
||||
if not await self.should_start_in_legacy_mode(sid):
|
||||
result.add(sid)
|
||||
|
||||
return result
|
||||
|
||||
async def is_agent_loop_running(self, sid: str) -> bool:
|
||||
return bool(await self.get_running_agent_loops(filter_to_sids={sid}))
|
||||
|
||||
async def get_connections(
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
) -> dict[str, str]:
|
||||
if filter_to_sids and len(filter_to_sids) == 1:
|
||||
sid = next(iter(filter_to_sids))
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.get_connections(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
return await self.conversation_manager.get_connections(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
agent_loops, legacy_agent_loops = await wait_all(
|
||||
[
|
||||
self.conversation_manager.get_connections(user_id, filter_to_sids),
|
||||
self.legacy_conversation_manager.get_connections(
|
||||
user_id, filter_to_sids
|
||||
),
|
||||
]
|
||||
)
|
||||
legacy_agent_loops.update(agent_loops)
|
||||
return legacy_agent_loops
|
||||
|
||||
async def maybe_start_agent_loop(
|
||||
self,
|
||||
sid: str,
|
||||
settings: Settings,
|
||||
user_id: str, # type: ignore[override]
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.maybe_start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
return await self.conversation_manager.maybe_start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
|
||||
async def send_to_event_stream(self, connection_id: str, data: dict):
|
||||
return await self.legacy_conversation_manager.send_to_event_stream(
|
||||
connection_id, data
|
||||
)
|
||||
|
||||
async def send_event_to_conversation(self, sid: str, data: dict):
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
await self.legacy_conversation_manager.send_event_to_conversation(sid, data)
|
||||
await self.conversation_manager.send_event_to_conversation(sid, data)
|
||||
|
||||
async def disconnect_from_session(self, connection_id: str):
|
||||
return await self.legacy_conversation_manager.disconnect_from_session(
|
||||
connection_id
|
||||
)
|
||||
|
||||
async def close_session(self, sid: str):
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
await self.legacy_conversation_manager.close_session(sid)
|
||||
await self.conversation_manager.close_session(sid)
|
||||
|
||||
async def get_agent_loop_info(
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
) -> list[AgentLoopInfo]:
|
||||
if filter_to_sids and len(filter_to_sids) == 1:
|
||||
sid = next(iter(filter_to_sids))
|
||||
if await self.should_start_in_legacy_mode(sid):
|
||||
return await self.legacy_conversation_manager.get_agent_loop_info(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
return await self.conversation_manager.get_agent_loop_info(
|
||||
user_id, filter_to_sids
|
||||
)
|
||||
agent_loops, legacy_agent_loops = await wait_all(
|
||||
[
|
||||
self.conversation_manager.get_agent_loop_info(user_id, filter_to_sids),
|
||||
self.legacy_conversation_manager.get_agent_loop_info(
|
||||
user_id, filter_to_sids
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Combine results
|
||||
result = []
|
||||
legacy_sids = set()
|
||||
|
||||
# Add legacy agent loops
|
||||
for agent_loop in legacy_agent_loops:
|
||||
if await self.should_start_in_legacy_mode(agent_loop.conversation_id):
|
||||
result.append(agent_loop)
|
||||
legacy_sids.add(agent_loop.conversation_id)
|
||||
|
||||
# Add non-legacy agent loops
|
||||
for agent_loop in agent_loops:
|
||||
if (
|
||||
agent_loop.conversation_id not in legacy_sids
|
||||
and not await self.should_start_in_legacy_mode(
|
||||
agent_loop.conversation_id
|
||||
)
|
||||
):
|
||||
result.append(agent_loop)
|
||||
|
||||
return result
|
||||
|
||||
def _cleanup_expired_cache_entries(self):
|
||||
"""Remove expired entries from the local cache."""
|
||||
current_time = time.time()
|
||||
expired_keys = [
|
||||
key
|
||||
for key, entry in self._legacy_cache.items()
|
||||
if current_time - entry.timestamp > _LEGACY_ENTRY_TIMEOUT_SECONDS
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self._legacy_cache[key]
|
||||
|
||||
async def should_start_in_legacy_mode(self, conversation_id: str) -> bool:
|
||||
"""
|
||||
Check if a conversation should run in legacy mode by directly checking the runtime.
|
||||
The /list method does not include stopped conversations even though the PVC for these
|
||||
may not yet have been deleted, so we need to check /sessions/{session_id} directly.
|
||||
"""
|
||||
# Clean up expired entries periodically
|
||||
self._cleanup_expired_cache_entries()
|
||||
|
||||
# First check the local cache
|
||||
if conversation_id in self._legacy_cache:
|
||||
cached_entry = self._legacy_cache[conversation_id]
|
||||
# Check if the cached value is still valid
|
||||
if time.time() - cached_entry.timestamp <= _LEGACY_ENTRY_TIMEOUT_SECONDS:
|
||||
return cached_entry.is_legacy
|
||||
|
||||
# If not in cache or expired, check the runtime directly
|
||||
runtime = await self.conversation_manager._get_runtime(conversation_id)
|
||||
is_legacy = self.is_legacy_runtime(runtime)
|
||||
|
||||
# Cache the result with current timestamp
|
||||
self._legacy_cache[conversation_id] = LegacyCacheEntry(is_legacy, time.time())
|
||||
|
||||
return is_legacy
|
||||
|
||||
def is_legacy_runtime(self, runtime: dict | None) -> bool:
|
||||
"""
|
||||
Determine if a runtime is a legacy runtime based on its command.
|
||||
|
||||
Args:
|
||||
runtime: The runtime dictionary or None if not found
|
||||
|
||||
Returns:
|
||||
bool: True if this is a legacy runtime, False otherwise
|
||||
"""
|
||||
if runtime is None:
|
||||
return False
|
||||
return 'openhands.server' not in runtime['command']
|
||||
|
||||
@classmethod
|
||||
def get_instance(
|
||||
cls,
|
||||
sio: socketio.AsyncServer,
|
||||
config: OpenHandsConfig,
|
||||
file_store: FileStore,
|
||||
server_config: ServerConfig,
|
||||
monitoring_listener: MonitoringListener,
|
||||
) -> ConversationManager:
|
||||
return LegacyConversationManager(
|
||||
sio=sio,
|
||||
config=config,
|
||||
server_config=server_config,
|
||||
file_store=file_store,
|
||||
conversation_manager=SaasNestedConversationManager.get_instance(
|
||||
sio, config, file_store, server_config, monitoring_listener
|
||||
),
|
||||
legacy_conversation_manager=ClusteredConversationManager.get_instance(
|
||||
sio, config, file_store, server_config, monitoring_listener
|
||||
),
|
||||
)
|
||||
@@ -152,22 +152,17 @@ class SetAuthCookieMiddleware:
|
||||
return False
|
||||
path = request.url.path
|
||||
|
||||
ignore_paths = (
|
||||
is_api_that_should_attach = path.startswith('/api') and path not in (
|
||||
'/api/options/config',
|
||||
'/api/keycloak/callback',
|
||||
'/api/billing/success',
|
||||
'/api/billing/cancel',
|
||||
'/api/billing/customer-setup-success',
|
||||
'/api/billing/stripe-webhook',
|
||||
'/oauth/device/authorize',
|
||||
'/oauth/device/token',
|
||||
)
|
||||
if path in ignore_paths:
|
||||
return False
|
||||
|
||||
is_mcp = path.startswith('/mcp')
|
||||
is_api_route = path.startswith('/api')
|
||||
return is_api_route or is_mcp
|
||||
return is_api_that_should_attach or is_mcp
|
||||
|
||||
async def _logout(self, request: Request):
|
||||
# Log out of keycloak - this prevents issues where you did not log in with the idp you believe you used
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
@@ -59,8 +58,7 @@ async def github_events(
|
||||
)
|
||||
|
||||
try:
|
||||
# Add timeout to prevent hanging on slow/stalled clients
|
||||
payload = await asyncio.wait_for(request.body(), timeout=15.0)
|
||||
payload = await request.body()
|
||||
verify_github_signature(payload, x_hub_signature_256)
|
||||
|
||||
payload_data = await request.json()
|
||||
@@ -80,12 +78,6 @@ async def github_events(
|
||||
status_code=200,
|
||||
content={'message': 'GitHub events endpoint reached successfully.'},
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning('GitHub webhook request timed out waiting for request body')
|
||||
return JSONResponse(
|
||||
status_code=408,
|
||||
content={'error': 'Request timeout - client took too long to send data.'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f'Error processing GitHub event: {e}')
|
||||
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
"""OAuth 2.0 Device Flow endpoints for CLI authentication."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.database import session_maker
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEVICE_CODE_EXPIRES_IN = 600 # 10 minutes
|
||||
DEVICE_TOKEN_POLL_INTERVAL = 5 # seconds
|
||||
|
||||
API_KEY_NAME = 'Device Link Access Key'
|
||||
KEY_EXPIRATION_TIME = timedelta(days=1) # Key expires in 24 hours
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceAuthorizationResponse(BaseModel):
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
class DeviceTokenResponse(BaseModel):
|
||||
access_token: str # This will be the user's API key
|
||||
token_type: str = 'Bearer'
|
||||
expires_in: Optional[int] = None # API keys may not have expiration
|
||||
|
||||
|
||||
class DeviceTokenErrorResponse(BaseModel):
|
||||
error: str
|
||||
error_description: Optional[str] = None
|
||||
interval: Optional[int] = None # Required for slow_down error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router + stores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
oauth_device_router = APIRouter(prefix='/oauth/device')
|
||||
device_code_store = DeviceCodeStore(session_maker)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _oauth_error(
|
||||
status_code: int,
|
||||
error: str,
|
||||
description: str,
|
||||
interval: Optional[int] = None,
|
||||
) -> JSONResponse:
|
||||
"""Return a JSON OAuth-style error response."""
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=DeviceTokenErrorResponse(
|
||||
error=error,
|
||||
error_description=description,
|
||||
interval=interval,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@oauth_device_router.post('/authorize', response_model=DeviceAuthorizationResponse)
|
||||
async def device_authorization(
|
||||
http_request: Request,
|
||||
) -> DeviceAuthorizationResponse:
|
||||
"""Start device flow by generating device and user codes."""
|
||||
try:
|
||||
device_code_entry = device_code_store.create_device_code(
|
||||
expires_in=DEVICE_CODE_EXPIRES_IN,
|
||||
)
|
||||
|
||||
base_url = str(http_request.base_url).rstrip('/')
|
||||
verification_uri = f'{base_url}/oauth/device/verify'
|
||||
verification_uri_complete = (
|
||||
f'{verification_uri}?user_code={device_code_entry.user_code}'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Device authorization initiated',
|
||||
extra={'user_code': device_code_entry.user_code},
|
||||
)
|
||||
|
||||
return DeviceAuthorizationResponse(
|
||||
device_code=device_code_entry.device_code,
|
||||
user_code=device_code_entry.user_code,
|
||||
verification_uri=verification_uri,
|
||||
verification_uri_complete=verification_uri_complete,
|
||||
expires_in=DEVICE_CODE_EXPIRES_IN,
|
||||
interval=device_code_entry.current_interval,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Error in device authorization: %s', str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Internal server error',
|
||||
) from e
|
||||
|
||||
|
||||
@oauth_device_router.post('/token')
|
||||
async def device_token(device_code: str = Form(...)):
|
||||
"""Poll for a token until the user authorizes or the code expires."""
|
||||
try:
|
||||
device_code_entry = device_code_store.get_by_device_code(device_code)
|
||||
|
||||
if not device_code_entry:
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'invalid_grant',
|
||||
'Invalid device code',
|
||||
)
|
||||
|
||||
# Check rate limiting (RFC 8628 section 3.5)
|
||||
is_too_fast, current_interval = device_code_entry.check_rate_limit()
|
||||
if is_too_fast:
|
||||
# Update poll time and increase interval
|
||||
device_code_store.update_poll_time(device_code, increase_interval=True)
|
||||
logger.warning(
|
||||
'Client polling too fast, returning slow_down error',
|
||||
extra={
|
||||
'device_code': device_code[:8] + '...', # Log partial for privacy
|
||||
'new_interval': current_interval,
|
||||
},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'slow_down',
|
||||
f'Polling too frequently. Wait at least {current_interval} seconds between requests.',
|
||||
interval=current_interval,
|
||||
)
|
||||
|
||||
# Update poll time for successful rate limit check
|
||||
device_code_store.update_poll_time(device_code, increase_interval=False)
|
||||
|
||||
if device_code_entry.is_expired():
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'expired_token',
|
||||
'Device code has expired',
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'denied':
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'access_denied',
|
||||
'User denied the authorization request',
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'pending':
|
||||
return _oauth_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'authorization_pending',
|
||||
'User has not yet completed authorization',
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'authorized':
|
||||
# Retrieve the specific API key for this device using the user_code
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})'
|
||||
device_api_key = api_key_store.retrieve_api_key_by_name(
|
||||
device_code_entry.keycloak_user_id, device_key_name
|
||||
)
|
||||
|
||||
if not device_api_key:
|
||||
logger.error(
|
||||
'No device API key found for authorized device',
|
||||
extra={
|
||||
'user_id': device_code_entry.keycloak_user_id,
|
||||
'user_code': device_code_entry.user_code,
|
||||
},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'API key not found',
|
||||
)
|
||||
|
||||
# Return the API key as access_token
|
||||
return DeviceTokenResponse(
|
||||
access_token=device_api_key,
|
||||
)
|
||||
|
||||
# Fallback for unexpected status values
|
||||
logger.error(
|
||||
'Unknown device code status',
|
||||
extra={'status': device_code_entry.status},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'Unknown device code status',
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Error in device token: %s', str(e))
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'Internal server error',
|
||||
)
|
||||
|
||||
|
||||
@oauth_device_router.post('/verify-authenticated')
|
||||
async def device_verification_authenticated(
|
||||
user_code: str = Form(...),
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
"""Process device verification for authenticated users (called by frontend)."""
|
||||
try:
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Authentication required',
|
||||
)
|
||||
|
||||
# Validate device code
|
||||
device_code_entry = device_code_store.get_by_user_code(user_code)
|
||||
if not device_code_entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='The device code is invalid or has expired.',
|
||||
)
|
||||
|
||||
if not device_code_entry.is_pending():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This device code has already been processed.',
|
||||
)
|
||||
|
||||
# First, authorize the device code
|
||||
success = device_code_store.authorize_device_code(
|
||||
user_code=user_code,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(
|
||||
'Failed to authorize device code',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to authorize the device. Please try again.',
|
||||
)
|
||||
|
||||
# Only create API key AFTER successful authorization
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
try:
|
||||
# Create a unique API key for this device using user_code in the name
|
||||
device_key_name = f'{API_KEY_NAME} ({user_code})'
|
||||
api_key_store.create_api_key(
|
||||
user_id,
|
||||
name=device_key_name,
|
||||
expires_at=datetime.now(UTC) + KEY_EXPIRATION_TIME,
|
||||
)
|
||||
logger.info(
|
||||
'Created new device API key for user after successful authorization',
|
||||
extra={'user_id': user_id, 'user_code': user_code},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Failed to create device API key after authorization: %s', str(e)
|
||||
)
|
||||
|
||||
# Clean up: revert the device authorization since API key creation failed
|
||||
# This prevents the device from being in an authorized state without an API key
|
||||
try:
|
||||
device_code_store.deny_device_code(user_code)
|
||||
logger.info(
|
||||
'Reverted device authorization due to API key creation failure',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
)
|
||||
except Exception as cleanup_error:
|
||||
logger.exception(
|
||||
'Failed to revert device authorization during cleanup: %s',
|
||||
str(cleanup_error),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create API key for device access.',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Device code authorized with API key successfully',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Device authorized successfully!'},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception('Error in device verification: %s', str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred. Please try again.',
|
||||
)
|
||||
@@ -31,7 +31,6 @@ from openhands.events.event_store import EventStore
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.runtime.plugins.vscode import VSCodeRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.constants import ROOM_KEY
|
||||
@@ -71,14 +70,6 @@ RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + (
|
||||
else '/api/conversations/{conversation_id}'
|
||||
)
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'false')
|
||||
truthy = {'1', 'true', 't', 'yes', 'y', 'on'}
|
||||
SU_TO_USER = str(SU_TO_USER.lower() in truthy).lower()
|
||||
|
||||
DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true'
|
||||
|
||||
# Time in seconds before a Redis entry is considered expired if not refreshed
|
||||
_REDIS_ENTRY_TIMEOUT_SECONDS = 300
|
||||
|
||||
@@ -781,11 +772,7 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
env_vars['SERVE_FRONTEND'] = '0'
|
||||
env_vars['RUNTIME'] = 'local'
|
||||
# TODO: In the long term we may come up with a more secure strategy for user management within the nested runtime.
|
||||
env_vars['USER'] = (
|
||||
RUNTIME_USERNAME
|
||||
if RUNTIME_USERNAME
|
||||
else ('openhands' if config.run_as_openhands else 'root')
|
||||
)
|
||||
env_vars['USER'] = 'openhands' if config.run_as_openhands else 'root'
|
||||
env_vars['PERMITTED_CORS_ORIGINS'] = ','.join(PERMITTED_CORS_ORIGINS)
|
||||
env_vars['port'] = '60000'
|
||||
# TODO: These values are static in the runtime-api project, but do not get copied into the runtime ENV
|
||||
@@ -802,8 +789,6 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
|
||||
env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
|
||||
env_vars['ENABLE_V1'] = '0'
|
||||
env_vars['SU_TO_USER'] = SU_TO_USER
|
||||
env_vars['DISABLE_VSCODE_PLUGIN'] = str(DISABLE_VSCODE_PLUGIN).lower()
|
||||
|
||||
# We need this for LLM traces tracking to identify the source of the LLM calls
|
||||
env_vars['WEB_HOST'] = WEB_HOST
|
||||
@@ -819,18 +804,11 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
if self._runtime_container_image:
|
||||
config.sandbox.runtime_container_image = self._runtime_container_image
|
||||
|
||||
plugins = [
|
||||
plugin
|
||||
for plugin in agent.sandbox_plugins
|
||||
if not (DISABLE_VSCODE_PLUGIN and isinstance(plugin, VSCodeRequirement))
|
||||
]
|
||||
logger.info(f'Loaded plugins for runtime {sid}: {plugins}')
|
||||
|
||||
runtime = RemoteRuntime(
|
||||
config=config,
|
||||
event_stream=None, # type: ignore[arg-type]
|
||||
sid=sid,
|
||||
plugins=plugins,
|
||||
plugins=agent.sandbox_plugins,
|
||||
# env_vars=env_vars,
|
||||
# status_callback: Callable[..., None] | None = None,
|
||||
attach_to_existing=False,
|
||||
|
||||
@@ -17,13 +17,10 @@ from openhands.core.logger import openhands_logger as logger
|
||||
class ApiKeyStore:
|
||||
session_maker: sessionmaker
|
||||
|
||||
API_KEY_PREFIX = 'sk-oh-'
|
||||
|
||||
def generate_api_key(self, length: int = 32) -> str:
|
||||
"""Generate a random API key with the sk-oh- prefix."""
|
||||
"""Generate a random API key."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return f'{self.API_KEY_PREFIX}{random_part}'
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
def create_api_key(
|
||||
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
|
||||
@@ -60,15 +57,9 @@ class ApiKeyStore:
|
||||
return None
|
||||
|
||||
# Check if the key has expired
|
||||
if key_record.expires_at:
|
||||
# Handle timezone-naive datetime from database by assuming it's UTC
|
||||
expires_at = key_record.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
|
||||
if expires_at < now:
|
||||
logger.info(f'API key has expired: {key_record.id}')
|
||||
return None
|
||||
if key_record.expires_at and key_record.expires_at < now:
|
||||
logger.info(f'API key has expired: {key_record.id}')
|
||||
return None
|
||||
|
||||
# Update last_used_at timestamp
|
||||
session.execute(
|
||||
@@ -134,33 +125,6 @@ class ApiKeyStore:
|
||||
|
||||
return None
|
||||
|
||||
def retrieve_api_key_by_name(self, user_id: str, name: str) -> str | None:
|
||||
"""Retrieve an API key by name for a specific user."""
|
||||
with self.session_maker() as session:
|
||||
key_record = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
.first()
|
||||
)
|
||||
return key_record.key if key_record else None
|
||||
|
||||
def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
|
||||
"""Delete an API key by name for a specific user."""
|
||||
with self.session_maker() as session:
|
||||
key_record = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
session.delete(key_record)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> ApiKeyStore:
|
||||
"""Get an instance of the ApiKeyStore."""
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Device code storage model for OAuth 2.0 Device Flow."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class DeviceCodeStatus(Enum):
|
||||
"""Status of a device code authorization request."""
|
||||
|
||||
PENDING = 'pending'
|
||||
AUTHORIZED = 'authorized'
|
||||
EXPIRED = 'expired'
|
||||
DENIED = 'denied'
|
||||
|
||||
|
||||
class DeviceCode(Base):
|
||||
"""Device code for OAuth 2.0 Device Flow.
|
||||
|
||||
This stores the device codes issued during the device authorization flow,
|
||||
along with their status and associated user information once authorized.
|
||||
"""
|
||||
|
||||
__tablename__ = 'device_codes'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
device_code = Column(String(128), unique=True, nullable=False, index=True)
|
||||
user_code = Column(String(16), unique=True, nullable=False, index=True)
|
||||
status = Column(String(32), nullable=False, default=DeviceCodeStatus.PENDING.value)
|
||||
|
||||
# Keycloak user ID who authorized the device (set during verification)
|
||||
keycloak_user_id = Column(String(255), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
authorized_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Rate limiting fields for RFC 8628 section 3.5 compliance
|
||||
last_poll_time = Column(DateTime(timezone=True), nullable=True)
|
||||
current_interval = Column(Integer, nullable=False, default=5)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceCode(user_code='{self.user_code}', status='{self.status}')>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the device code has expired."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return now > self.expires_at
|
||||
|
||||
def is_pending(self) -> bool:
|
||||
"""Check if the device code is still pending authorization."""
|
||||
return self.status == DeviceCodeStatus.PENDING.value and not self.is_expired()
|
||||
|
||||
def is_authorized(self) -> bool:
|
||||
"""Check if the device code has been authorized."""
|
||||
return self.status == DeviceCodeStatus.AUTHORIZED.value
|
||||
|
||||
def authorize(self, user_id: str) -> None:
|
||||
"""Mark the device code as authorized."""
|
||||
self.status = DeviceCodeStatus.AUTHORIZED.value
|
||||
self.keycloak_user_id = user_id # Set the Keycloak user ID during authorization
|
||||
self.authorized_at = datetime.now(timezone.utc)
|
||||
|
||||
def deny(self) -> None:
|
||||
"""Mark the device code as denied."""
|
||||
self.status = DeviceCodeStatus.DENIED.value
|
||||
|
||||
def expire(self) -> None:
|
||||
"""Mark the device code as expired."""
|
||||
self.status = DeviceCodeStatus.EXPIRED.value
|
||||
|
||||
def check_rate_limit(self) -> tuple[bool, int]:
|
||||
"""Check if the client is polling too fast.
|
||||
|
||||
Returns:
|
||||
tuple: (is_too_fast, current_interval)
|
||||
- is_too_fast: True if client should receive slow_down error
|
||||
- current_interval: Current polling interval to use
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# If this is the first poll, allow it
|
||||
if self.last_poll_time is None:
|
||||
return False, self.current_interval
|
||||
|
||||
# Calculate time since last poll
|
||||
time_since_last_poll = (now - self.last_poll_time).total_seconds()
|
||||
|
||||
# Check if polling too fast
|
||||
if time_since_last_poll < self.current_interval:
|
||||
# Increase interval for slow_down (RFC 8628 section 3.5)
|
||||
new_interval = min(self.current_interval + 5, 60) # Cap at 60 seconds
|
||||
return True, new_interval
|
||||
|
||||
return False, self.current_interval
|
||||
|
||||
def update_poll_time(self, increase_interval: bool = False) -> None:
|
||||
"""Update the last poll time and optionally increase the interval.
|
||||
|
||||
Args:
|
||||
increase_interval: If True, increase the current interval for slow_down
|
||||
"""
|
||||
self.last_poll_time = datetime.now(timezone.utc)
|
||||
|
||||
if increase_interval:
|
||||
# Increase interval by 5 seconds, cap at 60 seconds (RFC 8628)
|
||||
self.current_interval = min(self.current_interval + 5, 60)
|
||||
@@ -1,167 +0,0 @@
|
||||
"""Device code store for OAuth 2.0 Device Flow."""
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.device_code import DeviceCode
|
||||
|
||||
|
||||
class DeviceCodeStore:
|
||||
"""Store for managing OAuth 2.0 device codes."""
|
||||
|
||||
def __init__(self, session_maker):
|
||||
self.session_maker = session_maker
|
||||
|
||||
def generate_user_code(self) -> str:
|
||||
"""Generate a human-readable user code (8 characters, uppercase letters and digits)."""
|
||||
# Use a mix of uppercase letters and digits, avoiding confusing characters
|
||||
alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # No I, O, 0, 1
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
def generate_device_code(self) -> str:
|
||||
"""Generate a secure device code (128 characters)."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(128))
|
||||
|
||||
def create_device_code(
|
||||
self,
|
||||
expires_in: int = 600, # 10 minutes default
|
||||
max_attempts: int = 10,
|
||||
) -> DeviceCode:
|
||||
"""Create a new device code entry.
|
||||
|
||||
Uses database constraints to ensure uniqueness, avoiding TOCTOU race conditions.
|
||||
Retries on constraint violations until unique codes are generated.
|
||||
|
||||
Args:
|
||||
expires_in: Expiration time in seconds
|
||||
max_attempts: Maximum number of attempts to generate unique codes
|
||||
|
||||
Returns:
|
||||
The created DeviceCode instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If unable to generate unique codes after max_attempts
|
||||
"""
|
||||
for attempt in range(max_attempts):
|
||||
user_code = self.generate_user_code()
|
||||
device_code = self.generate_device_code()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
||||
|
||||
device_code_entry = DeviceCode(
|
||||
device_code=device_code,
|
||||
user_code=user_code,
|
||||
keycloak_user_id=None, # Will be set during authorization
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
try:
|
||||
with self.session_maker() as session:
|
||||
session.add(device_code_entry)
|
||||
session.commit()
|
||||
session.refresh(device_code_entry)
|
||||
session.expunge(device_code_entry) # Detach from session cleanly
|
||||
return device_code_entry
|
||||
except IntegrityError:
|
||||
# Constraint violation - codes already exist, retry with new codes
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
f'Failed to generate unique device codes after {max_attempts} attempts'
|
||||
)
|
||||
|
||||
def get_by_device_code(self, device_code: str) -> DeviceCode | None:
|
||||
"""Get device code entry by device code."""
|
||||
with self.session_maker() as session:
|
||||
result = (
|
||||
session.query(DeviceCode).filter_by(device_code=device_code).first()
|
||||
)
|
||||
if result:
|
||||
session.expunge(result) # Detach from session cleanly
|
||||
return result
|
||||
|
||||
def get_by_user_code(self, user_code: str) -> DeviceCode | None:
|
||||
"""Get device code entry by user code."""
|
||||
with self.session_maker() as session:
|
||||
result = session.query(DeviceCode).filter_by(user_code=user_code).first()
|
||||
if result:
|
||||
session.expunge(result) # Detach from session cleanly
|
||||
return result
|
||||
|
||||
def authorize_device_code(self, user_code: str, user_id: str) -> bool:
|
||||
"""Authorize a device code.
|
||||
|
||||
Args:
|
||||
user_code: The user code to authorize
|
||||
user_id: The user ID from Keycloak
|
||||
|
||||
Returns:
|
||||
True if authorization was successful, False otherwise
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
device_code_entry = (
|
||||
session.query(DeviceCode).filter_by(user_code=user_code).first()
|
||||
)
|
||||
|
||||
if not device_code_entry:
|
||||
return False
|
||||
|
||||
if not device_code_entry.is_pending():
|
||||
return False
|
||||
|
||||
device_code_entry.authorize(user_id)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
def deny_device_code(self, user_code: str) -> bool:
|
||||
"""Deny a device code authorization.
|
||||
|
||||
Args:
|
||||
user_code: The user code to deny
|
||||
|
||||
Returns:
|
||||
True if denial was successful, False otherwise
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
device_code_entry = (
|
||||
session.query(DeviceCode).filter_by(user_code=user_code).first()
|
||||
)
|
||||
|
||||
if not device_code_entry:
|
||||
return False
|
||||
|
||||
if not device_code_entry.is_pending():
|
||||
return False
|
||||
|
||||
device_code_entry.deny()
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
def update_poll_time(
|
||||
self, device_code: str, increase_interval: bool = False
|
||||
) -> bool:
|
||||
"""Update the poll time for a device code and optionally increase interval.
|
||||
|
||||
Args:
|
||||
device_code: The device code to update
|
||||
increase_interval: If True, increase the polling interval for slow_down
|
||||
|
||||
Returns:
|
||||
True if update was successful, False otherwise
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
device_code_entry = (
|
||||
session.query(DeviceCode).filter_by(device_code=device_code).first()
|
||||
)
|
||||
|
||||
if not device_code_entry:
|
||||
return False
|
||||
|
||||
device_code_entry.update_poll_time(increase_interval)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
@@ -94,7 +94,6 @@ class SaasSettingsStore(SettingsStore):
|
||||
}
|
||||
self._decrypt_kwargs(kwargs)
|
||||
settings = Settings(**kwargs)
|
||||
|
||||
return settings
|
||||
|
||||
async def store(self, item: Settings):
|
||||
|
||||
@@ -12,7 +12,6 @@ from storage.base import Base
|
||||
# Anything not loaded here may not have a table created for it.
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.conversation_work import ConversationWork
|
||||
from storage.device_code import DeviceCode # noqa: F401
|
||||
from storage.feedback import Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
"""Test for ResolverUserContext get_secrets conversion logic.
|
||||
|
||||
This test focuses on testing the actual ResolverUserContext implementation.
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from enterprise.integrations.resolver_context import ResolverUserContext
|
||||
|
||||
# Import the real classes we want to test
|
||||
from openhands.integrations.provider import CustomSecret
|
||||
|
||||
# Import the SDK types we need for testing
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_saas_user_auth():
|
||||
"""Mock SaasUserAuth for testing."""
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver_context(mock_saas_user_auth):
|
||||
"""Create a ResolverUserContext instance for testing."""
|
||||
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
|
||||
|
||||
|
||||
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
|
||||
"""Helper to create CustomSecret instances."""
|
||||
return CustomSecret(secret=SecretStr(value), description=description)
|
||||
|
||||
|
||||
def create_secrets(custom_secrets_dict: dict[str, CustomSecret]) -> Secrets:
|
||||
"""Helper to create Secrets instances."""
|
||||
return Secrets(custom_secrets=MappingProxyType(custom_secrets_dict))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_secrets_converts_custom_to_static(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that get_secrets correctly converts CustomSecret objects to StaticSecret objects."""
|
||||
# Arrange
|
||||
secrets = create_secrets(
|
||||
{
|
||||
'TEST_SECRET_1': create_custom_secret('secret_value_1'),
|
||||
'TEST_SECRET_2': create_custom_secret('secret_value_2'),
|
||||
}
|
||||
)
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(secret, StaticSecret) for secret in result.values())
|
||||
assert result['TEST_SECRET_1'].value.get_secret_value() == 'secret_value_1'
|
||||
assert result['TEST_SECRET_2'].value.get_secret_value() == 'secret_value_2'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_secrets_with_special_characters(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that secret values with special characters are preserved during conversion."""
|
||||
# Arrange
|
||||
special_value = 'very_secret_password_123!@#$%^&*()'
|
||||
secrets = create_secrets({'SPECIAL_SECRET': create_custom_secret(special_value)})
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert isinstance(result['SPECIAL_SECRET'], StaticSecret)
|
||||
assert result['SPECIAL_SECRET'].value.get_secret_value() == special_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'secrets_input,expected_result',
|
||||
[
|
||||
(None, {}), # No secrets available
|
||||
(create_secrets({}), {}), # Empty custom secrets
|
||||
],
|
||||
)
|
||||
async def test_get_secrets_empty_cases(
|
||||
resolver_context, mock_saas_user_auth, secrets_input, expected_result
|
||||
):
|
||||
"""Test that get_secrets handles empty cases correctly."""
|
||||
# Arrange
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets_input
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
def test_static_secret_is_valid_secret_source():
|
||||
"""Test that StaticSecret is a valid SecretSource for SDK validation."""
|
||||
# Arrange & Act
|
||||
static_secret = StaticSecret(value='test_secret_123')
|
||||
|
||||
# Assert
|
||||
assert isinstance(static_secret, StaticSecret)
|
||||
assert isinstance(static_secret, SecretSource)
|
||||
assert static_secret.value.get_secret_value() == 'test_secret_123'
|
||||
|
||||
|
||||
def test_custom_to_static_conversion():
|
||||
"""Test the complete conversion flow from CustomSecret to StaticSecret."""
|
||||
# Arrange
|
||||
secret_value = 'conversion_test_secret'
|
||||
custom_secret = create_custom_secret(secret_value, 'Conversion test')
|
||||
|
||||
# Act - simulate the conversion logic from the actual method
|
||||
extracted_value = custom_secret.secret.get_secret_value()
|
||||
static_secret = StaticSecret(value=extracted_value)
|
||||
|
||||
# Assert
|
||||
assert isinstance(static_secret, StaticSecret)
|
||||
assert isinstance(static_secret, SecretSource)
|
||||
assert static_secret.value.get_secret_value() == secret_value
|
||||
@@ -1,610 +0,0 @@
|
||||
"""Unit tests for OAuth2 Device Flow endpoints."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from server.routes.oauth_device import (
|
||||
device_authorization,
|
||||
device_token,
|
||||
device_verification_authenticated,
|
||||
)
|
||||
from storage.device_code import DeviceCode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_code_store():
|
||||
"""Mock device code store."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_key_store():
|
||||
"""Mock API key store."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_token_manager():
|
||||
"""Mock token manager."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Mock FastAPI request."""
|
||||
request = MagicMock(spec=Request)
|
||||
request.base_url = 'https://test.example.com/'
|
||||
return request
|
||||
|
||||
|
||||
class TestDeviceAuthorization:
|
||||
"""Test device authorization endpoint."""
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_device_authorization_success(self, mock_store, mock_request):
|
||||
"""Test successful device authorization."""
|
||||
mock_device = DeviceCode(
|
||||
device_code='test-device-code-123',
|
||||
user_code='ABC12345',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
current_interval=5, # Default interval
|
||||
)
|
||||
mock_store.create_device_code.return_value = mock_device
|
||||
|
||||
result = await device_authorization(mock_request)
|
||||
|
||||
assert result.device_code == 'test-device-code-123'
|
||||
assert result.user_code == 'ABC12345'
|
||||
assert result.expires_in == 600
|
||||
assert result.interval == 5 # Should match device's current_interval
|
||||
assert 'verify' in result.verification_uri
|
||||
assert 'ABC12345' in result.verification_uri_complete
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_device_authorization_with_increased_interval(
|
||||
self, mock_store, mock_request
|
||||
):
|
||||
"""Test device authorization returns increased interval from rate limiting."""
|
||||
mock_device = DeviceCode(
|
||||
device_code='test-device-code-456',
|
||||
user_code='XYZ98765',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
current_interval=15, # Increased interval from previous rate limiting
|
||||
)
|
||||
mock_store.create_device_code.return_value = mock_device
|
||||
|
||||
result = await device_authorization(mock_request)
|
||||
|
||||
assert result.device_code == 'test-device-code-456'
|
||||
assert result.user_code == 'XYZ98765'
|
||||
assert result.expires_in == 600
|
||||
assert result.interval == 15 # Should match device's increased current_interval
|
||||
assert 'verify' in result.verification_uri
|
||||
assert 'XYZ98765' in result.verification_uri_complete
|
||||
|
||||
|
||||
class TestDeviceToken:
|
||||
"""Test device token endpoint."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'device_exists,status,expected_error',
|
||||
[
|
||||
(False, None, 'invalid_grant'),
|
||||
(True, 'expired', 'expired_token'),
|
||||
(True, 'denied', 'access_denied'),
|
||||
(True, 'pending', 'authorization_pending'),
|
||||
],
|
||||
)
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_device_token_error_cases(
|
||||
self, mock_store, device_exists, status, expected_error
|
||||
):
|
||||
"""Test various error cases for device token endpoint."""
|
||||
device_code = 'test-device-code'
|
||||
|
||||
if device_exists:
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_expired.return_value = status == 'expired'
|
||||
mock_device.status = status
|
||||
# Mock rate limiting - return False (not too fast) and default interval
|
||||
mock_device.check_rate_limit.return_value = (False, 5)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
else:
|
||||
mock_store.get_by_device_code.return_value = None
|
||||
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
# Check error in response content
|
||||
content = result.body.decode()
|
||||
assert expected_error in content
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_device_token_success(self, mock_store, mock_api_key_class):
|
||||
"""Test successful device token retrieval."""
|
||||
device_code = 'test-device-code'
|
||||
|
||||
# Mock authorized device
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_expired.return_value = False
|
||||
mock_device.status = 'authorized'
|
||||
mock_device.keycloak_user_id = 'user-123'
|
||||
mock_device.user_code = (
|
||||
'ABC12345' # Add user_code for device-specific API key lookup
|
||||
)
|
||||
# Mock rate limiting - return False (not too fast) and default interval
|
||||
mock_device.check_rate_limit.return_value = (False, 5)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
# Mock API key retrieval
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.retrieve_api_key_by_name.return_value = 'test-api-key'
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Check that result is a DeviceTokenResponse
|
||||
assert result.access_token == 'test-api-key'
|
||||
assert result.token_type == 'Bearer'
|
||||
|
||||
# Verify that the correct device-specific API key name was used
|
||||
mock_api_key_store.retrieve_api_key_by_name.assert_called_once_with(
|
||||
'user-123', 'Device Link Access Key (ABC12345)'
|
||||
)
|
||||
|
||||
|
||||
class TestDeviceVerificationAuthenticated:
|
||||
"""Test device verification authenticated endpoint."""
|
||||
|
||||
async def test_verification_unauthenticated_user(self):
|
||||
"""Test verification with unauthenticated user."""
|
||||
with pytest.raises(HTTPException):
|
||||
await device_verification_authenticated(user_code='ABC12345', user_id=None)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_verification_invalid_device_code(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test verification with invalid device code."""
|
||||
mock_store.get_by_user_code.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await device_verification_authenticated(
|
||||
user_code='INVALID', user_id='user-123'
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_verification_already_processed(self, mock_store, mock_api_key_class):
|
||||
"""Test verification with already processed device code."""
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = False
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_verification_success(self, mock_store, mock_api_key_class):
|
||||
"""Test successful device verification."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = True
|
||||
|
||||
# Mock API key store
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
result = await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 200
|
||||
# Should NOT delete existing API keys (multiple devices allowed)
|
||||
mock_api_key_store.delete_api_key_by_name.assert_not_called()
|
||||
# Should create a new API key with device-specific name
|
||||
mock_api_key_store.create_api_key.assert_called_once()
|
||||
call_args = mock_api_key_store.create_api_key.call_args
|
||||
assert call_args[1]['name'] == 'Device Link Access Key (ABC12345)'
|
||||
mock_store.authorize_device_code.assert_called_once_with(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_multiple_device_authentication(self, mock_store, mock_api_key_class):
|
||||
"""Test that multiple devices can authenticate simultaneously."""
|
||||
# Mock API key store
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
# Simulate two different devices
|
||||
device1_code = 'ABC12345'
|
||||
device2_code = 'XYZ67890'
|
||||
user_id = 'user-123'
|
||||
|
||||
# Mock device codes
|
||||
mock_device1 = MagicMock()
|
||||
mock_device1.is_pending.return_value = True
|
||||
mock_device2 = MagicMock()
|
||||
mock_device2.is_pending.return_value = True
|
||||
|
||||
# Configure mock store to return appropriate device for each user_code
|
||||
def get_by_user_code_side_effect(user_code):
|
||||
if user_code == device1_code:
|
||||
return mock_device1
|
||||
elif user_code == device2_code:
|
||||
return mock_device2
|
||||
return None
|
||||
|
||||
mock_store.get_by_user_code.side_effect = get_by_user_code_side_effect
|
||||
mock_store.authorize_device_code.return_value = True
|
||||
|
||||
# Authenticate first device
|
||||
result1 = await device_verification_authenticated(
|
||||
user_code=device1_code, user_id=user_id
|
||||
)
|
||||
|
||||
# Authenticate second device
|
||||
result2 = await device_verification_authenticated(
|
||||
user_code=device2_code, user_id=user_id
|
||||
)
|
||||
|
||||
# Both should succeed
|
||||
assert isinstance(result1, JSONResponse)
|
||||
assert result1.status_code == 200
|
||||
assert isinstance(result2, JSONResponse)
|
||||
assert result2.status_code == 200
|
||||
|
||||
# Should create two separate API keys with different names
|
||||
assert mock_api_key_store.create_api_key.call_count == 2
|
||||
|
||||
# Check that each device got a unique API key name
|
||||
call_args_list = mock_api_key_store.create_api_key.call_args_list
|
||||
device1_name = call_args_list[0][1]['name']
|
||||
device2_name = call_args_list[1][1]['name']
|
||||
|
||||
assert device1_name == f'Device Link Access Key ({device1_code})'
|
||||
assert device2_name == f'Device Link Access Key ({device2_code})'
|
||||
assert device1_name != device2_name # Ensure they're different
|
||||
|
||||
# Should NOT delete any existing API keys
|
||||
mock_api_key_store.delete_api_key_by_name.assert_not_called()
|
||||
|
||||
|
||||
class TestDeviceTokenRateLimiting:
|
||||
"""Test rate limiting for device token polling (RFC 8628 section 3.5)."""
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_first_poll_allowed(self, mock_store):
|
||||
"""Test that the first poll is always allowed."""
|
||||
# Create a device code with no previous poll time
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=None, # First poll
|
||||
current_interval=5,
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return authorization_pending, not slow_down
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'authorization_pending' in content
|
||||
assert 'slow_down' not in content
|
||||
|
||||
# Should update poll time without increasing interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=False
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_normal_polling_allowed(self, mock_store):
|
||||
"""Test that normal polling (respecting interval) is allowed."""
|
||||
# Create a device code with last poll time 6 seconds ago (interval is 5)
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=6)
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=5,
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return authorization_pending, not slow_down
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'authorization_pending' in content
|
||||
assert 'slow_down' not in content
|
||||
|
||||
# Should update poll time without increasing interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=False
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_fast_polling_returns_slow_down(self, mock_store):
|
||||
"""Test that polling too fast returns slow_down error."""
|
||||
# Create a device code with last poll time 2 seconds ago (interval is 5)
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=2)
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=5,
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return slow_down error
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'slow_down' in content
|
||||
assert 'interval' in content
|
||||
assert '10' in content # New interval should be 5 + 5 = 10
|
||||
|
||||
# Should update poll time and increase interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=True
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_interval_increases_with_repeated_fast_polling(self, mock_store):
|
||||
"""Test that interval increases with repeated fast polling."""
|
||||
# Create a device code with higher current interval from previous slow_down
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=5) # 5 seconds ago
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=15, # Already increased from previous slow_down
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return slow_down error with increased interval
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'slow_down' in content
|
||||
assert '20' in content # New interval should be 15 + 5 = 20
|
||||
|
||||
# Should update poll time and increase interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=True
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_interval_caps_at_maximum(self, mock_store):
|
||||
"""Test that interval is capped at maximum value."""
|
||||
# Create a device code with interval near maximum
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=30)
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='pending',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=58, # Near maximum of 60
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should return slow_down error with capped interval
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'slow_down' in content
|
||||
assert '60' in content # Should be capped at 60, not 63
|
||||
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_rate_limiting_with_authorized_device(self, mock_store):
|
||||
"""Test that rate limiting still applies to authorized devices."""
|
||||
# Create an authorized device code with recent poll
|
||||
last_poll = datetime.now(UTC) - timedelta(seconds=2)
|
||||
mock_device = DeviceCode(
|
||||
device_code='test_device_code',
|
||||
user_code='ABC123',
|
||||
status='authorized', # Device is authorized
|
||||
keycloak_user_id='user123',
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=10),
|
||||
last_poll_time=last_poll,
|
||||
current_interval=5,
|
||||
)
|
||||
mock_store.get_by_device_code.return_value = mock_device
|
||||
mock_store.update_poll_time.return_value = True
|
||||
|
||||
device_code = 'test_device_code'
|
||||
result = await device_token(device_code=device_code)
|
||||
|
||||
# Should still return slow_down error even for authorized device
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
content = result.body.decode()
|
||||
assert 'slow_down' in content
|
||||
|
||||
# Should update poll time and increase interval
|
||||
mock_store.update_poll_time.assert_called_with(
|
||||
'test_device_code', increase_interval=True
|
||||
)
|
||||
|
||||
|
||||
class TestDeviceVerificationTransactionIntegrity:
|
||||
"""Test transaction integrity for device verification to prevent orphaned API keys."""
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_authorization_failure_prevents_api_key_creation(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test that if device authorization fails, no API key is created."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = False # Authorization fails
|
||||
|
||||
# Mock API key store
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
# Should raise HTTPException due to authorization failure
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to authorize the device' in exc_info.value.detail
|
||||
|
||||
# API key should NOT be created since authorization failed
|
||||
mock_api_key_store.create_api_key.assert_not_called()
|
||||
mock_store.authorize_device_code.assert_called_once_with(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_api_key_creation_failure_reverts_authorization(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test that if API key creation fails after authorization, the authorization is reverted."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = True # Authorization succeeds
|
||||
mock_store.deny_device_code.return_value = True # Cleanup succeeds
|
||||
|
||||
# Mock API key store to fail on creation
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.create_api_key.side_effect = Exception('Database error')
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
# Should raise HTTPException due to API key creation failure
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to create API key for device access' in exc_info.value.detail
|
||||
|
||||
# Authorization should have been attempted first
|
||||
mock_store.authorize_device_code.assert_called_once_with(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
# API key creation should have been attempted after authorization
|
||||
mock_api_key_store.create_api_key.assert_called_once()
|
||||
|
||||
# Authorization should be reverted due to API key creation failure
|
||||
mock_store.deny_device_code.assert_called_once_with('ABC12345')
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_api_key_creation_failure_cleanup_failure_logged(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test that cleanup failure is logged but doesn't prevent the main error from being raised."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = True # Authorization succeeds
|
||||
mock_store.deny_device_code.side_effect = Exception(
|
||||
'Cleanup failed'
|
||||
) # Cleanup fails
|
||||
|
||||
# Mock API key store to fail on creation
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.create_api_key.side_effect = Exception('Database error')
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
# Should still raise HTTPException for the original API key creation failure
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to create API key for device access' in exc_info.value.detail
|
||||
|
||||
# Both operations should have been attempted
|
||||
mock_store.authorize_device_code.assert_called_once()
|
||||
mock_api_key_store.create_api_key.assert_called_once()
|
||||
mock_store.deny_device_code.assert_called_once_with('ABC12345')
|
||||
|
||||
@patch('server.routes.oauth_device.ApiKeyStore')
|
||||
@patch('server.routes.oauth_device.device_code_store')
|
||||
async def test_successful_flow_creates_api_key_after_authorization(
|
||||
self, mock_store, mock_api_key_class
|
||||
):
|
||||
"""Test that in the successful flow, API key is created only after authorization."""
|
||||
# Mock device code
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_store.get_by_user_code.return_value = mock_device
|
||||
mock_store.authorize_device_code.return_value = True # Authorization succeeds
|
||||
|
||||
# Mock API key store
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_class.get_instance.return_value = mock_api_key_store
|
||||
|
||||
result = await device_verification_authenticated(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 200
|
||||
|
||||
# Verify the order: authorization first, then API key creation
|
||||
mock_store.authorize_device_code.assert_called_once_with(
|
||||
user_code='ABC12345', user_id='user-123'
|
||||
)
|
||||
mock_api_key_store.create_api_key.assert_called_once()
|
||||
|
||||
# No cleanup should be needed in successful case
|
||||
mock_store.deny_device_code.assert_not_called()
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Unit tests for DeviceCode model."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from storage.device_code import DeviceCode, DeviceCodeStatus
|
||||
|
||||
|
||||
class TestDeviceCode:
|
||||
"""Test cases for DeviceCode model."""
|
||||
|
||||
@pytest.fixture
|
||||
def device_code(self):
|
||||
"""Create a test device code."""
|
||||
return DeviceCode(
|
||||
device_code='test-device-code-123',
|
||||
user_code='ABC12345',
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'expires_delta,expected',
|
||||
[
|
||||
(timedelta(minutes=5), False), # Future expiry
|
||||
(timedelta(minutes=-5), True), # Past expiry
|
||||
(timedelta(seconds=1), False), # Just future (not expired)
|
||||
],
|
||||
)
|
||||
def test_is_expired(self, expires_delta, expected):
|
||||
"""Test expiration check with various time deltas."""
|
||||
device_code = DeviceCode(
|
||||
device_code='test-device-code',
|
||||
user_code='ABC12345',
|
||||
expires_at=datetime.now(timezone.utc) + expires_delta,
|
||||
)
|
||||
assert device_code.is_expired() == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'status,expired,expected',
|
||||
[
|
||||
(DeviceCodeStatus.PENDING.value, False, True),
|
||||
(DeviceCodeStatus.PENDING.value, True, False),
|
||||
(DeviceCodeStatus.AUTHORIZED.value, False, False),
|
||||
(DeviceCodeStatus.DENIED.value, False, False),
|
||||
],
|
||||
)
|
||||
def test_is_pending(self, status, expired, expected):
|
||||
"""Test pending status check."""
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
if expired
|
||||
else datetime.now(timezone.utc) + timedelta(minutes=10)
|
||||
)
|
||||
device_code = DeviceCode(
|
||||
device_code='test-device-code',
|
||||
user_code='ABC12345',
|
||||
status=status,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
assert device_code.is_pending() == expected
|
||||
|
||||
def test_authorize(self, device_code):
|
||||
"""Test device authorization."""
|
||||
user_id = 'test-user-123'
|
||||
|
||||
device_code.authorize(user_id)
|
||||
|
||||
assert device_code.status == DeviceCodeStatus.AUTHORIZED.value
|
||||
assert device_code.keycloak_user_id == user_id
|
||||
assert device_code.authorized_at is not None
|
||||
assert isinstance(device_code.authorized_at, datetime)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'method,expected_status',
|
||||
[
|
||||
('deny', DeviceCodeStatus.DENIED.value),
|
||||
('expire', DeviceCodeStatus.EXPIRED.value),
|
||||
],
|
||||
)
|
||||
def test_status_changes(self, device_code, method, expected_status):
|
||||
"""Test status change methods."""
|
||||
getattr(device_code, method)()
|
||||
assert device_code.status == expected_status
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Unit tests for DeviceCodeStore."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.device_code import DeviceCode
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Mock database session."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_maker(mock_session):
|
||||
"""Mock session maker."""
|
||||
session_maker = MagicMock()
|
||||
session_maker.return_value.__enter__.return_value = mock_session
|
||||
session_maker.return_value.__exit__.return_value = None
|
||||
return session_maker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_code_store(mock_session_maker):
|
||||
"""Create DeviceCodeStore instance."""
|
||||
return DeviceCodeStore(mock_session_maker)
|
||||
|
||||
|
||||
class TestDeviceCodeStore:
|
||||
"""Test cases for DeviceCodeStore."""
|
||||
|
||||
def test_generate_user_code(self, device_code_store):
|
||||
"""Test user code generation."""
|
||||
code = device_code_store.generate_user_code()
|
||||
|
||||
assert len(code) == 8
|
||||
assert code.isupper()
|
||||
# Should not contain confusing characters
|
||||
assert not any(char in code for char in 'IO01')
|
||||
|
||||
def test_generate_device_code(self, device_code_store):
|
||||
"""Test device code generation."""
|
||||
code = device_code_store.generate_device_code()
|
||||
|
||||
assert len(code) == 128
|
||||
assert code.isalnum()
|
||||
|
||||
def test_create_device_code_success(self, device_code_store, mock_session):
|
||||
"""Test successful device code creation."""
|
||||
# Mock successful creation (no IntegrityError)
|
||||
mock_device_code = MagicMock(spec=DeviceCode)
|
||||
mock_device_code.device_code = 'test-device-code-123'
|
||||
mock_device_code.user_code = 'TESTCODE'
|
||||
|
||||
# Mock the session to return our mock device code after refresh
|
||||
def mock_refresh(obj):
|
||||
obj.device_code = mock_device_code.device_code
|
||||
obj.user_code = mock_device_code.user_code
|
||||
|
||||
mock_session.refresh.side_effect = mock_refresh
|
||||
|
||||
result = device_code_store.create_device_code(expires_in=600)
|
||||
|
||||
assert isinstance(result, DeviceCode)
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_session.refresh.assert_called_once()
|
||||
mock_session.expunge.assert_called_once()
|
||||
|
||||
def test_create_device_code_with_retries(
|
||||
self, device_code_store, mock_session_maker
|
||||
):
|
||||
"""Test device code creation with constraint violation retries."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session_maker.return_value.__exit__.return_value = None
|
||||
|
||||
# First attempt fails with IntegrityError, second succeeds
|
||||
mock_session.commit.side_effect = [IntegrityError('', '', ''), None]
|
||||
|
||||
mock_device_code = MagicMock(spec=DeviceCode)
|
||||
mock_device_code.device_code = 'test-device-code-456'
|
||||
mock_device_code.user_code = 'TESTCD2'
|
||||
|
||||
def mock_refresh(obj):
|
||||
obj.device_code = mock_device_code.device_code
|
||||
obj.user_code = mock_device_code.user_code
|
||||
|
||||
mock_session.refresh.side_effect = mock_refresh
|
||||
|
||||
store = DeviceCodeStore(mock_session_maker)
|
||||
result = store.create_device_code(expires_in=600)
|
||||
|
||||
assert isinstance(result, DeviceCode)
|
||||
assert mock_session.add.call_count == 2 # Two attempts
|
||||
assert mock_session.commit.call_count == 2 # Two attempts
|
||||
|
||||
def test_create_device_code_max_attempts_exceeded(
|
||||
self, device_code_store, mock_session_maker
|
||||
):
|
||||
"""Test device code creation failure after max attempts."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session_maker.return_value.__exit__.return_value = None
|
||||
|
||||
# All attempts fail with IntegrityError
|
||||
mock_session.commit.side_effect = IntegrityError('', '', '')
|
||||
|
||||
store = DeviceCodeStore(mock_session_maker)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match='Failed to generate unique device codes after 3 attempts',
|
||||
):
|
||||
store.create_device_code(expires_in=600, max_attempts=3)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'lookup_method,lookup_field',
|
||||
[
|
||||
('get_by_device_code', 'device_code'),
|
||||
('get_by_user_code', 'user_code'),
|
||||
],
|
||||
)
|
||||
def test_lookup_methods(
|
||||
self, device_code_store, mock_session, lookup_method, lookup_field
|
||||
):
|
||||
"""Test device code lookup methods."""
|
||||
test_code = 'test-code-123'
|
||||
mock_device_code = MagicMock()
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
mock_device_code
|
||||
)
|
||||
|
||||
result = getattr(device_code_store, lookup_method)(test_code)
|
||||
|
||||
assert result == mock_device_code
|
||||
mock_session.query.assert_called_once_with(DeviceCode)
|
||||
mock_session.query.return_value.filter_by.assert_called_once_with(
|
||||
**{lookup_field: test_code}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'device_exists,is_pending,expected_result',
|
||||
[
|
||||
(True, True, True), # Success case
|
||||
(False, True, False), # Device not found
|
||||
(True, False, False), # Device not pending
|
||||
],
|
||||
)
|
||||
def test_authorize_device_code(
|
||||
self,
|
||||
device_code_store,
|
||||
mock_session,
|
||||
device_exists,
|
||||
is_pending,
|
||||
expected_result,
|
||||
):
|
||||
"""Test device code authorization."""
|
||||
user_code = 'ABC12345'
|
||||
user_id = 'test-user-123'
|
||||
|
||||
if device_exists:
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = is_pending
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_device
|
||||
else:
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
result = device_code_store.authorize_device_code(user_code, user_id)
|
||||
|
||||
assert result == expected_result
|
||||
if expected_result:
|
||||
mock_device.authorize.assert_called_once_with(user_id)
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_deny_device_code(self, device_code_store, mock_session):
|
||||
"""Test device code denial."""
|
||||
user_code = 'ABC12345'
|
||||
mock_device = MagicMock()
|
||||
mock_device.is_pending.return_value = True
|
||||
mock_session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
mock_device
|
||||
)
|
||||
|
||||
result = device_code_store.deny_device_code(user_code)
|
||||
|
||||
assert result is True
|
||||
mock_device.deny.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
@@ -25,12 +25,10 @@ def api_key_store(mock_session_maker):
|
||||
|
||||
|
||||
def test_generate_api_key(api_key_store):
|
||||
"""Test that generate_api_key returns a string with sk-oh- prefix and expected length."""
|
||||
"""Test that generate_api_key returns a string of the expected length."""
|
||||
key = api_key_store.generate_api_key(length=32)
|
||||
assert isinstance(key, str)
|
||||
assert key.startswith('sk-oh-')
|
||||
# Total length should be prefix (6 chars) + random part (32 chars) = 38 chars
|
||||
assert len(key) == len('sk-oh-') + 32
|
||||
assert len(key) == 32
|
||||
|
||||
|
||||
def test_create_api_key(api_key_store, mock_session):
|
||||
@@ -92,50 +90,6 @@ def test_validate_api_key_expired(api_key_store, mock_session):
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_api_key_expired_timezone_naive(api_key_store, mock_session):
|
||||
"""Test validating an expired API key with timezone-naive datetime from database."""
|
||||
# Setup
|
||||
api_key = 'test-api-key'
|
||||
mock_key_record = MagicMock()
|
||||
# Simulate timezone-naive datetime as returned from database
|
||||
mock_key_record.expires_at = datetime.now() - timedelta(days=1) # No UTC timezone
|
||||
mock_key_record.id = 1
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = (
|
||||
mock_key_record
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = api_key_store.validate_api_key(api_key)
|
||||
|
||||
# Verify
|
||||
assert result is None
|
||||
mock_session.execute.assert_not_called()
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_api_key_valid_timezone_naive(api_key_store, mock_session):
|
||||
"""Test validating a valid API key with timezone-naive datetime from database."""
|
||||
# Setup
|
||||
api_key = 'test-api-key'
|
||||
user_id = 'test-user-123'
|
||||
mock_key_record = MagicMock()
|
||||
mock_key_record.user_id = user_id
|
||||
# Simulate timezone-naive datetime as returned from database (future date)
|
||||
mock_key_record.expires_at = datetime.now() + timedelta(days=1) # No UTC timezone
|
||||
mock_key_record.id = 1
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = (
|
||||
mock_key_record
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = api_key_store.validate_api_key(api_key)
|
||||
|
||||
# Verify
|
||||
assert result == user_id
|
||||
mock_session.execute.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_validate_api_key_not_found(api_key_store, mock_session):
|
||||
"""Test validating a non-existent API key."""
|
||||
# Setup
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Unit tests for get_user_v1_enabled_setting function."""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.github.github_view import get_user_v1_enabled_setting
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_settings():
|
||||
"""Create a mock user settings object."""
|
||||
settings = MagicMock()
|
||||
settings.v1_enabled = True # Default to True, can be overridden in tests
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings_store(mock_user_settings):
|
||||
"""Create a mock settings store."""
|
||||
store = MagicMock()
|
||||
store.get_user_settings_by_keycloak_id = AsyncMock(return_value=mock_user_settings)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock config object."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_maker():
|
||||
"""Create a mock session maker."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dependencies(
|
||||
mock_settings_store, mock_config, mock_session_maker, mock_user_settings
|
||||
):
|
||||
"""Fixture that patches all the common dependencies."""
|
||||
with patch(
|
||||
'integrations.github.github_view.SaasSettingsStore',
|
||||
return_value=mock_settings_store,
|
||||
) as mock_store_class, patch(
|
||||
'integrations.github.github_view.get_config', return_value=mock_config
|
||||
) as mock_get_config, patch(
|
||||
'integrations.github.github_view.session_maker', mock_session_maker
|
||||
), patch(
|
||||
'integrations.github.github_view.call_sync_from_async',
|
||||
return_value=mock_user_settings,
|
||||
) as mock_call_sync:
|
||||
yield {
|
||||
'store_class': mock_store_class,
|
||||
'get_config': mock_get_config,
|
||||
'session_maker': mock_session_maker,
|
||||
'call_sync': mock_call_sync,
|
||||
'settings_store': mock_settings_store,
|
||||
'user_settings': mock_user_settings,
|
||||
}
|
||||
|
||||
|
||||
class TestGetUserV1EnabledSetting:
|
||||
"""Test cases for get_user_v1_enabled_setting function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'env_var_enabled,user_setting_enabled,expected_result',
|
||||
[
|
||||
(False, True, False), # Env var disabled, user enabled -> False
|
||||
(True, False, False), # Env var enabled, user disabled -> False
|
||||
(True, True, True), # Both enabled -> True
|
||||
(False, False, False), # Both disabled -> False
|
||||
],
|
||||
)
|
||||
async def test_v1_enabled_combinations(
|
||||
self, mock_dependencies, env_var_enabled, user_setting_enabled, expected_result
|
||||
):
|
||||
"""Test all combinations of environment variable and user setting values."""
|
||||
mock_dependencies['user_settings'].v1_enabled = user_setting_enabled
|
||||
|
||||
with patch(
|
||||
'integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', env_var_enabled
|
||||
):
|
||||
result = await get_user_v1_enabled_setting('test_user_id')
|
||||
assert result is expected_result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'env_var_value,env_var_bool,expected_result',
|
||||
[
|
||||
('false', False, False), # Environment variable 'false' -> False
|
||||
('true', True, True), # Environment variable 'true' -> True
|
||||
],
|
||||
)
|
||||
async def test_environment_variable_integration(
|
||||
self, mock_dependencies, env_var_value, env_var_bool, expected_result
|
||||
):
|
||||
"""Test that the function properly reads the ENABLE_V1_GITHUB_RESOLVER environment variable."""
|
||||
mock_dependencies['user_settings'].v1_enabled = True
|
||||
|
||||
with patch.dict(
|
||||
os.environ, {'ENABLE_V1_GITHUB_RESOLVER': env_var_value}
|
||||
), patch('integrations.utils.os.getenv', return_value=env_var_value), patch(
|
||||
'integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', env_var_bool
|
||||
):
|
||||
result = await get_user_v1_enabled_setting('test_user_id')
|
||||
assert result is expected_result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_calls_correct_methods(self, mock_dependencies):
|
||||
"""Test that the function calls the correct methods with correct parameters."""
|
||||
mock_dependencies['user_settings'].v1_enabled = True
|
||||
|
||||
with patch('integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', True):
|
||||
result = await get_user_v1_enabled_setting('test_user_123')
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify correct methods were called with correct parameters
|
||||
mock_dependencies['get_config'].assert_called_once()
|
||||
mock_dependencies['store_class'].assert_called_once_with(
|
||||
user_id='test_user_123',
|
||||
session_maker=mock_dependencies['session_maker'],
|
||||
config=mock_dependencies['get_config'].return_value,
|
||||
)
|
||||
mock_dependencies['call_sync'].assert_called_once_with(
|
||||
mock_dependencies['settings_store'].get_user_settings_by_keycloak_id,
|
||||
'test_user_123',
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
from unittest import TestCase, mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import UserData
|
||||
@@ -115,10 +114,8 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
title='Test Issue',
|
||||
description='Test issue description',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
@@ -147,7 +144,6 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
)
|
||||
mock_create_v1.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
@@ -176,7 +172,6 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
)
|
||||
mock_create_v0.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
|
||||
485
enterprise/tests/unit/test_legacy_conversation_manager.py
Normal file
485
enterprise/tests/unit/test_legacy_conversation_manager.py
Normal file
@@ -0,0 +1,485 @@
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from server.legacy_conversation_manager import (
|
||||
_LEGACY_ENTRY_TIMEOUT_SECONDS,
|
||||
LegacyCacheEntry,
|
||||
LegacyConversationManager,
|
||||
)
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sio():
|
||||
"""Create a mock SocketIO server."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock OpenHands config."""
|
||||
return MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_server_config():
|
||||
"""Create a mock server config."""
|
||||
return MagicMock(spec=ServerConfig)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_file_store():
|
||||
"""Create a mock file store."""
|
||||
return MagicMock(spec=InMemoryFileStore)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_monitoring_listener():
|
||||
"""Create a mock monitoring listener."""
|
||||
return MagicMock(spec=MonitoringListener)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation_manager():
|
||||
"""Create a mock SaasNestedConversationManager."""
|
||||
mock_cm = MagicMock()
|
||||
mock_cm._get_runtime = AsyncMock()
|
||||
return mock_cm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_legacy_conversation_manager():
|
||||
"""Create a mock ClusteredConversationManager."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_manager(
|
||||
mock_sio,
|
||||
mock_config,
|
||||
mock_server_config,
|
||||
mock_file_store,
|
||||
mock_conversation_manager,
|
||||
mock_legacy_conversation_manager,
|
||||
):
|
||||
"""Create a LegacyConversationManager instance for testing."""
|
||||
return LegacyConversationManager(
|
||||
sio=mock_sio,
|
||||
config=mock_config,
|
||||
server_config=mock_server_config,
|
||||
file_store=mock_file_store,
|
||||
conversation_manager=mock_conversation_manager,
|
||||
legacy_conversation_manager=mock_legacy_conversation_manager,
|
||||
)
|
||||
|
||||
|
||||
class TestLegacyCacheEntry:
|
||||
"""Test the LegacyCacheEntry dataclass."""
|
||||
|
||||
def test_cache_entry_creation(self):
|
||||
"""Test creating a cache entry."""
|
||||
timestamp = time.time()
|
||||
entry = LegacyCacheEntry(is_legacy=True, timestamp=timestamp)
|
||||
|
||||
assert entry.is_legacy is True
|
||||
assert entry.timestamp == timestamp
|
||||
|
||||
def test_cache_entry_false(self):
|
||||
"""Test creating a cache entry with False value."""
|
||||
timestamp = time.time()
|
||||
entry = LegacyCacheEntry(is_legacy=False, timestamp=timestamp)
|
||||
|
||||
assert entry.is_legacy is False
|
||||
assert entry.timestamp == timestamp
|
||||
|
||||
|
||||
class TestLegacyConversationManagerCacheCleanup:
|
||||
"""Test cache cleanup functionality."""
|
||||
|
||||
def test_cleanup_expired_cache_entries_removes_expired(self, legacy_manager):
|
||||
"""Test that expired entries are removed from cache."""
|
||||
current_time = time.time()
|
||||
expired_time = current_time - _LEGACY_ENTRY_TIMEOUT_SECONDS - 1
|
||||
valid_time = current_time - 100 # Well within timeout
|
||||
|
||||
# Add both expired and valid entries
|
||||
legacy_manager._legacy_cache = {
|
||||
'expired_conversation': LegacyCacheEntry(True, expired_time),
|
||||
'valid_conversation': LegacyCacheEntry(False, valid_time),
|
||||
'another_expired': LegacyCacheEntry(True, expired_time - 100),
|
||||
}
|
||||
|
||||
legacy_manager._cleanup_expired_cache_entries()
|
||||
|
||||
# Only valid entry should remain
|
||||
assert len(legacy_manager._legacy_cache) == 1
|
||||
assert 'valid_conversation' in legacy_manager._legacy_cache
|
||||
assert 'expired_conversation' not in legacy_manager._legacy_cache
|
||||
assert 'another_expired' not in legacy_manager._legacy_cache
|
||||
|
||||
def test_cleanup_expired_cache_entries_keeps_valid(self, legacy_manager):
|
||||
"""Test that valid entries are kept during cleanup."""
|
||||
current_time = time.time()
|
||||
valid_time = current_time - 100 # Well within timeout
|
||||
|
||||
legacy_manager._legacy_cache = {
|
||||
'valid_conversation_1': LegacyCacheEntry(True, valid_time),
|
||||
'valid_conversation_2': LegacyCacheEntry(False, valid_time - 50),
|
||||
}
|
||||
|
||||
legacy_manager._cleanup_expired_cache_entries()
|
||||
|
||||
# Both entries should remain
|
||||
assert len(legacy_manager._legacy_cache) == 2
|
||||
assert 'valid_conversation_1' in legacy_manager._legacy_cache
|
||||
assert 'valid_conversation_2' in legacy_manager._legacy_cache
|
||||
|
||||
def test_cleanup_expired_cache_entries_empty_cache(self, legacy_manager):
|
||||
"""Test cleanup with empty cache."""
|
||||
legacy_manager._legacy_cache = {}
|
||||
|
||||
legacy_manager._cleanup_expired_cache_entries()
|
||||
|
||||
assert len(legacy_manager._legacy_cache) == 0
|
||||
|
||||
|
||||
class TestIsLegacyRuntime:
|
||||
"""Test the is_legacy_runtime method."""
|
||||
|
||||
def test_is_legacy_runtime_none(self, legacy_manager):
|
||||
"""Test with None runtime."""
|
||||
result = legacy_manager.is_legacy_runtime(None)
|
||||
assert result is False
|
||||
|
||||
def test_is_legacy_runtime_legacy_command(self, legacy_manager):
|
||||
"""Test with legacy runtime command."""
|
||||
runtime = {'command': 'some_old_legacy_command'}
|
||||
result = legacy_manager.is_legacy_runtime(runtime)
|
||||
assert result is True
|
||||
|
||||
def test_is_legacy_runtime_new_command(self, legacy_manager):
|
||||
"""Test with new runtime command containing openhands.server."""
|
||||
runtime = {'command': 'python -m openhands.server.listen'}
|
||||
result = legacy_manager.is_legacy_runtime(runtime)
|
||||
assert result is False
|
||||
|
||||
def test_is_legacy_runtime_partial_match(self, legacy_manager):
|
||||
"""Test with command that partially matches but is still legacy."""
|
||||
runtime = {'command': 'openhands.client.start'}
|
||||
result = legacy_manager.is_legacy_runtime(runtime)
|
||||
assert result is True
|
||||
|
||||
def test_is_legacy_runtime_empty_command(self, legacy_manager):
|
||||
"""Test with empty command."""
|
||||
runtime = {'command': ''}
|
||||
result = legacy_manager.is_legacy_runtime(runtime)
|
||||
assert result is True
|
||||
|
||||
def test_is_legacy_runtime_missing_command_key(self, legacy_manager):
|
||||
"""Test with runtime missing command key."""
|
||||
runtime = {'other_key': 'value'}
|
||||
# This should raise a KeyError
|
||||
with pytest.raises(KeyError):
|
||||
legacy_manager.is_legacy_runtime(runtime)
|
||||
|
||||
|
||||
class TestShouldStartInLegacyMode:
|
||||
"""Test the should_start_in_legacy_mode method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_valid_entry_legacy(self, legacy_manager):
|
||||
"""Test cache hit with valid legacy entry."""
|
||||
conversation_id = 'test_conversation'
|
||||
current_time = time.time()
|
||||
|
||||
# Add valid cache entry
|
||||
legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry(
|
||||
True, current_time - 100
|
||||
)
|
||||
|
||||
result = await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
|
||||
assert result is True
|
||||
# Should not call _get_runtime since we hit cache
|
||||
legacy_manager.conversation_manager._get_runtime.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_valid_entry_non_legacy(self, legacy_manager):
|
||||
"""Test cache hit with valid non-legacy entry."""
|
||||
conversation_id = 'test_conversation'
|
||||
current_time = time.time()
|
||||
|
||||
# Add valid cache entry
|
||||
legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry(
|
||||
False, current_time - 100
|
||||
)
|
||||
|
||||
result = await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
|
||||
assert result is False
|
||||
# Should not call _get_runtime since we hit cache
|
||||
legacy_manager.conversation_manager._get_runtime.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_legacy_runtime(self, legacy_manager):
|
||||
"""Test cache miss with legacy runtime."""
|
||||
conversation_id = 'test_conversation'
|
||||
runtime = {'command': 'old_command'}
|
||||
|
||||
legacy_manager.conversation_manager._get_runtime.return_value = runtime
|
||||
|
||||
result = await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
|
||||
assert result is True
|
||||
# Should call _get_runtime
|
||||
legacy_manager.conversation_manager._get_runtime.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Should cache the result
|
||||
assert conversation_id in legacy_manager._legacy_cache
|
||||
assert legacy_manager._legacy_cache[conversation_id].is_legacy is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_non_legacy_runtime(self, legacy_manager):
|
||||
"""Test cache miss with non-legacy runtime."""
|
||||
conversation_id = 'test_conversation'
|
||||
runtime = {'command': 'python -m openhands.server.listen'}
|
||||
|
||||
legacy_manager.conversation_manager._get_runtime.return_value = runtime
|
||||
|
||||
result = await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
|
||||
assert result is False
|
||||
# Should call _get_runtime
|
||||
legacy_manager.conversation_manager._get_runtime.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Should cache the result
|
||||
assert conversation_id in legacy_manager._legacy_cache
|
||||
assert legacy_manager._legacy_cache[conversation_id].is_legacy is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_expired_entry(self, legacy_manager):
|
||||
"""Test with expired cache entry."""
|
||||
conversation_id = 'test_conversation'
|
||||
expired_time = time.time() - _LEGACY_ENTRY_TIMEOUT_SECONDS - 1
|
||||
runtime = {'command': 'python -m openhands.server.listen'}
|
||||
|
||||
# Add expired cache entry
|
||||
legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry(
|
||||
True,
|
||||
expired_time, # This should be considered expired
|
||||
)
|
||||
|
||||
legacy_manager.conversation_manager._get_runtime.return_value = runtime
|
||||
|
||||
result = await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
|
||||
assert result is False # Runtime indicates non-legacy
|
||||
# Should call _get_runtime since cache is expired
|
||||
legacy_manager.conversation_manager._get_runtime.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Should update cache with new result
|
||||
assert legacy_manager._legacy_cache[conversation_id].is_legacy is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_exactly_at_timeout(self, legacy_manager):
|
||||
"""Test with cache entry exactly at timeout boundary."""
|
||||
conversation_id = 'test_conversation'
|
||||
timeout_time = time.time() - _LEGACY_ENTRY_TIMEOUT_SECONDS
|
||||
runtime = {'command': 'python -m openhands.server.listen'}
|
||||
|
||||
# Add cache entry exactly at timeout
|
||||
legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry(
|
||||
True, timeout_time
|
||||
)
|
||||
|
||||
legacy_manager.conversation_manager._get_runtime.return_value = runtime
|
||||
|
||||
result = await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
|
||||
# Should treat as expired and fetch from runtime
|
||||
assert result is False
|
||||
legacy_manager.conversation_manager._get_runtime.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_returns_none(self, legacy_manager):
|
||||
"""Test when runtime returns None."""
|
||||
conversation_id = 'test_conversation'
|
||||
|
||||
legacy_manager.conversation_manager._get_runtime.return_value = None
|
||||
|
||||
result = await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
|
||||
assert result is False
|
||||
# Should cache the result
|
||||
assert conversation_id in legacy_manager._legacy_cache
|
||||
assert legacy_manager._legacy_cache[conversation_id].is_legacy is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_called_on_each_invocation(self, legacy_manager):
|
||||
"""Test that cleanup is called on each invocation."""
|
||||
conversation_id = 'test_conversation'
|
||||
runtime = {'command': 'test'}
|
||||
|
||||
legacy_manager.conversation_manager._get_runtime.return_value = runtime
|
||||
|
||||
# Mock the cleanup method to verify it's called
|
||||
with patch.object(
|
||||
legacy_manager, '_cleanup_expired_cache_entries'
|
||||
) as mock_cleanup:
|
||||
await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
mock_cleanup.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_conversations_cached_independently(self, legacy_manager):
|
||||
"""Test that multiple conversations are cached independently."""
|
||||
conv1 = 'conversation_1'
|
||||
conv2 = 'conversation_2'
|
||||
|
||||
runtime1 = {'command': 'old_command'} # Legacy
|
||||
runtime2 = {'command': 'python -m openhands.server.listen'} # Non-legacy
|
||||
|
||||
# Mock to return different runtimes based on conversation_id
|
||||
def mock_get_runtime(conversation_id):
|
||||
if conversation_id == conv1:
|
||||
return runtime1
|
||||
return runtime2
|
||||
|
||||
legacy_manager.conversation_manager._get_runtime.side_effect = mock_get_runtime
|
||||
|
||||
result1 = await legacy_manager.should_start_in_legacy_mode(conv1)
|
||||
result2 = await legacy_manager.should_start_in_legacy_mode(conv2)
|
||||
|
||||
assert result1 is True
|
||||
assert result2 is False
|
||||
|
||||
# Both should be cached
|
||||
assert conv1 in legacy_manager._legacy_cache
|
||||
assert conv2 in legacy_manager._legacy_cache
|
||||
assert legacy_manager._legacy_cache[conv1].is_legacy is True
|
||||
assert legacy_manager._legacy_cache[conv2].is_legacy is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_timestamp_updated_on_refresh(self, legacy_manager):
|
||||
"""Test that cache timestamp is updated when entry is refreshed."""
|
||||
conversation_id = 'test_conversation'
|
||||
old_time = time.time() - _LEGACY_ENTRY_TIMEOUT_SECONDS - 1
|
||||
runtime = {'command': 'test'}
|
||||
|
||||
# Add expired entry
|
||||
legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry(True, old_time)
|
||||
legacy_manager.conversation_manager._get_runtime.return_value = runtime
|
||||
|
||||
# Record time before call
|
||||
before_call = time.time()
|
||||
await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
after_call = time.time()
|
||||
|
||||
# Timestamp should be updated
|
||||
cached_entry = legacy_manager._legacy_cache[conversation_id]
|
||||
assert cached_entry.timestamp >= before_call
|
||||
assert cached_entry.timestamp <= after_call
|
||||
|
||||
|
||||
class TestLegacyConversationManagerIntegration:
|
||||
"""Integration tests for LegacyConversationManager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instance_creates_proper_manager(
|
||||
self,
|
||||
mock_sio,
|
||||
mock_config,
|
||||
mock_file_store,
|
||||
mock_server_config,
|
||||
mock_monitoring_listener,
|
||||
):
|
||||
"""Test that get_instance creates a properly configured manager."""
|
||||
with patch(
|
||||
'server.legacy_conversation_manager.SaasNestedConversationManager'
|
||||
) as mock_saas, patch(
|
||||
'server.legacy_conversation_manager.ClusteredConversationManager'
|
||||
) as mock_clustered:
|
||||
mock_saas.get_instance.return_value = MagicMock()
|
||||
mock_clustered.get_instance.return_value = MagicMock()
|
||||
|
||||
manager = LegacyConversationManager.get_instance(
|
||||
mock_sio,
|
||||
mock_config,
|
||||
mock_file_store,
|
||||
mock_server_config,
|
||||
mock_monitoring_listener,
|
||||
)
|
||||
|
||||
assert isinstance(manager, LegacyConversationManager)
|
||||
assert manager.sio == mock_sio
|
||||
assert manager.config == mock_config
|
||||
assert manager.file_store == mock_file_store
|
||||
assert manager.server_config == mock_server_config
|
||||
|
||||
# Verify that both nested managers are created
|
||||
mock_saas.get_instance.assert_called_once()
|
||||
mock_clustered.get_instance.assert_called_once()
|
||||
|
||||
def test_legacy_cache_initialized_empty(self, legacy_manager):
|
||||
"""Test that legacy cache is initialized as empty dict."""
|
||||
assert isinstance(legacy_manager._legacy_cache, dict)
|
||||
assert len(legacy_manager._legacy_cache) == 0
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_runtime_raises_exception(self, legacy_manager):
|
||||
"""Test behavior when _get_runtime raises an exception."""
|
||||
conversation_id = 'test_conversation'
|
||||
|
||||
legacy_manager.conversation_manager._get_runtime.side_effect = Exception(
|
||||
'Runtime error'
|
||||
)
|
||||
|
||||
# Should propagate the exception
|
||||
with pytest.raises(Exception, match='Runtime error'):
|
||||
await legacy_manager.should_start_in_legacy_mode(conversation_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_very_large_cache(self, legacy_manager):
|
||||
"""Test behavior with a large number of cache entries."""
|
||||
current_time = time.time()
|
||||
|
||||
# Add many cache entries
|
||||
for i in range(1000):
|
||||
legacy_manager._legacy_cache[f'conversation_{i}'] = LegacyCacheEntry(
|
||||
i % 2 == 0, current_time - i
|
||||
)
|
||||
|
||||
# This should work without issues
|
||||
await legacy_manager.should_start_in_legacy_mode('new_conversation')
|
||||
|
||||
# Should have added one more entry
|
||||
assert len(legacy_manager._legacy_cache) == 1001
|
||||
|
||||
def test_cleanup_with_concurrent_modifications(self, legacy_manager):
|
||||
"""Test cleanup behavior when cache is modified during cleanup."""
|
||||
current_time = time.time()
|
||||
expired_time = current_time - _LEGACY_ENTRY_TIMEOUT_SECONDS - 1
|
||||
|
||||
# Add expired entries
|
||||
legacy_manager._legacy_cache = {
|
||||
f'conversation_{i}': LegacyCacheEntry(True, expired_time) for i in range(10)
|
||||
}
|
||||
|
||||
# This should work without raising exceptions
|
||||
legacy_manager._cleanup_expired_cache_entries()
|
||||
|
||||
# All entries should be removed
|
||||
assert len(legacy_manager._legacy_cache) == 0
|
||||
@@ -1,10 +1,5 @@
|
||||
# Evaluation
|
||||
|
||||
> [!WARNING]
|
||||
> **This directory is deprecated.** Our new benchmarks are located at [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks).
|
||||
>
|
||||
> If you have already implemented a benchmark in this directory and would like to contribute it, we are happy to have the contribution. However, if you are starting anew, please use the new location.
|
||||
|
||||
This folder contains code and resources to run experiments and evaluations.
|
||||
|
||||
## For Benchmark Users
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
"i18next/no-literal-string": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"prettier/prettier": ["error"],
|
||||
// Enforce using optional chaining (?.) instead of && chains for null/undefined checks
|
||||
"@typescript-eslint/prefer-optional-chain": "error",
|
||||
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
|
||||
"import/extensions": [
|
||||
"error",
|
||||
|
||||
2
frontend/.npmrc
Normal file
2
frontend/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
public-hoist-pattern[]=*@nextui-org/*
|
||||
enable-pre-post-scripts=true
|
||||
@@ -30,33 +30,61 @@ vi.mock("react-i18next", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Zustand browser store
|
||||
let mockBrowserState = {
|
||||
url: "https://example.com",
|
||||
screenshotSrc: "",
|
||||
setUrl: vi.fn(),
|
||||
setScreenshotSrc: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("#/stores/browser-store", () => ({
|
||||
useBrowserStore: () => mockBrowserState,
|
||||
}));
|
||||
|
||||
// Import the component after all mocks are set up
|
||||
import { BrowserPanel } from "#/components/features/browser/browser";
|
||||
import { useBrowserStore } from "#/stores/browser-store";
|
||||
|
||||
describe("Browser", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the mock state
|
||||
mockBrowserState = {
|
||||
url: "https://example.com",
|
||||
screenshotSrc: "",
|
||||
setUrl: vi.fn(),
|
||||
setScreenshotSrc: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("renders a message if no screenshotSrc is provided", () => {
|
||||
useBrowserStore.setState({
|
||||
// Set the mock state for this test
|
||||
mockBrowserState = {
|
||||
url: "https://example.com",
|
||||
screenshotSrc: "",
|
||||
setUrl: vi.fn(),
|
||||
setScreenshotSrc: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
render(<BrowserPanel />);
|
||||
|
||||
// i18n empty message key
|
||||
expect(screen.getByText("BROWSER$NO_PAGE_LOADED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the url and a screenshot", () => {
|
||||
useBrowserStore.setState({
|
||||
// Set the mock state for this test
|
||||
mockBrowserState = {
|
||||
url: "https://example.com",
|
||||
screenshotSrc:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==",
|
||||
setUrl: vi.fn(),
|
||||
setScreenshotSrc: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
render(<BrowserPanel />);
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
vi.mock("#/stores/error-message-store");
|
||||
vi.mock("#/stores/optimistic-user-message-store");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-unified-upload-files");
|
||||
@@ -99,20 +102,24 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Default mock implementations
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
useOptimisticUserMessageStore.setState({
|
||||
optimisticUserMessage: null,
|
||||
(
|
||||
useOptimisticUserMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => null),
|
||||
});
|
||||
|
||||
useErrorMessageStore.setState({
|
||||
errorMessage: null,
|
||||
(
|
||||
useErrorMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setErrorMessage: vi.fn(),
|
||||
removeErrorMessage: vi.fn(),
|
||||
});
|
||||
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { APP_MODE: "local" },
|
||||
});
|
||||
@@ -197,8 +204,11 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is an optimistic user message", () => {
|
||||
useOptimisticUserMessageStore.setState({
|
||||
optimisticUserMessage: "Optimistic message",
|
||||
(
|
||||
useOptimisticUserMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
@@ -230,19 +240,24 @@ describe("ChatInterface - Empty state", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks to ensure empty state
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: sendMock,
|
||||
status: "CONNECTED",
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
useOptimisticUserMessageStore.setState({
|
||||
optimisticUserMessage: null,
|
||||
(
|
||||
useOptimisticUserMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => null),
|
||||
});
|
||||
|
||||
useErrorMessageStore.setState({
|
||||
errorMessage: null,
|
||||
(
|
||||
useErrorMessageStore as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setErrorMessage: vi.fn(),
|
||||
removeErrorMessage: vi.fn(),
|
||||
});
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { APP_MODE: "local" },
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("ExpandableMessage", () => {
|
||||
expect(icon).toHaveClass("fill-success");
|
||||
});
|
||||
|
||||
it("should render with no icon for failed action messages", () => {
|
||||
it("should render with error icon for failed action messages", () => {
|
||||
renderWithProviders(
|
||||
<ExpandableMessage
|
||||
id="OBSERVATION_MESSAGE$RUN"
|
||||
@@ -75,7 +75,8 @@ describe("ExpandableMessage", () => {
|
||||
"div.flex.gap-2.items-center.justify-start",
|
||||
);
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
|
||||
const icon = screen.getByTestId("status-icon");
|
||||
expect(icon).toHaveClass("fill-danger");
|
||||
});
|
||||
|
||||
it("should render with neutral border and no icon for action messages without success prop", () => {
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConversationTabTitle } from "#/components/features/conversation/conversation-tabs/conversation-tab-title";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import V1GitService from "#/api/git-service/v1-git-service.api";
|
||||
|
||||
// Mock the services that the hook depends on
|
||||
vi.mock("#/api/git-service/git-service.api");
|
||||
vi.mock("#/api/git-service/v1-git-service.api");
|
||||
|
||||
// Mock the hooks that useUnifiedGetGitChanges depends on
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({
|
||||
conversationId: "test-conversation-id",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
data: {
|
||||
conversation_version: "V0",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
selected_repository: null,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-runtime-is-ready", () => ({
|
||||
useRuntimeIsReady: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/get-git-path", () => ({
|
||||
getGitPath: () => "/workspace",
|
||||
}));
|
||||
|
||||
describe("ConversationTabTitle", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock GitService methods
|
||||
vi.mocked(GitService.getGitChanges).mockResolvedValue([]);
|
||||
vi.mocked(V1GitService.getGitChanges).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the title", () => {
|
||||
// Arrange
|
||||
const title = "Test Title";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title={title} conversationKey="browser" />,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show refresh button when conversationKey is 'editor'", () => {
|
||||
// Arrange
|
||||
const title = "Changes";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title={title} conversationKey="editor" />,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const refreshButton = screen.getByRole("button");
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show refresh button when conversationKey is not 'editor'", () => {
|
||||
// Arrange
|
||||
const title = "Browser";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title={title} conversationKey="browser" />,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
it("should call refetch and trigger GitService.getGitChanges when refresh button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const title = "Changes";
|
||||
const mockGitChanges: Array<{
|
||||
path: string;
|
||||
status: "M" | "A" | "D" | "R" | "U";
|
||||
}> = [
|
||||
{ path: "file1.ts", status: "M" },
|
||||
{ path: "file2.ts", status: "A" },
|
||||
];
|
||||
|
||||
vi.mocked(GitService.getGitChanges).mockResolvedValue(mockGitChanges);
|
||||
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title={title} conversationKey="editor" />,
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole("button");
|
||||
|
||||
// Wait for initial query to complete
|
||||
await waitFor(() => {
|
||||
expect(GitService.getGitChanges).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Clear the mock to track refetch calls
|
||||
vi.mocked(GitService.getGitChanges).mockClear();
|
||||
|
||||
// Act
|
||||
await user.click(refreshButton);
|
||||
|
||||
// Assert - refetch should trigger another service call
|
||||
await waitFor(() => {
|
||||
expect(GitService.getGitChanges).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
|
||||
describe("AnalyticsConsentFormModal", () => {
|
||||
it("should call saveUserSettings with consent", async () => {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
vi.mock("#/hooks/use-agent-state");
|
||||
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: "test-id" }),
|
||||
}));
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const renderAgentStatus = ({
|
||||
isPausing = false,
|
||||
}: { isPausing?: boolean } = {}) =>
|
||||
render(
|
||||
<AgentStatus
|
||||
handleStop={vi.fn()}
|
||||
handleResumeAgent={vi.fn()}
|
||||
isPausing={isPausing}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
describe("AgentStatus - isLoading logic", () => {
|
||||
it("should show loading when curAgentState is INIT", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.INIT,
|
||||
});
|
||||
|
||||
renderAgentStatus();
|
||||
|
||||
expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show loading when isPausing is true, even if shouldShownAgentLoading is false", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
});
|
||||
|
||||
renderAgentStatus({ isPausing: true });
|
||||
|
||||
expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT update global shouldShownAgentLoading when only isPausing is true", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
});
|
||||
|
||||
renderAgentStatus({ isPausing: true });
|
||||
|
||||
// Loading spinner shows (because isPausing)
|
||||
expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument();
|
||||
|
||||
// But global state should be false (because shouldShownAgentLoading is false)
|
||||
const { shouldShownAgentLoading } = useConversationStore.getState();
|
||||
expect(shouldShownAgentLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ vi.mock("react-i18next", async () => {
|
||||
BUTTON$EXPORT_CONVERSATION: "Export Conversation",
|
||||
BUTTON$DOWNLOAD_VIA_VSCODE: "Download via VS Code",
|
||||
BUTTON$SHOW_AGENT_TOOLS_AND_METADATA: "Show Agent Tools",
|
||||
CONVERSATION$SHOW_SKILLS: "Show Skills",
|
||||
CONVERSATION$SHOW_MICROAGENTS: "Show Microagents",
|
||||
BUTTON$DISPLAY_COST: "Display Cost",
|
||||
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
|
||||
"Close Conversation (Stop Runtime)",
|
||||
@@ -290,7 +290,7 @@ describe("ConversationNameContextMenu", () => {
|
||||
onStop: vi.fn(),
|
||||
onDisplayCost: vi.fn(),
|
||||
onShowAgentTools: vi.fn(),
|
||||
onShowSkills: vi.fn(),
|
||||
onShowMicroagents: vi.fn(),
|
||||
onExportConversation: vi.fn(),
|
||||
onDownloadViaVSCode: vi.fn(),
|
||||
};
|
||||
@@ -304,7 +304,7 @@ describe("ConversationNameContextMenu", () => {
|
||||
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("display-cost-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("show-skills-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("show-microagents-button")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("export-conversation-button"),
|
||||
).toBeInTheDocument();
|
||||
@@ -321,7 +321,9 @@ describe("ConversationNameContextMenu", () => {
|
||||
expect(
|
||||
screen.queryByTestId("show-agent-tools-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("show-skills-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("show-microagents-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("export-conversation-button"),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -408,19 +410,19 @@ describe("ConversationNameContextMenu", () => {
|
||||
|
||||
it("should call show microagents handler when show microagents button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onShowSkills = vi.fn();
|
||||
const onShowMicroagents = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ConversationNameContextMenu
|
||||
{...defaultProps}
|
||||
onShowSkills={onShowSkills}
|
||||
onShowMicroagents={onShowMicroagents}
|
||||
/>,
|
||||
);
|
||||
|
||||
const showMicroagentsButton = screen.getByTestId("show-skills-button");
|
||||
const showMicroagentsButton = screen.getByTestId("show-microagents-button");
|
||||
await user.click(showMicroagentsButton);
|
||||
|
||||
expect(onShowSkills).toHaveBeenCalledTimes(1);
|
||||
expect(onShowMicroagents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call export conversation handler when export conversation button is clicked", async () => {
|
||||
@@ -517,7 +519,7 @@ describe("ConversationNameContextMenu", () => {
|
||||
onStop: vi.fn(),
|
||||
onDisplayCost: vi.fn(),
|
||||
onShowAgentTools: vi.fn(),
|
||||
onShowSkills: vi.fn(),
|
||||
onShowMicroagents: vi.fn(),
|
||||
onExportConversation: vi.fn(),
|
||||
onDownloadViaVSCode: vi.fn(),
|
||||
};
|
||||
@@ -539,8 +541,8 @@ describe("ConversationNameContextMenu", () => {
|
||||
expect(screen.getByTestId("show-agent-tools-button")).toHaveTextContent(
|
||||
"Show Agent Tools",
|
||||
);
|
||||
expect(screen.getByTestId("show-skills-button")).toHaveTextContent(
|
||||
"Show Skills",
|
||||
expect(screen.getByTestId("show-microagents-button")).toHaveTextContent(
|
||||
"Show Microagents",
|
||||
);
|
||||
expect(screen.getByTestId("export-conversation-button")).toHaveTextContent(
|
||||
"Export Conversation",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { RecentConversations } from "#/components/features/home/recent-conversations/recent-conversations";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
|
||||
const renderRecentConversations = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <RecentConversations />,
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe("RecentConversations", () => {
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
|
||||
it("should not show empty state when there is an error", async () => {
|
||||
getUserConversationsSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch conversations"),
|
||||
);
|
||||
|
||||
renderRecentConversations();
|
||||
|
||||
// Wait for the error to be displayed
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Failed to fetch conversations"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The empty state should NOT be displayed when there's an error
|
||||
expect(
|
||||
screen.queryByText("HOME$NO_RECENT_CONVERSATIONS"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
@@ -404,7 +404,7 @@ describe("RepoConnector", () => {
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => { })); // Never resolves to keep loading state
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
"retrieveUserGitRepositories",
|
||||
|
||||
@@ -2,9 +2,9 @@ import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, vi, beforeEach, it } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
|
||||
import UserService from "#/api/user-service/user-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useHomeStore } from "#/stores/home-store";
|
||||
|
||||
// Create mock functions
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
@@ -97,7 +97,7 @@ vi.mock("#/context/auth-context", () => ({
|
||||
// Mock debounce to simulate proper debounced behavior
|
||||
let debouncedValue = "";
|
||||
vi.mock("#/hooks/use-debounce", () => ({
|
||||
useDebounce: (value: string) => {
|
||||
useDebounce: (value: string, _delay: number) => {
|
||||
// In real debouncing, only the final value after the delay should be returned
|
||||
// For testing, we'll return the full value once it's complete
|
||||
if (value && value.length > 20) {
|
||||
@@ -124,51 +124,28 @@ vi.mock("#/hooks/query/use-search-repositories", () => ({
|
||||
}));
|
||||
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
|
||||
// Helper function to render with custom store state
|
||||
const renderForm = (
|
||||
storeOverrides: Partial<{
|
||||
recentRepositories: GitRepository[];
|
||||
lastSelectedProvider: 'gitlab' | null;
|
||||
}> = {},
|
||||
) => {
|
||||
// Set up the store state before rendering
|
||||
useHomeStore.setState({
|
||||
recentRepositories: [],
|
||||
lastSelectedProvider: null,
|
||||
...storeOverrides,
|
||||
});
|
||||
|
||||
return render(
|
||||
<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
const renderForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("RepositorySelectionForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the store to initial state
|
||||
useHomeStore.setState({
|
||||
recentRepositories: [],
|
||||
lastSelectedProvider: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows dropdown when repositories are loaded", async () => {
|
||||
@@ -249,7 +226,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
await screen.findByTestId("git-repo-dropdown");
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
// The test should verify that typing a URL triggers the search behavior
|
||||
// Since the component uses useSearchRepositories hook, just verify the hook is set up correctly
|
||||
@@ -284,7 +261,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
await screen.findByTestId("git-repo-dropdown");
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
// Verify that the onRepoSelection callback prop was provided
|
||||
expect(mockOnRepoSelection).toBeDefined();
|
||||
@@ -293,38 +270,4 @@ describe("RepositorySelectionForm", () => {
|
||||
// we'll verify that the basic structure is in place and the callback is available
|
||||
expect(typeof mockOnRepoSelection).toBe("function");
|
||||
});
|
||||
|
||||
it("should auto-select the last selected provider when multiple providers are available", async () => {
|
||||
// Mock multiple providers
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github", "gitlab", "bitbucket"],
|
||||
});
|
||||
|
||||
// Set up the store with gitlab as the last selected provider
|
||||
renderForm({
|
||||
lastSelectedProvider: "gitlab",
|
||||
});
|
||||
|
||||
// The provider dropdown should be visible since there are multiple providers
|
||||
expect(
|
||||
await screen.findByTestId("git-provider-dropdown"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify that the store has the correct last selected provider
|
||||
expect(useHomeStore.getState().lastSelectedProvider).toBe("gitlab");
|
||||
});
|
||||
|
||||
it("should not show provider dropdown when there's only one provider", async () => {
|
||||
// Mock single provider
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
});
|
||||
|
||||
renderForm();
|
||||
|
||||
// The provider dropdown should not be visible since there's only one provider
|
||||
expect(
|
||||
screen.queryByTestId("git-provider-dropdown"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import GitService from "#/api/git-service/git-service.api";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
import { useMicroagentManagementStore } from "#/stores/microagent-management-store";
|
||||
import { useMicroagentManagementStore } from "#/state/microagent-management-store";
|
||||
|
||||
// Mock hooks
|
||||
const mockUseUserProviders = vi.fn();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
|
||||
// These tests will now fail because the conversation panel is rendered through a portal
|
||||
// and technically not a child of the Sidebar component.
|
||||
|
||||
@@ -6,12 +6,18 @@ import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation store
|
||||
vi.mock("#/state/conversation-store", () => ({
|
||||
useConversationStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock React Router hooks
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
@@ -52,23 +58,44 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
describe("InteractiveChatBox", () => {
|
||||
const onSubmitMock = vi.fn();
|
||||
|
||||
// Helper function to mock stores
|
||||
const mockStores = (agentState: AgentState = AgentState.INIT) => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
});
|
||||
|
||||
useConversationStore.setState({
|
||||
vi.mocked(useConversationStore).mockReturnValue({
|
||||
images: [],
|
||||
files: [],
|
||||
addImages: vi.fn(),
|
||||
addFiles: vi.fn(),
|
||||
clearAllFiles: vi.fn(),
|
||||
addFileLoading: vi.fn(),
|
||||
removeFileLoading: vi.fn(),
|
||||
addImageLoading: vi.fn(),
|
||||
removeImageLoading: vi.fn(),
|
||||
submittedMessage: null,
|
||||
setShouldHideSuggestions: vi.fn(),
|
||||
setSubmittedMessage: vi.fn(),
|
||||
isRightPanelShown: true,
|
||||
selectedTab: "editor" as const,
|
||||
loadingFiles: [],
|
||||
loadingImages: [],
|
||||
submittedMessage: null,
|
||||
messageToSend: null,
|
||||
shouldShownAgentLoading: false,
|
||||
shouldHideSuggestions: false,
|
||||
isRightPanelShown: true,
|
||||
selectedTab: "editor" as const,
|
||||
hasRightPanelToggled: true,
|
||||
setIsRightPanelShown: vi.fn(),
|
||||
setSelectedTab: vi.fn(),
|
||||
setShouldShownAgentLoading: vi.fn(),
|
||||
removeImage: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
clearImages: vi.fn(),
|
||||
clearFiles: vi.fn(),
|
||||
clearAllLoading: vi.fn(),
|
||||
setMessageToSend: vi.fn(),
|
||||
resetConversationState: vi.fn(),
|
||||
setHasRightPanelToggled: vi.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation ID hook
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: "test-conversation-id" }),
|
||||
}));
|
||||
|
||||
describe("MicroagentsModal - Refresh Button", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const conversationId = "test-conversation-id";
|
||||
|
||||
const defaultProps = {
|
||||
onClose: mockOnClose,
|
||||
conversationId,
|
||||
};
|
||||
|
||||
const mockMicroagents = [
|
||||
{
|
||||
name: "Test Agent 1",
|
||||
type: "repo" as const,
|
||||
triggers: ["test", "example"],
|
||||
content: "This is test content for agent 1",
|
||||
},
|
||||
{
|
||||
name: "Test Agent 2",
|
||||
type: "knowledge" as const,
|
||||
triggers: ["help", "support"],
|
||||
content: "This is test content for agent 2",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mock for getMicroagents
|
||||
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
|
||||
microagents: mockMicroagents,
|
||||
});
|
||||
|
||||
// Mock the agent state to return a ready state
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Refresh Button Rendering", () => {
|
||||
it("should render the refresh button with correct text and test ID", async () => {
|
||||
renderWithProviders(<MicroagentsModal {...defaultProps} />);
|
||||
|
||||
// Wait for the component to load and render the refresh button
|
||||
const refreshButton = await screen.findByTestId("refresh-microagents");
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh Button Functionality", () => {
|
||||
it("should call refetch when refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<MicroagentsModal {...defaultProps} />);
|
||||
|
||||
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
|
||||
|
||||
// Wait for the component to load and render the refresh button
|
||||
const refreshButton = await screen.findByTestId("refresh-microagents");
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,394 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { SkillsModal } from "#/components/features/conversation-panel/skills-modal";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation ID hook
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: "test-conversation-id" }),
|
||||
}));
|
||||
|
||||
describe("SkillsModal - Refresh Button", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const conversationId = "test-conversation-id";
|
||||
|
||||
const defaultProps = {
|
||||
onClose: mockOnClose,
|
||||
conversationId,
|
||||
};
|
||||
|
||||
const mockSkills = [
|
||||
{
|
||||
name: "Test Agent 1",
|
||||
type: "repo" as const,
|
||||
triggers: ["test", "example"],
|
||||
content: "This is test content for agent 1",
|
||||
},
|
||||
{
|
||||
name: "Test Agent 2",
|
||||
type: "knowledge" as const,
|
||||
triggers: ["help", "support"],
|
||||
content: "This is test content for agent 2",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mock for getMicroagents (V0)
|
||||
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
|
||||
microagents: mockSkills,
|
||||
});
|
||||
|
||||
// Mock the agent state to return a ready state
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Refresh Button Rendering", () => {
|
||||
it("should render the refresh button with correct text and test ID", async () => {
|
||||
renderWithProviders(<SkillsModal {...defaultProps} />);
|
||||
|
||||
// Wait for the component to load and render the refresh button
|
||||
const refreshButton = await screen.findByTestId("refresh-skills");
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh Button Functionality", () => {
|
||||
it("should call refetch when refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
|
||||
|
||||
renderWithProviders(<SkillsModal {...defaultProps} />);
|
||||
|
||||
// Wait for the component to load and render the refresh button
|
||||
const refreshButton = await screen.findByTestId("refresh-skills");
|
||||
|
||||
// Clear previous calls to only track the click
|
||||
refreshSpy.mockClear();
|
||||
|
||||
await user.click(refreshButton);
|
||||
|
||||
// Verify the refresh triggered a new API call
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useConversationSkills - V1 API Integration", () => {
|
||||
const conversationId = "test-conversation-id";
|
||||
|
||||
const mockMicroagents = [
|
||||
{
|
||||
name: "V0 Test Agent",
|
||||
type: "repo" as const,
|
||||
triggers: ["v0"],
|
||||
content: "V0 skill content",
|
||||
},
|
||||
];
|
||||
|
||||
const mockSkills = [
|
||||
{
|
||||
name: "V1 Test Skill",
|
||||
type: "knowledge" as const,
|
||||
triggers: ["v1", "skill"],
|
||||
content: "V1 skill content",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock agent state
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("V0 API Usage (v1_enabled: false)", () => {
|
||||
it("should call v0 ConversationService.getMicroagents when v1_enabled is false", async () => {
|
||||
// Arrange
|
||||
const getMicroagentsSpy = vi
|
||||
.spyOn(ConversationService, "getMicroagents")
|
||||
.mockResolvedValue({ microagents: mockMicroagents });
|
||||
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
|
||||
v1_enabled: false,
|
||||
llm_model: "test-model",
|
||||
llm_base_url: "",
|
||||
agent: "test-agent",
|
||||
language: "en",
|
||||
llm_api_key: null,
|
||||
llm_api_key_set: false,
|
||||
search_api_key_set: false,
|
||||
confirmation_mode: false,
|
||||
security_analyzer: null,
|
||||
remote_runtime_resource_factor: null,
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: false,
|
||||
condenser_max_size: null,
|
||||
enable_sound_notifications: false,
|
||||
enable_proactive_conversation_starters: false,
|
||||
enable_solvability_analysis: false,
|
||||
user_consents_to_analytics: null,
|
||||
max_budget_per_task: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
|
||||
|
||||
// Assert
|
||||
await screen.findByText("V0 Test Agent");
|
||||
expect(getMicroagentsSpy).toHaveBeenCalledWith(conversationId);
|
||||
expect(getMicroagentsSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should display v0 skills correctly", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
|
||||
microagents: mockMicroagents,
|
||||
});
|
||||
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
|
||||
v1_enabled: false,
|
||||
llm_model: "test-model",
|
||||
llm_base_url: "",
|
||||
agent: "test-agent",
|
||||
language: "en",
|
||||
llm_api_key: null,
|
||||
llm_api_key_set: false,
|
||||
search_api_key_set: false,
|
||||
confirmation_mode: false,
|
||||
security_analyzer: null,
|
||||
remote_runtime_resource_factor: null,
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: false,
|
||||
condenser_max_size: null,
|
||||
enable_sound_notifications: false,
|
||||
enable_proactive_conversation_starters: false,
|
||||
enable_solvability_analysis: false,
|
||||
user_consents_to_analytics: null,
|
||||
max_budget_per_task: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
|
||||
|
||||
// Assert
|
||||
const agentName = await screen.findByText("V0 Test Agent");
|
||||
expect(agentName).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1 API Usage (v1_enabled: true)", () => {
|
||||
it("should call v1 V1ConversationService.getSkills when v1_enabled is true", async () => {
|
||||
// Arrange
|
||||
const getSkillsSpy = vi
|
||||
.spyOn(V1ConversationService, "getSkills")
|
||||
.mockResolvedValue({ skills: mockSkills });
|
||||
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
|
||||
v1_enabled: true,
|
||||
llm_model: "test-model",
|
||||
llm_base_url: "",
|
||||
agent: "test-agent",
|
||||
language: "en",
|
||||
llm_api_key: null,
|
||||
llm_api_key_set: false,
|
||||
search_api_key_set: false,
|
||||
confirmation_mode: false,
|
||||
security_analyzer: null,
|
||||
remote_runtime_resource_factor: null,
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: false,
|
||||
condenser_max_size: null,
|
||||
enable_sound_notifications: false,
|
||||
enable_proactive_conversation_starters: false,
|
||||
enable_solvability_analysis: false,
|
||||
user_consents_to_analytics: null,
|
||||
max_budget_per_task: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
|
||||
|
||||
// Assert
|
||||
await screen.findByText("V1 Test Skill");
|
||||
expect(getSkillsSpy).toHaveBeenCalledWith(conversationId);
|
||||
expect(getSkillsSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should display v1 skills correctly", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(V1ConversationService, "getSkills").mockResolvedValue({
|
||||
skills: mockSkills,
|
||||
});
|
||||
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
|
||||
v1_enabled: true,
|
||||
llm_model: "test-model",
|
||||
llm_base_url: "",
|
||||
agent: "test-agent",
|
||||
language: "en",
|
||||
llm_api_key: null,
|
||||
llm_api_key_set: false,
|
||||
search_api_key_set: false,
|
||||
confirmation_mode: false,
|
||||
security_analyzer: null,
|
||||
remote_runtime_resource_factor: null,
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: false,
|
||||
condenser_max_size: null,
|
||||
enable_sound_notifications: false,
|
||||
enable_proactive_conversation_starters: false,
|
||||
enable_solvability_analysis: false,
|
||||
user_consents_to_analytics: null,
|
||||
max_budget_per_task: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
|
||||
|
||||
// Assert
|
||||
const skillName = await screen.findByText("V1 Test Skill");
|
||||
expect(skillName).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use v1 API when v1_enabled is true", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
|
||||
v1_enabled: true,
|
||||
llm_model: "test-model",
|
||||
llm_base_url: "",
|
||||
agent: "test-agent",
|
||||
language: "en",
|
||||
llm_api_key: null,
|
||||
llm_api_key_set: false,
|
||||
search_api_key_set: false,
|
||||
confirmation_mode: false,
|
||||
security_analyzer: null,
|
||||
remote_runtime_resource_factor: null,
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: false,
|
||||
condenser_max_size: null,
|
||||
enable_sound_notifications: false,
|
||||
enable_proactive_conversation_starters: false,
|
||||
enable_solvability_analysis: false,
|
||||
user_consents_to_analytics: null,
|
||||
max_budget_per_task: null,
|
||||
});
|
||||
|
||||
const getSkillsSpy = vi
|
||||
.spyOn(V1ConversationService, "getSkills")
|
||||
.mockResolvedValue({
|
||||
skills: mockSkills,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
|
||||
|
||||
// Assert
|
||||
await screen.findByText("V1 Test Skill");
|
||||
// Verify v1 API was called
|
||||
expect(getSkillsSpy).toHaveBeenCalledWith(conversationId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Switching on Settings Change", () => {
|
||||
it("should refetch using different API when v1_enabled setting changes", async () => {
|
||||
// Arrange
|
||||
const getMicroagentsSpy = vi
|
||||
.spyOn(ConversationService, "getMicroagents")
|
||||
.mockResolvedValue({ microagents: mockMicroagents });
|
||||
const getSkillsSpy = vi
|
||||
.spyOn(V1ConversationService, "getSkills")
|
||||
.mockResolvedValue({ skills: mockSkills });
|
||||
|
||||
const settingsSpy = vi
|
||||
.spyOn(SettingsService, "getSettings")
|
||||
.mockResolvedValue({
|
||||
v1_enabled: false,
|
||||
llm_model: "test-model",
|
||||
llm_base_url: "",
|
||||
agent: "test-agent",
|
||||
language: "en",
|
||||
llm_api_key: null,
|
||||
llm_api_key_set: false,
|
||||
search_api_key_set: false,
|
||||
confirmation_mode: false,
|
||||
security_analyzer: null,
|
||||
remote_runtime_resource_factor: null,
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: false,
|
||||
condenser_max_size: null,
|
||||
enable_sound_notifications: false,
|
||||
enable_proactive_conversation_starters: false,
|
||||
enable_solvability_analysis: false,
|
||||
user_consents_to_analytics: null,
|
||||
max_budget_per_task: null,
|
||||
});
|
||||
|
||||
// Act - Initial render with v1_enabled: false
|
||||
const { rerender } = renderWithProviders(
|
||||
<SkillsModal onClose={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Assert - v0 API called initially
|
||||
await screen.findByText("V0 Test Agent");
|
||||
expect(getMicroagentsSpy).toHaveBeenCalledWith(conversationId);
|
||||
|
||||
// Arrange - Change settings to v1_enabled: true
|
||||
settingsSpy.mockResolvedValue({
|
||||
v1_enabled: true,
|
||||
llm_model: "test-model",
|
||||
llm_base_url: "",
|
||||
agent: "test-agent",
|
||||
language: "en",
|
||||
llm_api_key: null,
|
||||
llm_api_key_set: false,
|
||||
search_api_key_set: false,
|
||||
confirmation_mode: false,
|
||||
security_analyzer: null,
|
||||
remote_runtime_resource_factor: null,
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: false,
|
||||
condenser_max_size: null,
|
||||
enable_sound_notifications: false,
|
||||
enable_proactive_conversation_starters: false,
|
||||
enable_solvability_analysis: false,
|
||||
user_consents_to_analytics: null,
|
||||
max_budget_per_task: null,
|
||||
});
|
||||
|
||||
// Act - Force re-render
|
||||
rerender(<SkillsModal onClose={vi.fn()} />);
|
||||
|
||||
// Assert - v1 API should be called after settings change
|
||||
await screen.findByText("V1 Test Skill");
|
||||
expect(getSkillsSpy).toHaveBeenCalledWith(conversationId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen } from "@testing-library/react";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("SettingsForm", () => {
|
||||
Component: () => (
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={[DEFAULT_SETTINGS.llm_model]}
|
||||
models={[DEFAULT_SETTINGS.LLM_MODEL]}
|
||||
onClose={onCloseMock}
|
||||
/>
|
||||
),
|
||||
@@ -33,7 +33,7 @@ describe("SettingsForm", () => {
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: DEFAULT_SETTINGS.llm_model,
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { vi, describe, afterEach, it, expect } from "vitest";
|
||||
import { Command, useCommandStore } from "#/stores/command-store";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
|
||||
const renderTerminal = (commands: Command[] = []) => {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getObservationContent } from "#/components/v1/chat/event-content-helpers/get-observation-content";
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import { BrowserObservation } from "#/types/v1/core/base/observation";
|
||||
|
||||
describe("getObservationContent - BrowserObservation", () => {
|
||||
it("should return output content when available", () => {
|
||||
const mockEvent: ObservationEvent<BrowserObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output: "Browser action completed",
|
||||
error: null,
|
||||
screenshot_data: "base64data",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
expect(result).toContain("**Output:**");
|
||||
expect(result).toContain("Browser action completed");
|
||||
});
|
||||
|
||||
it("should handle error cases properly", () => {
|
||||
const mockEvent: ObservationEvent<BrowserObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output: "",
|
||||
error: "Browser action failed",
|
||||
screenshot_data: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
expect(result).toContain("**Error:**");
|
||||
expect(result).toContain("Browser action failed");
|
||||
});
|
||||
|
||||
it("should provide default message when no output or error", () => {
|
||||
const mockEvent: ObservationEvent<BrowserObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output: "",
|
||||
error: null,
|
||||
screenshot_data: "base64data",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
expect(result).toBe("Browser action completed successfully.");
|
||||
});
|
||||
|
||||
it("should return output when screenshot_data is null", () => {
|
||||
const mockEvent: ObservationEvent<BrowserObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output: "Page loaded successfully",
|
||||
error: null,
|
||||
screenshot_data: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
expect(result).toBe("**Output:**\nPage loaded successfully");
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,12 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useBrowserStore } from "#/stores/browser-store";
|
||||
import { useCommandStore } from "#/stores/command-store";
|
||||
import {
|
||||
createMockMessageEvent,
|
||||
createMockUserMessageEvent,
|
||||
createMockAgentErrorEvent,
|
||||
createMockBrowserObservationEvent,
|
||||
createMockBrowserNavigateActionEvent,
|
||||
createMockExecuteBashActionEvent,
|
||||
createMockExecuteBashObservationEvent,
|
||||
} from "#/mocks/mock-ws-helpers";
|
||||
import {
|
||||
ConnectionStatusComponent,
|
||||
@@ -453,10 +439,18 @@ describe("Conversation WebSocket Handler", () => {
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get(
|
||||
`http://localhost:3000/api/conversations/${conversationId}/events/count`,
|
||||
() => HttpResponse.json(expectedEventCount),
|
||||
),
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(expectedEventCount);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send all history events
|
||||
@@ -467,7 +461,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
function HistoryLoadingComponent() {
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
@@ -480,7 +474,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
<div data-testid="expected-event-count">{expectedEventCount}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
@@ -490,9 +484,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
@@ -512,10 +504,18 @@ describe("Conversation WebSocket Handler", () => {
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get(
|
||||
`http://localhost:3000/api/conversations/${conversationId}/events/count`,
|
||||
() => HttpResponse.json(0),
|
||||
),
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(0);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ server }) => {
|
||||
server.connect();
|
||||
// No events sent for empty history
|
||||
@@ -523,7 +523,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
function HistoryLoadingComponent() {
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
@@ -533,7 +533,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
@@ -561,10 +561,18 @@ describe("Conversation WebSocket Handler", () => {
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get(
|
||||
`http://localhost:3000/api/conversations/${conversationId}/events/count`,
|
||||
() => HttpResponse.json(expectedEventCount),
|
||||
),
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(expectedEventCount);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send all history events
|
||||
@@ -575,7 +583,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
function HistoryLoadingComponent() {
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
@@ -587,7 +595,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
<div data-testid="events-received">{events.length}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
@@ -597,9 +605,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
@@ -615,133 +621,17 @@ describe("Conversation WebSocket Handler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 9. Browser State Tests (BrowserObservation)
|
||||
describe("Browser State Integration", () => {
|
||||
beforeEach(() => {
|
||||
useBrowserStore.getState().reset();
|
||||
});
|
||||
|
||||
it("should update browser store with screenshot when BrowserObservation event is received", async () => {
|
||||
// Create a mock BrowserObservation event with screenshot data
|
||||
const mockBrowserObsEvent = createMockBrowserObservationEvent(
|
||||
"base64-screenshot-data",
|
||||
"Page loaded successfully",
|
||||
);
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBrowserObsEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the browser store to be updated with screenshot
|
||||
await waitFor(() => {
|
||||
const { screenshotSrc } = useBrowserStore.getState();
|
||||
expect(screenshotSrc).toBe(
|
||||
"data:image/png;base64,base64-screenshot-data",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update browser store with URL when BrowserNavigateAction followed by BrowserObservation", async () => {
|
||||
// Create mock events - action first, then observation
|
||||
const mockBrowserActionEvent = createMockBrowserNavigateActionEvent(
|
||||
"https://example.com/test-page",
|
||||
);
|
||||
const mockBrowserObsEvent = createMockBrowserObservationEvent(
|
||||
"base64-screenshot-data",
|
||||
"Page loaded successfully",
|
||||
);
|
||||
|
||||
// Set up MSW to send both events when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send action first, then observation
|
||||
client.send(JSON.stringify(mockBrowserActionEvent));
|
||||
client.send(JSON.stringify(mockBrowserObsEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the browser store to be updated with both screenshot and URL
|
||||
await waitFor(() => {
|
||||
const { screenshotSrc, url } = useBrowserStore.getState();
|
||||
expect(screenshotSrc).toBe(
|
||||
"data:image/png;base64,base64-screenshot-data",
|
||||
);
|
||||
expect(url).toBe("https://example.com/test-page");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update browser store when BrowserObservation has no screenshot data", async () => {
|
||||
const initialScreenshot = useBrowserStore.getState().screenshotSrc;
|
||||
|
||||
// Create a mock BrowserObservation event WITHOUT screenshot data
|
||||
const mockBrowserObsEvent = createMockBrowserObservationEvent(
|
||||
null, // no screenshot
|
||||
"Browser action completed",
|
||||
);
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBrowserObsEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Give some time for any potential updates
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
// Screenshot should remain unchanged (empty/initial value)
|
||||
const { screenshotSrc } = useBrowserStore.getState();
|
||||
expect(screenshotSrc).toBe(initialScreenshot);
|
||||
});
|
||||
});
|
||||
|
||||
// 10. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
// 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
beforeEach(() => {
|
||||
useCommandStore.getState().clearTerminal();
|
||||
});
|
||||
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashAction event
|
||||
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
|
||||
|
||||
@@ -777,6 +667,14 @@ describe("Conversation WebSocket Handler", () => {
|
||||
});
|
||||
|
||||
it("should append output to store when ExecuteBashObservation event is received", async () => {
|
||||
const { createMockExecuteBashObservationEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashObservation event
|
||||
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import {
|
||||
useGitHubIssuesPRs,
|
||||
useRefreshGitHubIssuesPRs,
|
||||
} from "../src/hooks/query/use-github-issues-prs";
|
||||
import { useShouldShowUserFeatures } from "../src/hooks/use-should-show-user-features";
|
||||
import GitHubIssuesPRsService from "../src/api/github-service/github-issues-prs.api";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("../src/hooks/use-should-show-user-features");
|
||||
vi.mock("../src/api/github-service/github-issues-prs.api", () => ({
|
||||
default: {
|
||||
getGitHubItems: vi.fn(),
|
||||
buildItemUrl: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures);
|
||||
const mockGetGitHubItems = vi.mocked(GitHubIssuesPRsService.getGitHubItems);
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe("useGitHubIssuesPRs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("should be disabled when useShouldShowUserFeatures returns false", () => {
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useGitHubIssuesPRs(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it("should be enabled when useShouldShowUserFeatures returns true", () => {
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(true);
|
||||
mockGetGitHubItems.mockResolvedValue({
|
||||
items: [],
|
||||
cached_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGitHubIssuesPRs(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// When enabled, the query should be loading/fetching
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("should fetch and return GitHub items", async () => {
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(true);
|
||||
const mockItems = [
|
||||
{
|
||||
git_provider: "github" as const,
|
||||
item_type: "issue" as const,
|
||||
status: "OPEN_ISSUE" as const,
|
||||
repo: "test/repo",
|
||||
number: 1,
|
||||
title: "Test Issue",
|
||||
author: "testuser",
|
||||
assignees: ["testuser"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
url: "https://github.com/test/repo/issues/1",
|
||||
},
|
||||
];
|
||||
mockGetGitHubItems.mockResolvedValue({
|
||||
items: mockItems,
|
||||
cached_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGitHubIssuesPRs(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data?.items).toEqual(mockItems);
|
||||
});
|
||||
|
||||
it("should filter by item type", async () => {
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(true);
|
||||
const mockItems = [
|
||||
{
|
||||
git_provider: "github" as const,
|
||||
item_type: "issue" as const,
|
||||
status: "OPEN_ISSUE" as const,
|
||||
repo: "test/repo",
|
||||
number: 1,
|
||||
title: "Test Issue",
|
||||
author: "testuser",
|
||||
assignees: ["testuser"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
url: "https://github.com/test/repo/issues/1",
|
||||
},
|
||||
];
|
||||
mockGetGitHubItems.mockResolvedValue({
|
||||
items: mockItems,
|
||||
cached_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useGitHubIssuesPRs({ itemType: "issues" }),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockGetGitHubItems).toHaveBeenCalledWith({ itemType: "issues" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRefreshGitHubIssuesPRs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("should clear localStorage cache when called", () => {
|
||||
// Set up some cached data
|
||||
localStorage.setItem(
|
||||
"github-issues-prs-cache",
|
||||
JSON.stringify({
|
||||
data: { items: [], cached_at: new Date().toISOString() },
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRefreshGitHubIssuesPRs(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Call the refresh function
|
||||
result.current();
|
||||
|
||||
// Check that localStorage was cleared
|
||||
expect(localStorage.getItem("github-issues-prs-cache")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
describe("useSaveSettings", () => {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
APP_MODE: appMode,
|
||||
FEATURE_FLAGS: { HIDE_LLM_SETTINGS: hideLlmSettings },
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
describe("useSettingsNavItems", () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("should return SAAS_NAV_ITEMS when APP_MODE is 'saas'", async () => {
|
||||
mockConfig("saas");
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual(SAAS_NAV_ITEMS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return OSS_NAV_ITEMS when APP_MODE is 'oss'", async () => {
|
||||
mockConfig("oss");
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual(OSS_NAV_ITEMS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter out '/settings' item when HIDE_LLM_SETTINGS feature flag is enabled", async () => {
|
||||
mockConfig("saas", true);
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeAll, describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
import { Command, useCommandStore } from "#/stores/command-store";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
// Mock the WsClient context
|
||||
@@ -42,46 +42,20 @@ describe("useTerminal", () => {
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
element: document.createElement("div"),
|
||||
}));
|
||||
|
||||
const mockFitAddon = vi.hoisted(() => ({
|
||||
fit: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
// mock ResizeObserver - use class for Vitest 4 constructor support
|
||||
window.ResizeObserver = class {
|
||||
observe = vi.fn();
|
||||
|
||||
unobserve = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
} as unknown as typeof ResizeObserver;
|
||||
|
||||
// mock Terminal - use class for Vitest 4 constructor support
|
||||
vi.mock("@xterm/xterm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@xterm/xterm")>()),
|
||||
Terminal: class {
|
||||
loadAddon = mockTerminal.loadAddon;
|
||||
|
||||
open = mockTerminal.open;
|
||||
|
||||
write = mockTerminal.write;
|
||||
|
||||
writeln = mockTerminal.writeln;
|
||||
|
||||
dispose = mockTerminal.dispose;
|
||||
|
||||
element = mockTerminal.element;
|
||||
},
|
||||
// mock ResizeObserver
|
||||
window.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// mock FitAddon
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class {
|
||||
fit = mockFitAddon.fit;
|
||||
},
|
||||
// mock Terminal
|
||||
vi.mock("@xterm/xterm", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@xterm/xterm")>()),
|
||||
Terminal: vi.fn().mockImplementation(() => mockTerminal),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -109,18 +83,4 @@ describe("useTerminal", () => {
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello");
|
||||
});
|
||||
|
||||
it("should not call fit() when terminal.element is null", () => {
|
||||
// Temporarily set element to null to simulate terminal not being opened
|
||||
const originalElement = mockTerminal.element;
|
||||
mockTerminal.element = null as unknown as HTMLDivElement;
|
||||
|
||||
renderWithProviders(<TestTerminalComponent />);
|
||||
|
||||
// fit() should not be called because terminal.element is null
|
||||
expect(mockFitAddon.fit).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original element
|
||||
mockTerminal.element = originalElement;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
/**
|
||||
* TODO: Fix flaky WebSocket tests (https://github.com/OpenHands/OpenHands/issues/11944)
|
||||
*
|
||||
* Several tests in this file are skipped because they fail intermittently in CI
|
||||
* but pass locally. The SUSPECTED root cause is that `wsLink.broadcast()` sends messages
|
||||
* to ALL connected clients across all tests, causing cross-test contamination
|
||||
* when tests run in parallel with Vitest v4.
|
||||
*/
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
describe,
|
||||
@@ -59,7 +51,7 @@ describe("useWebSocket", () => {
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it.skip("should handle incoming messages correctly", async () => {
|
||||
it("should handle incoming messages correctly", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Wait for connection to be established
|
||||
@@ -122,7 +114,7 @@ describe("useWebSocket", () => {
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it.skip("should close the WebSocket connection on unmount", async () => {
|
||||
it("should close the WebSocket connection on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws"),
|
||||
);
|
||||
@@ -212,7 +204,7 @@ describe("useWebSocket", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("should call onMessage handler when WebSocket receives a message", async () => {
|
||||
it("should call onMessage handler when WebSocket receives a message", async () => {
|
||||
const onMessageSpy = vi.fn();
|
||||
const options = { onMessage: onMessageSpy };
|
||||
|
||||
@@ -279,7 +271,7 @@ describe("useWebSocket", () => {
|
||||
expect(onErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip("should provide sendMessage function to send messages to WebSocket", async () => {
|
||||
it("should provide sendMessage function to send messages to WebSocket", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Wait for connection to be established
|
||||
|
||||
@@ -10,7 +10,7 @@ import MainApp from "#/routes/root-layout";
|
||||
import i18n from "#/i18n";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
describe("frontend/routes/_oh", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AppSettingsScreen from "#/routes/app-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
|
||||
@@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Routes, Route } from "react-router";
|
||||
import GitHubIssuesPRsPage from "#/routes/github-issues-prs";
|
||||
import GitHubIssuesPRsService from "#/api/github-service/github-issues-prs.api";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
// Mock the services
|
||||
vi.mock("#/api/github-service/github-issues-prs.api", () => ({
|
||||
default: {
|
||||
getGitHubItems: vi.fn(),
|
||||
buildItemUrl: vi.fn((provider, repo, number, type) => {
|
||||
if (provider === "github") {
|
||||
return `https://github.com/${repo}/${type === "issue" ? "issues" : "pull"}/${number}`;
|
||||
}
|
||||
return "";
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/api/conversation-service/conversation-service.api", () => ({
|
||||
default: {
|
||||
searchConversations: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-should-show-user-features", () => ({
|
||||
useShouldShowUserFeatures: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next to return the key as the translation
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockGetGitHubItems = vi.mocked(GitHubIssuesPRsService.getGitHubItems);
|
||||
const mockSearchConversations = vi.mocked(
|
||||
ConversationService.searchConversations,
|
||||
);
|
||||
const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures);
|
||||
const mockUseUserProviders = vi.mocked(useUserProviders);
|
||||
|
||||
const renderGitHubIssuesPRsPage = () =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MemoryRouter initialEntries={["/github-issues-prs"]}>
|
||||
<Routes>
|
||||
<Route path="/github-issues-prs" element={<GitHubIssuesPRsPage />} />
|
||||
<Route
|
||||
path="/conversations/:conversationId"
|
||||
element={<div data-testid="conversation-screen" />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const MOCK_GITHUB_ITEMS = [
|
||||
{
|
||||
git_provider: "github" as const,
|
||||
item_type: "issue" as const,
|
||||
status: "OPEN_ISSUE" as const,
|
||||
repo: "test/repo",
|
||||
number: 1,
|
||||
title: "Test Issue",
|
||||
author: "testuser",
|
||||
assignees: ["testuser"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
url: "https://github.com/test/repo/issues/1",
|
||||
},
|
||||
{
|
||||
git_provider: "github" as const,
|
||||
item_type: "pr" as const,
|
||||
status: "OPEN_PR" as const,
|
||||
repo: "test/repo",
|
||||
number: 2,
|
||||
title: "Test PR",
|
||||
author: "testuser",
|
||||
assignees: [],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
url: "https://github.com/test/repo/pull/2",
|
||||
},
|
||||
];
|
||||
|
||||
describe("GitHubIssuesPRsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(true);
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
isLoadingSettings: false,
|
||||
});
|
||||
|
||||
mockGetGitHubItems.mockResolvedValue({
|
||||
items: MOCK_GITHUB_ITEMS,
|
||||
cached_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
mockSearchConversations.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("should render the page title", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("GITHUB_ISSUES_PRS$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the view type selector", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
// The view label has a colon after it
|
||||
expect(screen.getByText("GITHUB_ISSUES_PRS$VIEW:")).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render filter checkboxes", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITHUB_ISSUES_PRS$ASSIGNED_TO_ME"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("GITHUB_ISSUES_PRS$AUTHORED_BY_ME"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the refresh button", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("GITHUB_ISSUES_PRS$REFRESH")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display GitHub items when loaded", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test PR")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display item status badges", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITHUB_ISSUES_PRS$OPEN_ISSUE"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("GITHUB_ISSUES_PRS$OPEN_PR")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display Start Session buttons for items without related conversations", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
const startButtons = screen.getAllByText(
|
||||
"GITHUB_ISSUES_PRS$START_SESSION",
|
||||
);
|
||||
expect(startButtons.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter items when view type is changed to issues", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test PR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change view type to issues
|
||||
const select = screen.getByRole("combobox");
|
||||
await userEvent.selectOptions(select, "issues");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Issue")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test PR")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter items when view type is changed to PRs", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test PR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change view type to PRs
|
||||
const select = screen.getByRole("combobox");
|
||||
await userEvent.selectOptions(select, "prs");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test Issue")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test PR")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display Resume Session button when a related conversation exists", async () => {
|
||||
mockSearchConversations.mockResolvedValue([
|
||||
{
|
||||
conversation_id: "conv-1",
|
||||
title: "Working on #1",
|
||||
selected_repository: "test/repo",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
pr_number: [1],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
last_updated_at: "2024-01-01T00:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
]);
|
||||
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITHUB_ISSUES_PRS$RESUME_SESSION"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show empty state when no items are found", async () => {
|
||||
mockGetGitHubItems.mockResolvedValue({
|
||||
items: [],
|
||||
cached_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITHUB_ISSUES_PRS$NO_ITEMS"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display View on GitHub links", async () => {
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
const viewLinks = screen.getAllByText("GITHUB_ISSUES_PRS$VIEW_ON_GITHUB");
|
||||
expect(viewLinks.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state while checking settings", async () => {
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: [],
|
||||
isLoadingSettings: true,
|
||||
});
|
||||
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
// Should show loading spinner
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show no-token message when GitHub token is not configured", async () => {
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: [],
|
||||
isLoadingSettings: false,
|
||||
});
|
||||
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("GITHUB_ISSUES_PRS$NO_TOKEN")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("GITHUB_ISSUES_PRS$CONFIGURE_TOKEN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should have a link to git settings when no token is configured", async () => {
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: [],
|
||||
isLoadingSettings: false,
|
||||
});
|
||||
|
||||
renderGitHubIssuesPRsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
const configureLink = screen.getByText("GITHUB_ISSUES_PRS$CONFIGURE_TOKEN");
|
||||
expect(configureLink).toHaveAttribute("href", "/settings/git");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { createRoutesStub } from "react-router";
|
||||
import { createAxiosNotFoundErrorObject } from "test-utils";
|
||||
import HomeScreen from "#/routes/home";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import MainApp from "#/routes/root-layout";
|
||||
|
||||
@@ -3,14 +3,13 @@ import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import {
|
||||
MOCK_DEFAULT_USER_SETTINGS,
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
@@ -72,7 +71,7 @@ describe("Content", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
expect(model).toHaveValue("claude-opus-4-5-20251101");
|
||||
expect(model).toHaveValue("claude-sonnet-4-20250514");
|
||||
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
@@ -190,7 +189,7 @@ describe("Content", () => {
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
expect(model).toHaveValue("openhands/claude-opus-4-5-20251101");
|
||||
expect(model).toHaveValue("openhands/claude-sonnet-4-20250514");
|
||||
expect(baseUrl).toHaveValue("");
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
@@ -253,290 +252,9 @@ describe("Content", () => {
|
||||
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit invariant and custom analyzers when V1 is enabled", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
confirmation_mode: true,
|
||||
security_analyzer: "llm",
|
||||
v1_enabled: true,
|
||||
});
|
||||
|
||||
const getSecurityAnalyzersSpy = vi.spyOn(
|
||||
OptionService,
|
||||
"getSecurityAnalyzers",
|
||||
);
|
||||
getSecurityAnalyzersSpy.mockResolvedValue([
|
||||
"llm",
|
||||
"none",
|
||||
"invariant",
|
||||
"custom",
|
||||
]);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
const securityAnalyzer = await screen.findByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
await userEvent.click(securityAnalyzer);
|
||||
|
||||
// Only llm + none should be available when V1 is enabled
|
||||
screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
|
||||
screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
expect(
|
||||
screen.queryByText("SETTINGS$SECURITY_ANALYZER_INVARIANT"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("custom")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should include invariant analyzer option when V1 is disabled", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
confirmation_mode: true,
|
||||
security_analyzer: "llm",
|
||||
v1_enabled: false,
|
||||
});
|
||||
|
||||
const getSecurityAnalyzersSpy = vi.spyOn(
|
||||
OptionService,
|
||||
"getSecurityAnalyzers",
|
||||
);
|
||||
getSecurityAnalyzersSpy.mockResolvedValue(["llm", "none", "invariant"]);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
const securityAnalyzer = await screen.findByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
await userEvent.click(securityAnalyzer);
|
||||
|
||||
expect(
|
||||
screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("SETTINGS$SECURITY_ANALYZER_INVARIANT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.todo("should render an indicator if the llm api key is set");
|
||||
|
||||
describe("API key visibility in Basic Settings", () => {
|
||||
it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Verify OpenHands is selected by default
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
});
|
||||
|
||||
// API key input should not be visible when OpenHands provider is selected in SaaS mode
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Select OpenAI provider
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// API key input should be visible when non-OpenHands provider is selected in SaaS mode
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Verify OpenHands is selected by default
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
});
|
||||
|
||||
// API key input should be visible when OSS mode is enabled (even with OpenHands provider)
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Select OpenAI provider
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// API key input should be visible when OSS mode is enabled
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Start with OpenAI provider
|
||||
await userEvent.click(provider);
|
||||
const openAIOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(openAIOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// API key input should be visible with OpenAI
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Switch to OpenHands provider
|
||||
await userEvent.click(provider);
|
||||
const openHandsOption = screen.getByText("OpenHands");
|
||||
await userEvent.click(openHandsOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
});
|
||||
|
||||
// API key input should now be hidden
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Verify OpenHands is selected by default
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
});
|
||||
|
||||
// API key input should be hidden with OpenHands
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-input"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Switch to OpenAI provider
|
||||
await userEvent.click(provider);
|
||||
const openAIOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(openAIOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// API key input should now be visible
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SecretsSettingsScreen from "#/routes/secrets-settings";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
|
||||
@@ -21,25 +21,25 @@ const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
|
||||
},
|
||||
];
|
||||
|
||||
const renderSecretsSettings = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <Outlet />,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
Component: SecretsSettingsScreen,
|
||||
path: "/settings/secrets",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <Outlet />,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
Component: SecretsSettingsScreen,
|
||||
path: "/settings/secrets",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
return render(<RouterStub initialEntries={["/settings/secrets"]} />, {
|
||||
const renderSecretsSettings = () =>
|
||||
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
@@ -52,7 +52,6 @@ const renderSecretsSettings = () => {
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
@@ -62,10 +61,6 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render the secrets settings screen", () => {
|
||||
renderSecretsSettings();
|
||||
@@ -506,8 +501,6 @@ describe("Secret actions", () => {
|
||||
|
||||
it("should not submit whitespace secret names or values", async () => {
|
||||
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue([]);
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
@@ -539,11 +532,9 @@ describe("Secret actions", () => {
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(createSecretSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not reset ipout values on an invalid submit", async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import ActionType from "#/types/action-type";
|
||||
import { ActionMessage } from "#/types/message";
|
||||
import { useCommandStore } from "#/stores/command-store";
|
||||
|
||||
// Mock the store and actions
|
||||
const mockDispatch = vi.fn();
|
||||
const mockAppendInput = vi.fn();
|
||||
|
||||
@@ -12,12 +12,26 @@ vi.mock("#/store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/command-store", () => ({
|
||||
useCommandStore: {
|
||||
getState: () => ({
|
||||
appendInput: mockAppendInput,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/metrics-slice", () => ({
|
||||
setMetrics: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/state/security-analyzer-slice", () => ({
|
||||
appendSecurityAnalyzerInput: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("handleActionMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
useCommandStore.setState({
|
||||
appendInput: mockAppendInput,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle RUN actions by adding input to terminal", async () => {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import GitHubIssuesPRsService from "../../src/api/github-service/github-issues-prs.api";
|
||||
|
||||
describe("GitHubIssuesPRsService", () => {
|
||||
describe("buildItemUrl", () => {
|
||||
it("should build correct GitHub issue URL", () => {
|
||||
const url = GitHubIssuesPRsService.buildItemUrl(
|
||||
"github",
|
||||
"owner/repo",
|
||||
123,
|
||||
"issue",
|
||||
);
|
||||
expect(url).toBe("https://github.com/owner/repo/issues/123");
|
||||
});
|
||||
|
||||
it("should build correct GitHub PR URL", () => {
|
||||
const url = GitHubIssuesPRsService.buildItemUrl(
|
||||
"github",
|
||||
"owner/repo",
|
||||
456,
|
||||
"pr",
|
||||
);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/456");
|
||||
});
|
||||
|
||||
it("should build correct GitLab issue URL", () => {
|
||||
const url = GitHubIssuesPRsService.buildItemUrl(
|
||||
"gitlab",
|
||||
"owner/repo",
|
||||
123,
|
||||
"issue",
|
||||
);
|
||||
expect(url).toBe("https://gitlab.com/owner/repo/-/issues/123");
|
||||
});
|
||||
|
||||
it("should build correct GitLab MR URL", () => {
|
||||
const url = GitHubIssuesPRsService.buildItemUrl(
|
||||
"gitlab",
|
||||
"owner/repo",
|
||||
456,
|
||||
"pr",
|
||||
);
|
||||
expect(url).toBe("https://gitlab.com/owner/repo/-/merge_requests/456");
|
||||
});
|
||||
|
||||
it("should build correct Bitbucket issue URL", () => {
|
||||
const url = GitHubIssuesPRsService.buildItemUrl(
|
||||
"bitbucket",
|
||||
"owner/repo",
|
||||
123,
|
||||
"issue",
|
||||
);
|
||||
expect(url).toBe("https://bitbucket.org/owner/repo/issues/123");
|
||||
});
|
||||
|
||||
it("should build correct Bitbucket PR URL", () => {
|
||||
const url = GitHubIssuesPRsService.buildItemUrl(
|
||||
"bitbucket",
|
||||
"owner/repo",
|
||||
456,
|
||||
"pr",
|
||||
);
|
||||
expect(url).toBe("https://bitbucket.org/owner/repo/pull-requests/456");
|
||||
});
|
||||
|
||||
it("should return empty string for unknown provider", () => {
|
||||
const url = GitHubIssuesPRsService.buildItemUrl(
|
||||
"unknown" as any,
|
||||
"owner/repo",
|
||||
123,
|
||||
"issue",
|
||||
);
|
||||
expect(url).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,20 +12,20 @@ describe("hasAdvancedSettingsSet", () => {
|
||||
});
|
||||
|
||||
describe("should be true if", () => {
|
||||
test("llm_base_url is set", () => {
|
||||
test("LLM_BASE_URL is set", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
llm_base_url: "test",
|
||||
LLM_BASE_URL: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("agent is not default value", () => {
|
||||
test("AGENT is not default value", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
agent: "test",
|
||||
AGENT: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("Model name case preservation", () => {
|
||||
const settings = extractSettings(formData);
|
||||
|
||||
// Test that model names maintain their original casing
|
||||
expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
|
||||
expect(settings.LLM_MODEL).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
|
||||
});
|
||||
|
||||
it("should preserve openai model case", () => {
|
||||
@@ -24,7 +24,7 @@ describe("Model name case preservation", () => {
|
||||
formData.set("language", "en");
|
||||
|
||||
const settings = extractSettings(formData);
|
||||
expect(settings.llm_model).toBe("openai/gpt-4o");
|
||||
expect(settings.LLM_MODEL).toBe("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("should preserve anthropic model case", () => {
|
||||
@@ -35,7 +35,7 @@ describe("Model name case preservation", () => {
|
||||
formData.set("language", "en");
|
||||
|
||||
const settings = extractSettings(formData);
|
||||
expect(settings.llm_model).toBe("anthropic/claude-sonnet-4-20250514");
|
||||
expect(settings.LLM_MODEL).toBe("anthropic/claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should not automatically lowercase model names", () => {
|
||||
@@ -48,7 +48,7 @@ describe("Model name case preservation", () => {
|
||||
const settings = extractSettings(formData);
|
||||
|
||||
// Test that camelCase and PascalCase are preserved
|
||||
expect(settings.llm_model).not.toBe("sambanova/meta-llama-3.1-8b-instruct");
|
||||
expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
|
||||
expect(settings.LLM_MODEL).not.toBe("sambanova/meta-llama-3.1-8b-instruct");
|
||||
expect(settings.LLM_MODEL).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
|
||||
});
|
||||
});
|
||||
|
||||
5508
frontend/package-lock.json
generated
5508
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,63 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.62.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.8.6",
|
||||
"@heroui/react": "2.8.5",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.11.0",
|
||||
"@react-router/serve": "^7.11.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@posthog/react": "^1.4.0",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@react-router/serve": "^7.9.3",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.12.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"downshift": "^9.0.13",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.25",
|
||||
"i18next": "^25.7.3",
|
||||
"framer-motion": "^12.23.22",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.32",
|
||||
"lucide-react": "^0.562.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"posthog-js": "^1.309.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"isbot": "^5.1.31",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"posthog-js": "^1.298.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.11.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-router": "^7.9.3",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"vite": "^7.3.0",
|
||||
"zustand": "^5.0.9"
|
||||
"vite": "^7.1.7",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
||||
@@ -80,23 +92,29 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/traverse": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@react-router/dev": "^7.11.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@react-router/dev": "^7.9.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.15",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"cross-env": "^10.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^10.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
@@ -109,15 +127,16 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.3.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"jsdom": "^27.0.0",
|
||||
"lint-staged": "^16.2.3",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.7.3",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.5.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-tsconfig-paths": "^6.0.3",
|
||||
"vitest": "^4.0.14"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.2"
|
||||
},
|
||||
"packageManager": "npm@10.5.0",
|
||||
"volta": {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.12.4'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const PACKAGE_VERSION = '2.11.1'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@@ -71,6 +71,11 @@ addEventListener('message', async function (event) {
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
@@ -89,8 +94,6 @@ addEventListener('message', async function (event) {
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
@@ -107,29 +110,23 @@ addEventListener('fetch', function (event) {
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
@@ -205,10 +202,9 @@ async function resolveMainClient(event) {
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
async function getResponse(event, client, requestId) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
@@ -259,7 +255,6 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
GetSkillsResponse,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
@@ -316,18 +315,6 @@ class V1ConversationService {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all skills associated with a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns The available skills associated with the conversation
|
||||
*/
|
||||
static async getSkills(conversationId: string): Promise<GetSkillsResponse> {
|
||||
const { data } = await openHands.get<GetSkillsResponse>(
|
||||
`/api/v1/app-conversations/${conversationId}/skills`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
|
||||
@@ -99,14 +99,3 @@ export interface V1AppConversation {
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
type: "repo" | "knowledge";
|
||||
content: string;
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export interface GetSkillsResponse {
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ConfirmationResponseRequest,
|
||||
ConfirmationResponseResponse,
|
||||
} from "./event-service.types";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
class EventService {
|
||||
/**
|
||||
@@ -37,27 +38,11 @@ class EventService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event count for a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns The event count
|
||||
*/
|
||||
static async getEventCount(
|
||||
conversationId: string,
|
||||
conversationUrl: string,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<number> {
|
||||
// Build the runtime URL using the conversation URL
|
||||
const runtimeUrl = buildHttpBaseUrl(conversationUrl);
|
||||
|
||||
// Build session headers for authentication
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.get<number>(
|
||||
`${runtimeUrl}/api/conversations/${conversationId}/events/count`,
|
||||
{ headers },
|
||||
static async getEventCount(conversationId: string): Promise<number> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("conversation_id__eq", conversationId);
|
||||
const { data } = await openHands.get<number>(
|
||||
`/api/v1/events/count?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export type GitHubItemType = "issue" | "pr";
|
||||
|
||||
export type GitHubItemStatus =
|
||||
| "MERGE_CONFLICTS"
|
||||
| "FAILING_CHECKS"
|
||||
| "UNRESOLVED_COMMENTS"
|
||||
| "OPEN_ISSUE"
|
||||
| "OPEN_PR";
|
||||
|
||||
export interface GitHubItem {
|
||||
git_provider: Provider;
|
||||
item_type: GitHubItemType;
|
||||
status: GitHubItemStatus;
|
||||
repo: string;
|
||||
number: number;
|
||||
title: string;
|
||||
author: string;
|
||||
assignees: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GitHubItemsFilter {
|
||||
itemType?: "issues" | "prs" | "all";
|
||||
assignedToMe?: boolean;
|
||||
authoredByMe?: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubItemsResponse {
|
||||
items: GitHubItem[];
|
||||
cached_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Issues/PRs Service - Handles fetching GitHub issues and pull requests
|
||||
*/
|
||||
class GitHubIssuesPRsService {
|
||||
/**
|
||||
* Get GitHub issues and PRs for the authenticated user
|
||||
* This uses the existing suggested-tasks endpoint and transforms the data
|
||||
*/
|
||||
static async getGitHubItems(
|
||||
filter?: GitHubItemsFilter,
|
||||
): Promise<GitHubItemsResponse> {
|
||||
const { data } = await openHands.get<
|
||||
Array<{
|
||||
git_provider: Provider;
|
||||
task_type: GitHubItemStatus;
|
||||
repo: string;
|
||||
issue_number: number;
|
||||
title: string;
|
||||
}>
|
||||
>("/api/user/suggested-tasks");
|
||||
|
||||
// Transform the suggested tasks into GitHubItems
|
||||
const items: GitHubItem[] = data.map((task) => ({
|
||||
git_provider: task.git_provider,
|
||||
item_type: task.task_type === "OPEN_ISSUE" ? "issue" : "pr",
|
||||
status: task.task_type,
|
||||
repo: task.repo,
|
||||
number: task.issue_number,
|
||||
title: task.title,
|
||||
author: "", // Not available from suggested-tasks endpoint
|
||||
assignees: [], // Not available from suggested-tasks endpoint
|
||||
created_at: new Date().toISOString(), // Not available from suggested-tasks endpoint
|
||||
updated_at: new Date().toISOString(), // Not available from suggested-tasks endpoint
|
||||
url: GitHubIssuesPRsService.buildItemUrl(
|
||||
task.git_provider,
|
||||
task.repo,
|
||||
task.issue_number,
|
||||
task.task_type === "OPEN_ISSUE" ? "issue" : "pr",
|
||||
),
|
||||
}));
|
||||
|
||||
// Apply filters
|
||||
let filteredItems = items;
|
||||
|
||||
if (filter?.itemType === "issues") {
|
||||
filteredItems = filteredItems.filter(
|
||||
(item) => item.item_type === "issue",
|
||||
);
|
||||
} else if (filter?.itemType === "prs") {
|
||||
filteredItems = filteredItems.filter((item) => item.item_type === "pr");
|
||||
}
|
||||
|
||||
// Note: assignedToMe and authoredByMe filters would require additional API data
|
||||
// For now, the suggested-tasks endpoint already returns:
|
||||
// - PRs authored by the user
|
||||
// - Issues assigned to the user
|
||||
// So these filters are implicitly applied by the backend
|
||||
|
||||
return {
|
||||
items: filteredItems,
|
||||
cached_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL for a GitHub item
|
||||
*/
|
||||
static buildItemUrl(
|
||||
provider: Provider,
|
||||
repo: string,
|
||||
number: number,
|
||||
itemType: GitHubItemType,
|
||||
): string {
|
||||
if (provider === "github") {
|
||||
return `https://github.com/${repo}/${itemType === "issue" ? "issues" : "pull"}/${number}`;
|
||||
}
|
||||
if (provider === "gitlab") {
|
||||
return `https://gitlab.com/${repo}/-/${itemType === "issue" ? "issues" : "merge_requests"}/${number}`;
|
||||
}
|
||||
if (provider === "bitbucket") {
|
||||
return `https://bitbucket.org/${repo}/${itemType === "issue" ? "issues" : "pull-requests"}/${number}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default GitHubIssuesPRsService;
|
||||
@@ -12,9 +12,10 @@ export function BrowserPanel() {
|
||||
reset();
|
||||
}, [conversationId, reset]);
|
||||
|
||||
const imgSrc = screenshotSrc?.startsWith("data:image/png;base64,")
|
||||
? screenshotSrc
|
||||
: `data:image/png;base64,${screenshotSrc ?? ""}`;
|
||||
const imgSrc =
|
||||
screenshotSrc && screenshotSrc.startsWith("data:image/png;base64,")
|
||||
? screenshotSrc
|
||||
: `data:image/png;base64,${screenshotSrc || ""}`;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col text-neutral-400">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import CodeTagIcon from "#/icons/code-tag.svg?react";
|
||||
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { ChangeAgentContextMenu } from "./change-agent-context-menu";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
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 { useConversationStore } from "#/state/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import {
|
||||
isV0Event,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Suggestions } from "#/components/features/suggestions/suggestions";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import BuildIt from "#/icons/build-it.svg?react";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
interface ChatSuggestionsProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DragOver } from "../drag-over";
|
||||
import { UploadedFiles } from "../uploaded-files";
|
||||
import { ChatInputRow } from "./chat-input-row";
|
||||
import { ChatInputActions } from "./chat-input-actions";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ChatInputContainerProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
interface ChatInputFieldProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
|
||||
@@ -9,7 +9,7 @@ function ConfirmationModeEnabled() {
|
||||
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
if (!settings?.confirmation_mode) {
|
||||
if (!settings?.CONFIRMATION_MODE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useChatSubmission } from "#/hooks/chat/use-chat-submission";
|
||||
import { ChatInputGrip } from "./components/chat-input-grip";
|
||||
import { ChatInputContainer } from "./components/chat-input-container";
|
||||
import { HiddenFileInput } from "./components/hidden-file-input";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
export interface CustomChatInputProps {
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -22,13 +22,6 @@ const getCommandObservationContent = (
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
const command = event.observation === "run" ? event.extras.command : null;
|
||||
|
||||
if (command) {
|
||||
return `Command:\n\`\`\`sh\n${command}\n\`\`\`\n\nOutput:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
|
||||
}
|
||||
|
||||
return `Output:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
|
||||
};
|
||||
|
||||
@@ -140,7 +133,7 @@ const getTaskTrackingObservationContent = (
|
||||
content += "\n\n**Task List:** Empty";
|
||||
}
|
||||
|
||||
if (event.content?.trim()) {
|
||||
if (event.content && event.content.trim()) {
|
||||
content += `\n\n**Result:** ${event.content.trim()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import ArrowDown from "#/icons/angle-down-solid.svg?react";
|
||||
import ArrowUp from "#/icons/angle-up-solid.svg?react";
|
||||
import CheckCircle from "#/icons/check-circle-solid.svg?react";
|
||||
import XCircle from "#/icons/x-circle-solid.svg?react";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import { cn } from "#/utils/utils";
|
||||
@@ -94,7 +95,7 @@ export function ExpandableMessage({
|
||||
const statusIconClasses = "h-4 w-4 ml-2 inline";
|
||||
|
||||
if (
|
||||
config?.FEATURE_FLAGS?.ENABLE_BILLING &&
|
||||
config?.FEATURE_FLAGS.ENABLE_BILLING &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
id === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS
|
||||
) {
|
||||
@@ -168,12 +169,19 @@ export function ExpandableMessage({
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
{type === "action" && success && (
|
||||
{type === "action" && success !== undefined && (
|
||||
<span className="flex-shrink-0">
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-success")}
|
||||
/>
|
||||
{success ? (
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-success")}
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-danger")}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CustomChatInput } from "./custom-chat-input";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { GitControlBar } from "./git-control-bar";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { processFiles, processImages } from "#/utils/file-processing";
|
||||
import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling";
|
||||
|
||||
@@ -192,7 +192,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
) => {
|
||||
const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`;
|
||||
if (
|
||||
!conversation?.selected_repository ||
|
||||
!conversation ||
|
||||
!conversation.selected_repository ||
|
||||
!conversation.selected_branch ||
|
||||
!conversation.git_provider ||
|
||||
!selectedEventId
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FaClock } from "react-icons/fa";
|
||||
import CheckCircle from "#/icons/check-circle-solid.svg?react";
|
||||
import XCircle from "#/icons/x-circle-solid.svg?react";
|
||||
import { ObservationResultStatus } from "./event-content-helpers/get-observation-result";
|
||||
|
||||
interface SuccessIndicatorProps {
|
||||
@@ -16,6 +17,13 @@ export function SuccessIndicator({ status }: SuccessIndicatorProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<XCircle
|
||||
data-testid="status-icon"
|
||||
className="h-4 w-4 ml-2 inline fill-danger"
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "timeout" && (
|
||||
<FaClock
|
||||
data-testid="status-icon"
|
||||
|
||||
@@ -24,7 +24,7 @@ export function TaskItem({ task }: TaskItemProps) {
|
||||
case "todo":
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
case "in_progress":
|
||||
return <LoadingIcon className="w-4 h-4 text-[#ffffff] animate-spin" />;
|
||||
return <LoadingIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
case "done":
|
||||
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UploadedFile } from "./uploaded-file";
|
||||
import { UploadedImage } from "./uploaded-image";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
export function UploadedFiles() {
|
||||
const {
|
||||
|
||||
@@ -5,10 +5,11 @@ import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "./context-menu-list-item";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import LogOutIcon from "#/icons/log-out.svg?react";
|
||||
import DocumentIcon from "#/icons/document.svg?react";
|
||||
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
|
||||
interface AccountSettingsContextMenuProps {
|
||||
onLogout: () => void;
|
||||
@@ -21,17 +22,21 @@ export function AccountSettingsContextMenu({
|
||||
}: AccountSettingsContextMenuProps) {
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { t } = useTranslation();
|
||||
// Get navigation items and filter out LLM settings if the feature flag is enabled
|
||||
const items = useSettingsNavItems();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const navItems = items.map((item) => ({
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const navItems = (isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS).map((item) => ({
|
||||
...item,
|
||||
icon: React.cloneElement(item.icon, {
|
||||
width: 16,
|
||||
height: 16,
|
||||
} as React.SVGProps<SVGSVGElement>),
|
||||
}));
|
||||
const handleNavigationClick = () => onClose();
|
||||
|
||||
const handleNavigationClick = () => {
|
||||
onClose();
|
||||
// The Link component will handle the actual navigation
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
@@ -43,7 +48,7 @@ export function AccountSettingsContextMenu({
|
||||
{navItems.map(({ to, text, icon }) => (
|
||||
<Link key={to} to={to} className="text-decoration-none">
|
||||
<ContextMenuListItem
|
||||
onClick={handleNavigationClick}
|
||||
onClick={() => handleNavigationClick()}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useStatusStore } from "#/stores/status-store";
|
||||
import { useStatusStore } from "#/state/status-store";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { getStatusCode } from "#/utils/status";
|
||||
import { ChatStopButton } from "../chat/chat-stop-button";
|
||||
@@ -9,7 +9,7 @@ import ClockIcon from "#/icons/u-clock-three.svg?react";
|
||||
import { ChatResumeAgentButton } from "../chat/chat-play-button";
|
||||
import { cn, isTaskPolling } from "#/utils/utils";
|
||||
import { AgentLoading } from "./agent-loading";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import CircleErrorIcon from "#/icons/circle-error.svg?react";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||
@@ -59,15 +59,13 @@ export function AgentStatus({
|
||||
);
|
||||
|
||||
const shouldShownAgentLoading =
|
||||
isPausing ||
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING ||
|
||||
(webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") ||
|
||||
isTaskPolling(taskStatus) ||
|
||||
isTaskPolling(subConversationTaskStatus);
|
||||
|
||||
// For UI rendering - includes pause state
|
||||
const isLoading = shouldShownAgentLoading || isPausing;
|
||||
|
||||
const shouldShownAgentError =
|
||||
curAgentState === AgentState.ERROR ||
|
||||
curAgentState === AgentState.RATE_LIMITED ||
|
||||
@@ -95,28 +93,25 @@ export function AgentStatus({
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#525252] box-border content-stretch flex flex-row gap-[3px] items-center justify-center overflow-clip px-0.5 py-1 relative rounded-[100px] shrink-0 size-6 transition-all duration-200 active:scale-95",
|
||||
!isLoading &&
|
||||
!shouldShownAgentLoading &&
|
||||
(shouldShownAgentStop || shouldShownAgentResume) &&
|
||||
"hover:bg-[#737373] cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{isLoading && <AgentLoading />}
|
||||
{!isLoading && shouldShownAgentStop && (
|
||||
{shouldShownAgentLoading && <AgentLoading />}
|
||||
{!shouldShownAgentLoading && shouldShownAgentStop && (
|
||||
<ChatStopButton handleStop={handleStop} />
|
||||
)}
|
||||
{!isLoading && shouldShownAgentResume && (
|
||||
{!shouldShownAgentLoading && shouldShownAgentResume && (
|
||||
<ChatResumeAgentButton
|
||||
onAgentResumed={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && shouldShownAgentError && (
|
||||
<CircleErrorIcon
|
||||
className="w-4 h-4"
|
||||
data-testid="circle-error-icon"
|
||||
/>
|
||||
{!shouldShownAgentLoading && shouldShownAgentError && (
|
||||
<CircleErrorIcon className="w-4 h-4" />
|
||||
)}
|
||||
{!isLoading &&
|
||||
{!shouldShownAgentLoading &&
|
||||
!shouldShownAgentStop &&
|
||||
!shouldShownAgentResume &&
|
||||
!shouldShownAgentError && <ClockIcon className="w-4 h-4" />}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user