mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Merge main and refresh settings schema PR for current CI
- merge latest main into the gui settings schema branch - regenerate root and enterprise lockfiles after dependency changes - fix stale llm-settings test to use sdk_settings_schema Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
8
.github/workflows/ghcr-build.yml
vendored
8
.github/workflows/ghcr-build.yml
vendored
@@ -219,11 +219,9 @@ jobs:
|
||||
- name: Determine app image tag
|
||||
shell: bash
|
||||
run: |
|
||||
# Duplicated with build.sh
|
||||
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
|
||||
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
|
||||
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
|
||||
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
|
||||
# Use the commit SHA to pin the exact app image built by ghcr_build_app,
|
||||
# rather than a mutable branch tag like "main" which can serve stale cached layers.
|
||||
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
|
||||
- name: Build and push Docker image
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
|
||||
8
enterprise/poetry.lock
generated
8
enterprise/poetry.lock
generated
@@ -6394,7 +6394,7 @@ pybase62 = ">=1"
|
||||
pygithub = ">=2.5"
|
||||
pyjwt = ">=2.12"
|
||||
pylatexenc = "*"
|
||||
pypdf = ">=6.7.2"
|
||||
pypdf = ">=6.9.1"
|
||||
python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = ">=1.1"
|
||||
@@ -11708,14 +11708,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.9.0"
|
||||
version = "6.9.1"
|
||||
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pypdf-6.9.0-py3-none-any.whl", hash = "sha256:85805ad7457ca878c4cfd1bc026c4b3dcae359b4a80f889fa7e8c5a1c1a83e51"},
|
||||
{file = "pypdf-6.9.0.tar.gz", hash = "sha256:a59257869fc575ba2ccc10100a36be0a47cd1bc1fb00f2950abf1d219fa94c01"},
|
||||
{file = "pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f"},
|
||||
{file = "pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
|
||||
@@ -35,7 +35,7 @@ Usage:
|
||||
from enum import Enum
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.role import Role
|
||||
from storage.role_store import RoleStore
|
||||
@@ -214,6 +214,19 @@ def has_permission(user_role: Role, permission: Permission) -> bool:
|
||||
return permission in permissions
|
||||
|
||||
|
||||
async def get_api_key_org_id_from_request(request: Request) -> UUID | None:
|
||||
"""Get the org_id bound to the API key used for authentication.
|
||||
|
||||
Returns None if:
|
||||
- Not authenticated via API key (cookie auth)
|
||||
- API key is a legacy key without org binding
|
||||
"""
|
||||
user_auth = getattr(request.state, 'user_auth', None)
|
||||
if user_auth and hasattr(user_auth, 'get_api_key_org_id'):
|
||||
return user_auth.get_api_key_org_id()
|
||||
return None
|
||||
|
||||
|
||||
def require_permission(permission: Permission):
|
||||
"""
|
||||
Factory function that creates a dependency to require a specific permission.
|
||||
@@ -221,8 +234,9 @@ def require_permission(permission: Permission):
|
||||
This creates a FastAPI dependency that:
|
||||
1. Extracts org_id from the path parameter
|
||||
2. Gets the authenticated user_id
|
||||
3. Checks if the user has the required permission in the organization
|
||||
4. Returns the user_id if authorized, raises HTTPException otherwise
|
||||
3. Validates API key org binding (if using API key auth)
|
||||
4. Checks if the user has the required permission in the organization
|
||||
5. Returns the user_id if authorized, raises HTTPException otherwise
|
||||
|
||||
Usage:
|
||||
@router.get('/{org_id}/settings')
|
||||
@@ -240,6 +254,7 @@ def require_permission(permission: Permission):
|
||||
"""
|
||||
|
||||
async def permission_checker(
|
||||
request: Request,
|
||||
org_id: UUID | None = None,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> str:
|
||||
@@ -249,6 +264,23 @@ def require_permission(permission: Permission):
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
# Validate API key organization binding
|
||||
api_key_org_id = await get_api_key_org_id_from_request(request)
|
||||
if api_key_org_id is not None and org_id is not None:
|
||||
if api_key_org_id != org_id:
|
||||
logger.warning(
|
||||
'API key organization mismatch',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'api_key_org_id': str(api_key_org_id),
|
||||
'target_org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='API key is not authorized for this organization',
|
||||
)
|
||||
|
||||
user_role = await get_user_org_role(user_id, org_id)
|
||||
|
||||
if not user_role:
|
||||
|
||||
@@ -61,10 +61,19 @@ class SaasUserAuth(UserAuth):
|
||||
accepted_tos: bool | None = None
|
||||
auth_type: AuthType = AuthType.COOKIE
|
||||
# API key context fields - populated when authenticated via API key
|
||||
api_key_org_id: UUID | None = None
|
||||
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
|
||||
api_key_id: int | None = None
|
||||
api_key_name: str | None = None
|
||||
|
||||
def get_api_key_org_id(self) -> UUID | None:
|
||||
"""Get the organization ID bound to the API key used for authentication.
|
||||
|
||||
Returns:
|
||||
The org_id if authenticated via API key with org binding, None otherwise
|
||||
(cookie auth or legacy API keys without org binding).
|
||||
"""
|
||||
return self.api_key_org_id
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_id
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
@dataclass
|
||||
class ApiKeyValidationResult:
|
||||
"""Result of API key validation containing user and org context."""
|
||||
"""Result of API key validation containing user and organization info."""
|
||||
|
||||
user_id: str
|
||||
org_id: UUID | None
|
||||
org_id: UUID | None # None for legacy API keys without org binding
|
||||
key_id: int
|
||||
key_name: str | None
|
||||
|
||||
@@ -195,7 +195,12 @@ class ApiKeyStore:
|
||||
return api_key
|
||||
|
||||
async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None:
|
||||
"""Validate an API key and return the associated user_id and org_id if valid."""
|
||||
"""Validate an API key and return the associated user_id and org_id if valid.
|
||||
|
||||
Returns:
|
||||
ApiKeyValidationResult if the key is valid, None otherwise.
|
||||
The org_id may be None for legacy API keys that weren't bound to an organization.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
|
||||
@@ -589,20 +589,26 @@ class LiteLlmManager:
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
|
||||
json_data: dict[str, Any] = {
|
||||
'team_id': team_id,
|
||||
'team_alias': team_alias,
|
||||
'models': [],
|
||||
'spend': 0,
|
||||
'metadata': {
|
||||
'version': ORG_SETTINGS_VERSION,
|
||||
'model': get_default_litellm_model(),
|
||||
},
|
||||
}
|
||||
|
||||
if max_budget is not None:
|
||||
json_data['max_budget'] = max_budget
|
||||
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/team/new',
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'team_alias': team_alias,
|
||||
'models': [],
|
||||
'max_budget': max_budget, # None disables budget enforcement
|
||||
'spend': 0,
|
||||
'metadata': {
|
||||
'version': ORG_SETTINGS_VERSION,
|
||||
'model': get_default_litellm_model(),
|
||||
},
|
||||
},
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
# Team failed to create in litellm - this is an unforseen error state...
|
||||
if not response.is_success:
|
||||
if (
|
||||
@@ -1040,14 +1046,20 @@ class LiteLlmManager:
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
|
||||
json_data: dict[str, Any] = {
|
||||
'team_id': team_id,
|
||||
'member': {'user_id': keycloak_user_id, 'role': 'user'},
|
||||
}
|
||||
|
||||
if max_budget is not None:
|
||||
json_data['max_budget_in_team'] = max_budget
|
||||
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/team/member_add',
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'member': {'user_id': keycloak_user_id, 'role': 'user'},
|
||||
'max_budget_in_team': max_budget, # None disables budget enforcement
|
||||
},
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
# Failed to add user to team - this is an unforseen error state...
|
||||
if not response.is_success:
|
||||
if (
|
||||
@@ -1129,14 +1141,20 @@ class LiteLlmManager:
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
|
||||
json_data: dict[str, Any] = {
|
||||
'team_id': team_id,
|
||||
'user_id': keycloak_user_id,
|
||||
}
|
||||
|
||||
if max_budget is not None:
|
||||
json_data['max_budget_in_team'] = max_budget
|
||||
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/team/member_update',
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'user_id': keycloak_user_id,
|
||||
'max_budget_in_team': max_budget, # None disables budget enforcement
|
||||
},
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
# Failed to update user in team - this is an unforseen error state...
|
||||
if not response.is_success:
|
||||
logger.error(
|
||||
|
||||
@@ -15,13 +15,13 @@ class SaasConversationValidator(ConversationValidator):
|
||||
|
||||
async def _validate_api_key(self, api_key: str) -> str | None:
|
||||
"""
|
||||
Validate an API key and return the user_id and github_user_id if valid.
|
||||
Validate an API key and return the user_id if valid.
|
||||
|
||||
Args:
|
||||
api_key: The API key to validate
|
||||
|
||||
Returns:
|
||||
A tuple of (user_id, github_user_id) if the API key is valid, None otherwise
|
||||
The user_id if the API key is valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
token_manager = TokenManager()
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.api_key_store import ApiKeyStore, ApiKeyValidationResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -110,8 +110,8 @@ async def test_create_api_key(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_valid(api_key_store, async_session_maker):
|
||||
"""Test validating a valid API key."""
|
||||
# Setup - create an API key in the database
|
||||
"""Test validating a valid API key returns user_id and org_id."""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-api-key'
|
||||
@@ -128,11 +128,12 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker):
|
||||
await session.commit()
|
||||
key_id = key_record.id
|
||||
|
||||
# Execute - patch a_session_maker to use test's async session maker
|
||||
# Act
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Verify - result is now ApiKeyValidationResult
|
||||
# Assert
|
||||
assert isinstance(result, ApiKeyValidationResult)
|
||||
assert result is not None
|
||||
assert result.user_id == user_id
|
||||
assert result.org_id == org_id
|
||||
@@ -202,7 +203,7 @@ async def test_validate_api_key_valid_timezone_naive(
|
||||
api_key_store, async_session_maker
|
||||
):
|
||||
"""Test validating a valid API key with timezone-naive datetime from database."""
|
||||
# Setup - create a valid API key with timezone-naive datetime (future date)
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-valid-naive-key'
|
||||
@@ -219,13 +220,44 @@ async def test_validate_api_key_valid_timezone_naive(
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
|
||||
# Execute - patch a_session_maker to use test's async session maker
|
||||
# Act
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Verify - result is now ApiKeyValidationResult
|
||||
# Assert
|
||||
assert isinstance(result, ApiKeyValidationResult)
|
||||
assert result.user_id == user_id
|
||||
assert result.org_id == org_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_legacy_without_org_id(
|
||||
api_key_store, async_session_maker
|
||||
):
|
||||
"""Test validating a legacy API key without org_id returns None for org_id."""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
api_key_value = 'test-legacy-key-no-org'
|
||||
|
||||
async with async_session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
key=api_key_value,
|
||||
user_id=user_id,
|
||||
org_id=None, # Legacy key without org binding
|
||||
name='Legacy Key',
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
|
||||
# Act
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, ApiKeyValidationResult)
|
||||
assert result is not None
|
||||
assert result.user_id == user_id
|
||||
assert result.org_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -13,6 +13,7 @@ from server.auth.authorization import (
|
||||
ROLE_PERMISSIONS,
|
||||
Permission,
|
||||
RoleName,
|
||||
get_api_key_org_id_from_request,
|
||||
get_role_permissions,
|
||||
get_user_org_role,
|
||||
has_permission,
|
||||
@@ -444,6 +445,15 @@ class TestGetUserOrgRole:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _create_mock_request(api_key_org_id=None):
|
||||
"""Helper to create a mock request with optional api_key_org_id."""
|
||||
mock_request = MagicMock()
|
||||
mock_user_auth = MagicMock()
|
||||
mock_user_auth.get_api_key_org_id.return_value = api_key_org_id
|
||||
mock_request.state.user_auth = mock_user_auth
|
||||
return mock_request
|
||||
|
||||
|
||||
class TestRequirePermission:
|
||||
"""Tests for require_permission dependency factory."""
|
||||
|
||||
@@ -456,6 +466,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -465,7 +476,9 @@ class TestRequirePermission:
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -476,10 +489,11 @@ class TestRequirePermission:
|
||||
THEN: 401 Unauthorized is raised
|
||||
"""
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=None)
|
||||
await permission_checker(request=mock_request, org_id=org_id, user_id=None)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'not authenticated' in exc_info.value.detail.lower()
|
||||
@@ -493,6 +507,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
@@ -500,7 +515,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'not a member' in exc_info.value.detail.lower()
|
||||
@@ -514,6 +531,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
@@ -524,7 +542,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'delete_organization' in exc_info.value.detail.lower()
|
||||
@@ -538,6 +558,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'owner'
|
||||
@@ -547,7 +568,9 @@ class TestRequirePermission:
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -559,6 +582,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -569,7 +593,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@@ -582,6 +608,7 @@ class TestRequirePermission:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
@@ -595,7 +622,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
|
||||
with pytest.raises(HTTPException):
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called()
|
||||
call_args = mock_logger.warning.call_args
|
||||
@@ -611,6 +640,7 @@ class TestRequirePermission:
|
||||
THEN: User ID is returned
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -620,7 +650,9 @@ class TestRequirePermission:
|
||||
AsyncMock(return_value=mock_role),
|
||||
) as mock_get_role:
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(org_id=None, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=None, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
mock_get_role.assert_called_once_with(user_id, None)
|
||||
|
||||
@@ -632,6 +664,7 @@ class TestRequirePermission:
|
||||
THEN: HTTPException with 403 status is raised
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
@@ -639,7 +672,9 @@ class TestRequirePermission:
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=None, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=None, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'not a member' in exc_info.value.detail
|
||||
@@ -662,6 +697,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
@@ -671,7 +707,9 @@ class TestPermissionScenarios:
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.MANAGE_SECRETS)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -683,6 +721,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
@@ -695,7 +734,9 @@ class TestPermissionScenarios:
|
||||
Permission.INVITE_USER_TO_ORGANIZATION
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@@ -708,6 +749,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -719,7 +761,9 @@ class TestPermissionScenarios:
|
||||
permission_checker = require_permission(
|
||||
Permission.INVITE_USER_TO_ORGANIZATION
|
||||
)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -731,6 +775,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
@@ -741,7 +786,9 @@ class TestPermissionScenarios:
|
||||
):
|
||||
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(org_id=org_id, user_id=user_id)
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@@ -754,6 +801,7 @@ class TestPermissionScenarios:
|
||||
"""
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request()
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'owner'
|
||||
@@ -763,5 +811,200 @@ class TestPermissionScenarios:
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
|
||||
result = await permission_checker(org_id=org_id, user_id=user_id)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for API key organization validation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestApiKeyOrgValidation:
|
||||
"""Tests for API key organization binding validation in require_permission."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_access_when_api_key_org_matches_target_org(self):
|
||||
"""
|
||||
GIVEN: API key with org_id that matches the target org_id in the request
|
||||
WHEN: Permission checker is called
|
||||
THEN: User ID is returned (access allowed)
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=org_id)
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
|
||||
# Act & Assert
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_when_api_key_org_mismatches_target_org(self):
|
||||
"""
|
||||
GIVEN: API key created for Org A, but user tries to access Org B
|
||||
WHEN: Permission checker is called
|
||||
THEN: 403 Forbidden is raised with org mismatch message
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
api_key_org_id = uuid4() # Org A - where API key was created
|
||||
target_org_id = uuid4() # Org B - where user is trying to access
|
||||
mock_request = _create_mock_request(api_key_org_id=api_key_org_id)
|
||||
|
||||
# Act & Assert
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=target_org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert (
|
||||
'API key is not authorized for this organization' in exc_info.value.detail
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_access_for_legacy_api_key_without_org_binding(self):
|
||||
"""
|
||||
GIVEN: Legacy API key without org_id binding (org_id is None)
|
||||
WHEN: Permission checker is called
|
||||
THEN: Falls through to normal permission check (backward compatible)
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=None)
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
|
||||
# Act & Assert
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_access_for_cookie_auth_without_api_key_org_id(self):
|
||||
"""
|
||||
GIVEN: Cookie-based authentication (no api_key_org_id in user_auth)
|
||||
WHEN: Permission checker is called
|
||||
THEN: Falls through to normal permission check
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=None)
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
|
||||
# Act & Assert
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
):
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
result = await permission_checker(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_warning_on_api_key_org_mismatch(self):
|
||||
"""
|
||||
GIVEN: API key org_id doesn't match target org_id
|
||||
WHEN: Permission checker is called
|
||||
THEN: Warning is logged with org mismatch details
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
api_key_org_id = uuid4()
|
||||
target_org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=api_key_org_id)
|
||||
|
||||
# Act & Assert
|
||||
with patch('server.auth.authorization.logger') as mock_logger:
|
||||
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
|
||||
with pytest.raises(HTTPException):
|
||||
await permission_checker(
|
||||
request=mock_request, org_id=target_org_id, user_id=user_id
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called()
|
||||
call_args = mock_logger.warning.call_args
|
||||
assert call_args[1]['extra']['user_id'] == user_id
|
||||
assert call_args[1]['extra']['api_key_org_id'] == str(api_key_org_id)
|
||||
assert call_args[1]['extra']['target_org_id'] == str(target_org_id)
|
||||
|
||||
|
||||
class TestGetApiKeyOrgIdFromRequest:
|
||||
"""Tests for get_api_key_org_id_from_request helper function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_org_id_when_user_auth_has_api_key_org_id(self):
|
||||
"""
|
||||
GIVEN: Request with user_auth that has api_key_org_id
|
||||
WHEN: get_api_key_org_id_from_request is called
|
||||
THEN: Returns the api_key_org_id
|
||||
"""
|
||||
# Arrange
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request(api_key_org_id=org_id)
|
||||
|
||||
# Act
|
||||
result = await get_api_key_org_id_from_request(mock_request)
|
||||
|
||||
# Assert
|
||||
assert result == org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_user_auth_has_no_api_key_org_id(self):
|
||||
"""
|
||||
GIVEN: Request with user_auth that has no api_key_org_id (cookie auth)
|
||||
WHEN: get_api_key_org_id_from_request is called
|
||||
THEN: Returns None
|
||||
"""
|
||||
# Arrange
|
||||
mock_request = _create_mock_request(api_key_org_id=None)
|
||||
|
||||
# Act
|
||||
result = await get_api_key_org_id_from_request(mock_request)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_no_user_auth_in_request(self):
|
||||
"""
|
||||
GIVEN: Request without user_auth in state
|
||||
WHEN: get_api_key_org_id_from_request is called
|
||||
THEN: Returns None
|
||||
"""
|
||||
# Arrange
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.user_auth = None
|
||||
|
||||
# Act
|
||||
result = await get_api_key_org_id_from_request(mock_request)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@@ -2384,3 +2384,195 @@ class TestVerifyExistingKey:
|
||||
openhands_type=True,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestBudgetPayloadHandling:
|
||||
"""Test cases for budget field handling in API payloads.
|
||||
|
||||
These tests verify that when max_budget is None, the budget field is NOT
|
||||
included in the JSON payload (which tells LiteLLM to disable budget
|
||||
enforcement), and when max_budget has a value, it IS included.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_team_excludes_max_budget_when_none(self):
|
||||
"""Test that _create_team does NOT include max_budget when it is None."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._create_team(
|
||||
mock_client,
|
||||
team_alias='test-team',
|
||||
team_id='test-team-id',
|
||||
max_budget=None, # None = no budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify URL
|
||||
assert call_args[0][0] == 'http://test.com/team/new'
|
||||
|
||||
# Verify that max_budget is NOT in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert 'max_budget' not in json_payload, (
|
||||
'max_budget should NOT be in payload when None '
|
||||
'(omitting it tells LiteLLM to disable budget enforcement)'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_team_includes_max_budget_when_set(self):
|
||||
"""Test that _create_team includes max_budget when it has a value."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._create_team(
|
||||
mock_client,
|
||||
team_alias='test-team',
|
||||
team_id='test-team-id',
|
||||
max_budget=100.0, # Explicit budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify that max_budget IS in the JSON payload with the correct value
|
||||
json_payload = call_args[1]['json']
|
||||
assert (
|
||||
'max_budget' in json_payload
|
||||
), 'max_budget should be in payload when set to a value'
|
||||
assert json_payload['max_budget'] == 100.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_user_to_team_excludes_max_budget_when_none(self):
|
||||
"""Test that _add_user_to_team does NOT include max_budget_in_team when None."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._add_user_to_team(
|
||||
mock_client,
|
||||
keycloak_user_id='test-user-id',
|
||||
team_id='test-team-id',
|
||||
max_budget=None, # None = no budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify URL
|
||||
assert call_args[0][0] == 'http://test.com/team/member_add'
|
||||
|
||||
# Verify that max_budget_in_team is NOT in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert 'max_budget_in_team' not in json_payload, (
|
||||
'max_budget_in_team should NOT be in payload when None '
|
||||
'(omitting it tells LiteLLM to disable budget enforcement)'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_user_to_team_includes_max_budget_when_set(self):
|
||||
"""Test that _add_user_to_team includes max_budget_in_team when set."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._add_user_to_team(
|
||||
mock_client,
|
||||
keycloak_user_id='test-user-id',
|
||||
team_id='test-team-id',
|
||||
max_budget=50.0, # Explicit budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify that max_budget_in_team IS in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert (
|
||||
'max_budget_in_team' in json_payload
|
||||
), 'max_budget_in_team should be in payload when set to a value'
|
||||
assert json_payload['max_budget_in_team'] == 50.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_in_team_excludes_max_budget_when_none(self):
|
||||
"""Test that _update_user_in_team does NOT include max_budget_in_team when None."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._update_user_in_team(
|
||||
mock_client,
|
||||
keycloak_user_id='test-user-id',
|
||||
team_id='test-team-id',
|
||||
max_budget=None, # None = no budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify URL
|
||||
assert call_args[0][0] == 'http://test.com/team/member_update'
|
||||
|
||||
# Verify that max_budget_in_team is NOT in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert 'max_budget_in_team' not in json_payload, (
|
||||
'max_budget_in_team should NOT be in payload when None '
|
||||
'(omitting it tells LiteLLM to disable budget enforcement)'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_in_team_includes_max_budget_when_set(self):
|
||||
"""Test that _update_user_in_team includes max_budget_in_team when set."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
await LiteLlmManager._update_user_in_team(
|
||||
mock_client,
|
||||
keycloak_user_id='test-user-id',
|
||||
team_id='test-team-id',
|
||||
max_budget=75.0, # Explicit budget limit
|
||||
)
|
||||
|
||||
# Verify the call was made
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
|
||||
# Verify that max_budget_in_team IS in the JSON payload
|
||||
json_payload = call_args[1]['json']
|
||||
assert (
|
||||
'max_budget_in_team' in json_payload
|
||||
), 'max_budget_in_team should be in payload when set to a value'
|
||||
assert json_payload['max_budget_in_team'] == 75.0
|
||||
|
||||
@@ -459,7 +459,8 @@ async def test_get_instance_no_auth(mock_request):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saas_user_auth_from_bearer_success():
|
||||
"""Test successful authentication from bearer token."""
|
||||
"""Test successful authentication from bearer token sets user_id and api_key_org_id."""
|
||||
# Arrange
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } 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 { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
describe("AnalyticsConsentFormModal", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
it("should call saveUserSettings with consent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
useParams: () => ({ conversationId: "123" }),
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
let queryClient: QueryClient;
|
||||
@@ -47,6 +50,7 @@ const renderMessages = ({
|
||||
describe("Messages", () => {
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
const assistantMessage: AssistantMessageAction = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import OptionService from "#/api/option-service/option-service.api";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RepoConnector } from "#/components/features/home/repo-connector";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
const renderRepoConnector = () => {
|
||||
const mockRepoSelection = vi.fn();
|
||||
@@ -65,6 +66,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AddCreditsModal", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
const renderModal = () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AddCreditsModal onClose={onCloseMock} />);
|
||||
return { user };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the form with correct elements", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByTestId("add-credits-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("amount-input")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /ORG\$NEXT/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the title", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText("ORG$ADD_CREDITS")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button State Management", () => {
|
||||
it("should enable submit button initially when modal opens", () => {
|
||||
renderModal();
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains invalid value", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains valid value", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "100");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button after validation error is shown", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Attributes & Placeholder", () => {
|
||||
it("should have min attribute set to 10", () => {
|
||||
renderModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("min", "10");
|
||||
});
|
||||
|
||||
it("should have max attribute set to 25000", () => {
|
||||
renderModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("max", "25000");
|
||||
});
|
||||
|
||||
it("should have step attribute set to 1", () => {
|
||||
renderModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("step", "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Message Display", () => {
|
||||
it("should not display error message initially when modal opens", () => {
|
||||
renderModal();
|
||||
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error message after submitting amount above maximum", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting decimal value", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "50.5");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting amount below minimum", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting negative amount", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should replace error message when submitting different invalid value", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||
});
|
||||
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Submission Behavior", () => {
|
||||
it("should prevent submission when amount is invalid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should call createCheckoutSession with correct amount when valid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call createCheckoutSession when validation fails", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should close modal on successful submission", async () => {
|
||||
vi.spyOn(BillingService, "createCheckoutSession").mockResolvedValue(
|
||||
"https://checkout.stripe.com/test-session",
|
||||
);
|
||||
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow API call when validation passes and clear any previous errors", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
// First submit invalid value
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Then submit valid value
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "100");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle zero value correctly", async () => {
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
await user.type(amountInput, "0");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle whitespace-only input correctly", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = renderModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||
|
||||
// Number inputs typically don't accept spaces, but test the behavior
|
||||
await user.type(amountInput, " ");
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should not call API (empty/invalid input)
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Interaction", () => {
|
||||
it("should call onClose when cancel button is clicked", async () => {
|
||||
const { user } = renderModal();
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: /close/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Mock the react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -37,6 +38,10 @@ vi.mock("#/hooks/query/use-api-keys", () => ({
|
||||
}));
|
||||
|
||||
describe("ApiKeysManager", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
const renderComponent = () => {
|
||||
const queryClient = new QueryClient();
|
||||
return render(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
renderWithProviders,
|
||||
createAxiosNotFoundErrorObject,
|
||||
@@ -10,6 +10,7 @@ import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Helper to create mock config with sensible defaults
|
||||
const createMockConfig = (
|
||||
@@ -76,6 +77,10 @@ describe("Sidebar", () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -156,11 +156,19 @@ describe("UserContextMenu", () => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should render the default context items for a user", () => {
|
||||
it("should render the default context items for a user", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
|
||||
// Wait for config to load so logout button appears
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText("ORG$INVITE_ORG_MEMBERS"),
|
||||
@@ -304,6 +312,20 @@ describe("UserContextMenu", () => {
|
||||
screen.queryByText("Organization Members"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display logout button in OSS mode", async () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for the config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify logout button is NOT rendered in OSS mode
|
||||
expect(
|
||||
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HIDE_LLM_SETTINGS feature flag", () => {
|
||||
@@ -382,10 +404,15 @@ describe("UserContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should call the logout handler when Logout is clicked", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
|
||||
const logoutSpy = vi.spyOn(AuthService, "logout");
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
// Wait for config to load so logout button appears
|
||||
const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalledOnce();
|
||||
@@ -488,6 +515,10 @@ describe("UserContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should call the onClose handler after each action", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
|
||||
// Mock a team org so org management buttons are visible
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
@@ -497,7 +528,8 @@ describe("UserContextMenu", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
||||
|
||||
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
// Wait for config to load so logout button appears
|
||||
const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await userEvent.click(logoutButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
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 { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock React Router hooks
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Mock the useActiveConversation hook
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
@@ -52,6 +51,10 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
describe("InteractiveChatBox", () => {
|
||||
const onSubmitMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
const mockStores = (agentState: AgentState = AgentState.INIT) => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
@@ -213,6 +216,36 @@ describe("InteractiveChatBox", () => {
|
||||
expect(onSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should lock the text input field when disabled prop is true (isNewConversationPending)", () => {
|
||||
mockStores(AgentState.INIT);
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const chatInput = screen.getByTestId("chat-input");
|
||||
// When disabled=true, the text field should not be editable
|
||||
expect(chatInput).toHaveAttribute("contenteditable", "false");
|
||||
// Should show visual disabled state
|
||||
expect(chatInput.className).toContain("cursor-not-allowed");
|
||||
expect(chatInput.className).toContain("opacity-50");
|
||||
});
|
||||
|
||||
it("should keep the text input field editable when disabled prop is false", () => {
|
||||
mockStores(AgentState.INIT);
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const chatInput = screen.getByTestId("chat-input");
|
||||
expect(chatInput).toHaveAttribute("contenteditable", "true");
|
||||
expect(chatInput.className).not.toContain("cursor-not-allowed");
|
||||
expect(chatInput.className).not.toContain("opacity-50");
|
||||
});
|
||||
|
||||
it("should handle image upload and message submission correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { MemoryRouter, createRoutesStub } from "react-router";
|
||||
import { ReactElement } from "react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { server } from "#/mocks/node";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
@@ -59,6 +62,20 @@ const renderUserActions = (props = { hasAvatar: true }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// RouterStub and render helper for menu close delay tests
|
||||
const RouterStubForMenuCloseDelay = createRoutesStub([
|
||||
{
|
||||
path: "/",
|
||||
Component: () => (
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const renderUserActionsForMenuCloseDelay = () => {
|
||||
return renderWithProviders(<RouterStubForMenuCloseDelay initialEntries={["/"]} />);
|
||||
};
|
||||
|
||||
// Create mocks for all the hooks we need
|
||||
const useIsAuthedMock = vi
|
||||
.fn()
|
||||
@@ -347,7 +364,7 @@ describe("UserActions", () => {
|
||||
expect(contextMenu).toBeVisible();
|
||||
});
|
||||
|
||||
it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => {
|
||||
it("should use state-based visibility for hover behavior instead of CSS pseudo-element", async () => {
|
||||
renderUserActions();
|
||||
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
@@ -356,19 +373,17 @@ describe("UserActions", () => {
|
||||
const contextMenu = screen.getByTestId("user-context-menu");
|
||||
const hoverBridgeContainer = contextMenu.parentElement;
|
||||
|
||||
// The hover bridge uses a ::before pseudo-element for diagonal mouse movement
|
||||
// This pseudo-element MUST have pointer-events-none to allow clicks through to menu items
|
||||
// The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks
|
||||
expect(hoverBridgeContainer?.className).toContain(
|
||||
"before:pointer-events-none",
|
||||
);
|
||||
// The component uses state-based visibility with a 500ms delay for diagonal mouse movement
|
||||
// When visible, the container should have opacity-100 and pointer-events-auto
|
||||
expect(hoverBridgeContainer?.className).toContain("opacity-100");
|
||||
expect(hoverBridgeContainer?.className).toContain("pointer-events-auto");
|
||||
});
|
||||
|
||||
describe("Org selector dropdown state reset when context menu hides", () => {
|
||||
// These tests verify that the org selector dropdown resets its internal
|
||||
// state (search text, open/closed) when the context menu hides and
|
||||
// reappears. Without this, stale state persists because the context
|
||||
// menu is hidden via CSS (opacity/pointer-events) rather than unmounted.
|
||||
// reappears. The component uses a 500ms delay before hiding (to support
|
||||
// diagonal mouse movement).
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
@@ -400,8 +415,22 @@ describe("UserActions", () => {
|
||||
await user.type(input, "search text");
|
||||
expect(input).toHaveValue("search text");
|
||||
|
||||
// Unhover to hide context menu, then hover again
|
||||
// Unhover to trigger hide timeout, then wait for the 500ms delay to complete
|
||||
await user.unhover(userActions);
|
||||
|
||||
// Wait for the 500ms hide delay to complete and menu to actually hide
|
||||
await waitFor(
|
||||
() => {
|
||||
// The menu resets when it actually hides (after 500ms delay)
|
||||
// After hiding, hovering again should show a fresh menu
|
||||
},
|
||||
{ timeout: 600 },
|
||||
);
|
||||
|
||||
// Wait a bit more for the timeout to fire
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
|
||||
// Now hover again to show the menu
|
||||
await user.hover(userActions);
|
||||
|
||||
// Org selector should be reset — showing selected org name, not search text
|
||||
@@ -434,8 +463,13 @@ describe("UserActions", () => {
|
||||
await user.type(input, "Acme");
|
||||
expect(input).toHaveValue("Acme");
|
||||
|
||||
// Unhover to hide context menu, then hover again
|
||||
// Unhover to trigger hide timeout
|
||||
await user.unhover(userActions);
|
||||
|
||||
// Wait for the 500ms hide delay to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
|
||||
// Now hover again to show the menu
|
||||
await user.hover(userActions);
|
||||
|
||||
// Wait for fresh component with org data
|
||||
@@ -454,4 +488,83 @@ describe("UserActions", () => {
|
||||
expect(screen.queryAllByRole("option")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("menu close delay", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
// Mock config to return SaaS mode so useShouldShowUserFeatures returns true
|
||||
server.use(
|
||||
http.get("/api/v1/web-client/config", () =>
|
||||
HttpResponse.json(createMockWebClientConfig({ app_mode: "saas" })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it("should keep menu visible when mouse leaves and re-enters within 500ms", async () => {
|
||||
// Arrange - render and wait for queries to settle
|
||||
renderUserActionsForMenuCloseDelay();
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
|
||||
// Act - open menu
|
||||
await act(async () => {
|
||||
fireEvent.mouseEnter(userActions);
|
||||
});
|
||||
|
||||
// Assert - menu is visible
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
|
||||
// Act - leave and re-enter within 500ms
|
||||
await act(async () => {
|
||||
fireEvent.mouseLeave(userActions);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
fireEvent.mouseEnter(userActions);
|
||||
});
|
||||
|
||||
// Assert - menu should still be visible after waiting (pending close was cancelled)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not close menu before 500ms delay when mouse leaves", async () => {
|
||||
// Arrange - render and wait for queries to settle
|
||||
renderUserActionsForMenuCloseDelay();
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
|
||||
// Act - open menu
|
||||
await act(async () => {
|
||||
fireEvent.mouseEnter(userActions);
|
||||
});
|
||||
|
||||
// Assert - menu is visible
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
|
||||
// Act - leave without re-entering, but check before timeout expires
|
||||
await act(async () => {
|
||||
fireEvent.mouseLeave(userActions);
|
||||
await vi.advanceTimersByTimeAsync(400); // Before the 500ms delay
|
||||
});
|
||||
|
||||
// Assert - menu should still be visible (delay hasn't expired yet)
|
||||
// Note: The menu is always in DOM but with opacity-0 when closed.
|
||||
// This test verifies the state hasn't changed yet (delay is working).
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
WsClientProvider,
|
||||
useWsClient,
|
||||
} from "#/context/ws-client-provider";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
describe("Propagate error message", () => {
|
||||
it("should do nothing when no message was passed from server", () => {
|
||||
@@ -56,6 +57,7 @@ function TestComponent() {
|
||||
describe("WsClientProvider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => {
|
||||
return { data: {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV1Event } from "#/types/v1/type-guards";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Mock useUserConversation to return V1 conversation data
|
||||
vi.mock("#/hooks/query/use-user-conversation", () => ({
|
||||
@@ -62,6 +63,10 @@ beforeAll(() => {
|
||||
mswServer.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mswServer.resetHandlers();
|
||||
// Clean up any React components
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
useParams: () => ({ conversationId: "conv-123" }),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mockToast } = vi.hoisted(() => {
|
||||
const mockToast = Object.assign(vi.fn(), {
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
});
|
||||
return { mockToast };
|
||||
});
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: mockToast,
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displayErrorToast: vi.fn(),
|
||||
TOAST_OPTIONS: { position: "top-right" },
|
||||
}));
|
||||
|
||||
const mockConversation = {
|
||||
conversation_id: "conv-123",
|
||||
sandbox_id: "sandbox-456",
|
||||
title: "Test Conversation",
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
conversation_version: "V1" as const,
|
||||
};
|
||||
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
data: mockConversation,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeStartTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "task-789",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "new-conv-999",
|
||||
sandbox_id: "sandbox-456",
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useNewConversationCommand", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
// Mock batchGetAppConversations to return V1 data with llm_model
|
||||
vi.spyOn(
|
||||
V1ConversationService,
|
||||
"batchGetAppConversations",
|
||||
).mockResolvedValue([
|
||||
{
|
||||
id: "conv-123",
|
||||
title: "Test Conversation",
|
||||
sandbox_id: "sandbox-456",
|
||||
sandbox_status: "RUNNING",
|
||||
execution_status: "IDLE",
|
||||
conversation_url: null,
|
||||
session_api_key: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
llm_model: "gpt-4o",
|
||||
metrics: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
sub_conversation_ids: [],
|
||||
public: false,
|
||||
} as never,
|
||||
]);
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
it("calls createConversation with sandbox_id and navigates on success", async () => {
|
||||
const readyTask = makeStartTask();
|
||||
const createSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue(readyTask as never);
|
||||
const getStartTaskSpy = vi
|
||||
.spyOn(V1ConversationService, "getStartTask")
|
||||
.mockResolvedValue(readyTask as never);
|
||||
|
||||
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"sandbox-456",
|
||||
"gpt-4o",
|
||||
);
|
||||
expect(getStartTaskSpy).toHaveBeenCalledWith("task-789");
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
"/conversations/new-conv-999",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("polls getStartTask until status is READY", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
const workingTask = makeStartTask({
|
||||
status: "WORKING",
|
||||
app_conversation_id: null,
|
||||
});
|
||||
const readyTask = makeStartTask({ status: "READY" });
|
||||
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
|
||||
workingTask as never,
|
||||
);
|
||||
const getStartTaskSpy = vi
|
||||
.spyOn(V1ConversationService, "getStartTask")
|
||||
.mockResolvedValueOnce(workingTask as never)
|
||||
.mockResolvedValueOnce(readyTask as never);
|
||||
|
||||
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
|
||||
|
||||
const mutatePromise = result.current.mutateAsync();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await mutatePromise;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getStartTaskSpy).toHaveBeenCalledTimes(2);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
"/conversations/new-conv-999",
|
||||
);
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("throws when task status is ERROR", async () => {
|
||||
const errorTask = makeStartTask({
|
||||
status: "ERROR",
|
||||
detail: "Sandbox crashed",
|
||||
app_conversation_id: null,
|
||||
});
|
||||
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
|
||||
errorTask as never,
|
||||
);
|
||||
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
|
||||
errorTask as never,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
|
||||
|
||||
await expect(result.current.mutateAsync()).rejects.toThrow(
|
||||
"Sandbox crashed",
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates conversation list queries on success", async () => {
|
||||
const readyTask = makeStartTask();
|
||||
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
|
||||
readyTask as never,
|
||||
);
|
||||
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
|
||||
readyTask as never,
|
||||
);
|
||||
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["v1-batch-get-app-conversations"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a standalone conversation (not a sub-conversation) so it appears in the list", async () => {
|
||||
const readyTask = makeStartTask();
|
||||
const createSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue(readyTask as never);
|
||||
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
|
||||
readyTask as never,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync();
|
||||
|
||||
await waitFor(() => {
|
||||
// parent_conversation_id should be undefined so the new conversation
|
||||
// is NOT a sub-conversation and will appear in the conversation list.
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
undefined, // selectedRepository (null from mock)
|
||||
undefined, // git_provider (null from mock)
|
||||
undefined, // initialUserMsg
|
||||
undefined, // selected_branch (null from mock)
|
||||
undefined, // conversationInstructions
|
||||
undefined, // suggestedTask
|
||||
undefined, // trigger
|
||||
undefined, // parent_conversation_id is NOT set
|
||||
undefined, // agent_type
|
||||
"sandbox-456", // sandbox_id IS set to reuse the sandbox
|
||||
"gpt-4o", // llm_model IS inherited from the original conversation
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a loading toast immediately and dismisses it on success", async () => {
|
||||
const readyTask = makeStartTask();
|
||||
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
|
||||
readyTask as never,
|
||||
);
|
||||
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
|
||||
readyTask as never,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.loading).toHaveBeenCalledWith(
|
||||
"CONVERSATION$CLEARING",
|
||||
expect.objectContaining({ id: "clear-conversation" }),
|
||||
);
|
||||
expect(mockToast.dismiss).toHaveBeenCalledWith("clear-conversation");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
describe("useSaveSettings", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const { result } = renderHook(() => useSaveSettings(), {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
|
||||
import { useApiKeys } from "#/hooks/query/use-api-keys";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import ApiKeysClient from "#/api/api-keys";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({
|
||||
data: { app_mode: "saas" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({
|
||||
data: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
|
||||
useIsOnIntermediatePage: () => false,
|
||||
}));
|
||||
|
||||
describe("Organization-scoped query hooks", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useSettings", () => {
|
||||
it("should include organizationId in query key for proper cache isolation", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const { result } = renderHook(() => useSettings(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetched).toBe(true));
|
||||
|
||||
// Verify the query was cached with the org-specific key
|
||||
const cachedData = queryClient.getQueryData(["settings", "org-1"]);
|
||||
expect(cachedData).toBeDefined();
|
||||
|
||||
// Verify no data is cached under the old key without org ID
|
||||
const oldKeyData = queryClient.getQueryData(["settings"]);
|
||||
expect(oldKeyData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should refetch when organization changes", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "en",
|
||||
});
|
||||
|
||||
// First render with org-1
|
||||
const { result, rerender } = renderHook(() => useSettings(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetched).toBe(true));
|
||||
expect(getSettingsSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change organization
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "es",
|
||||
});
|
||||
|
||||
// Rerender to pick up the new org ID
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
// Should have fetched again for the new org
|
||||
expect(getSettingsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Verify both org caches exist independently
|
||||
const org1Data = queryClient.getQueryData(["settings", "org-1"]);
|
||||
const org2Data = queryClient.getQueryData(["settings", "org-2"]);
|
||||
expect(org1Data).toBeDefined();
|
||||
expect(org2Data).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGetSecrets", () => {
|
||||
it("should include organizationId in query key for proper cache isolation", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useGetSecrets(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetched).toBe(true));
|
||||
|
||||
// Verify the query was cached with the org-specific key
|
||||
const cachedData = queryClient.getQueryData(["secrets", "org-1"]);
|
||||
expect(cachedData).toBeDefined();
|
||||
|
||||
// Verify no data is cached under the old key without org ID
|
||||
const oldKeyData = queryClient.getQueryData(["secrets"]);
|
||||
expect(oldKeyData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should fetch different data when organization changes", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
|
||||
// Mock different secrets for different orgs
|
||||
getSecretsSpy.mockResolvedValueOnce([
|
||||
{ name: "SECRET_ORG_1", description: "Org 1 secret" },
|
||||
]);
|
||||
|
||||
const { result, rerender } = renderHook(() => useGetSecrets(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetched).toBe(true));
|
||||
expect(result.current.data).toHaveLength(1);
|
||||
expect(result.current.data?.[0].name).toBe("SECRET_ORG_1");
|
||||
|
||||
// Change organization
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
|
||||
getSecretsSpy.mockResolvedValueOnce([
|
||||
{ name: "SECRET_ORG_2", description: "Org 2 secret" },
|
||||
]);
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.[0]?.name).toBe("SECRET_ORG_2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useApiKeys", () => {
|
||||
it("should include organizationId in query key for proper cache isolation", async () => {
|
||||
const getApiKeysSpy = vi.spyOn(ApiKeysClient, "getApiKeys");
|
||||
getApiKeysSpy.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useApiKeys(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetched).toBe(true));
|
||||
|
||||
// Verify the query was cached with the org-specific key
|
||||
const cachedData = queryClient.getQueryData(["api-keys", "org-1"]);
|
||||
expect(cachedData).toBeDefined();
|
||||
|
||||
// Verify no data is cached under the old key without org ID
|
||||
const oldKeyData = queryClient.getQueryData(["api-keys"]);
|
||||
expect(oldKeyData).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache isolation between organizations", () => {
|
||||
it("should maintain separate caches for each organization", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
// Simulate fetching for org-1
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "en",
|
||||
});
|
||||
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
const { rerender } = renderHook(() => useSettings(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryClient.getQueryData(["settings", "org-1"])).toBeDefined();
|
||||
});
|
||||
|
||||
// Switch to org-2
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "fr",
|
||||
});
|
||||
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryClient.getQueryData(["settings", "org-2"])).toBeDefined();
|
||||
});
|
||||
|
||||
// Switch back to org-1 - should use cached data, not refetch
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
rerender();
|
||||
|
||||
// org-1 data should still be in cache
|
||||
const org1Cache = queryClient.getQueryData(["settings", "org-1"]) as any;
|
||||
expect(org1Cache?.language).toBe("en");
|
||||
|
||||
// org-2 data should also still be in cache
|
||||
const org2Cache = queryClient.getQueryData(["settings", "org-2"]) as any;
|
||||
expect(org2Cache?.language).toBe("fr");
|
||||
});
|
||||
});
|
||||
});
|
||||
64
frontend/__tests__/hooks/use-runtime-is-ready.test.tsx
Normal file
64
frontend/__tests__/hooks/use-runtime-is-ready.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Conversation } from "#/api/open-hands.types";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
vi.mock("#/hooks/use-agent-state");
|
||||
vi.mock("#/hooks/query/use-active-conversation");
|
||||
|
||||
function asMockReturnValue<T>(value: Partial<T>): T {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function makeConversation(): Conversation {
|
||||
return {
|
||||
conversation_id: "conv-123",
|
||||
title: "Test Conversation",
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useRuntimeIsReady", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(useActiveConversation).mockReturnValue(
|
||||
asMockReturnValue<ReturnType<typeof useActiveConversation>>({
|
||||
data: makeConversation(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats agent errors as not ready by default", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.ERROR,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRuntimeIsReady());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("allows runtime-backed tabs to stay ready when the agent errors", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.ERROR,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRuntimeIsReady({ allowAgentError: true }),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,9 @@ describe("useWebSocket", () => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
expect(onCloseSpy).not.toHaveBeenCalled();
|
||||
// Reset spy after connection is established to ignore any spurious
|
||||
// close events fired by the MSW mock during the handshake.
|
||||
onCloseSpy.mockClear();
|
||||
|
||||
// Unmount to trigger close
|
||||
unmount();
|
||||
|
||||
@@ -5,9 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import AcceptTOS from "#/routes/accept-tos";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Mock the react-router hooks
|
||||
vi.mock("react-router", () => ({
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [
|
||||
{
|
||||
@@ -19,6 +21,7 @@ vi.mock("react-router", () => ({
|
||||
},
|
||||
},
|
||||
],
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Mock the axios instance
|
||||
@@ -54,6 +57,7 @@ const createWrapper = () => {
|
||||
|
||||
describe("AcceptTOS", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AppSettingsScreen, { clientLoader } from "#/routes/app-settings";
|
||||
@@ -8,6 +8,11 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
const renderAppSettingsScreen = () =>
|
||||
render(<AppSettingsScreen />, {
|
||||
|
||||
@@ -37,12 +37,20 @@ vi.mock("#/hooks/query/use-config", () => ({
|
||||
}));
|
||||
|
||||
function buildSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
const hasSchemaOverride = Object.prototype.hasOwnProperty.call(
|
||||
overrides,
|
||||
"sdk_settings_schema",
|
||||
);
|
||||
|
||||
return {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
...overrides,
|
||||
agent_settings: {
|
||||
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
|
||||
...overrides.agent_settings,
|
||||
sdk_settings_schema: hasSchemaOverride
|
||||
? overrides.sdk_settings_schema ?? null
|
||||
: MOCK_DEFAULT_USER_SETTINGS.sdk_settings_schema,
|
||||
sdk_settings_values: {
|
||||
...MOCK_DEFAULT_USER_SETTINGS.sdk_settings_values,
|
||||
...overrides.sdk_settings_values,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -121,7 +129,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("LlmSettingsScreen", () => {
|
||||
it("renders critical LLM fields from agent_settings_schema", async () => {
|
||||
it("renders critical LLM fields from sdk_settings_schema", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -177,7 +185,7 @@ describe("LlmSettingsScreen", () => {
|
||||
|
||||
it("shows a fallback message when sdk settings schema is unavailable", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({ agent_settings_schema: null }),
|
||||
buildSettings({ sdk_settings_schema: null }),
|
||||
);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
@@ -283,305 +283,6 @@ describe("Manage Org Route", () => {
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("AddCreditsModal", () => {
|
||||
const openAddCreditsModal = async () => {
|
||||
const user = userEvent.setup();
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
|
||||
|
||||
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
|
||||
await user.click(addCreditsButton);
|
||||
|
||||
const addCreditsForm = screen.getByTestId("add-credits-form");
|
||||
expect(addCreditsForm).toBeInTheDocument();
|
||||
|
||||
return { user, addCreditsForm };
|
||||
};
|
||||
|
||||
describe("Button State Management", () => {
|
||||
it("should enable submit button initially when modal opens", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains invalid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains valid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "100");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button after validation error is shown", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Attributes & Placeholder", () => {
|
||||
it("should have min attribute set to 10", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("min", "10");
|
||||
});
|
||||
|
||||
it("should have max attribute set to 25000", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("max", "25000");
|
||||
});
|
||||
|
||||
it("should have step attribute set to 1", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("step", "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Message Display", () => {
|
||||
it("should not display error message initially when modal opens", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error message after submitting amount above maximum", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting decimal value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "50.5");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should replace error message when submitting different invalid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Submission Behavior", () => {
|
||||
it("should prevent submission when amount is invalid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call createCheckoutSession with correct amount when valid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call createCheckoutSession when validation fails", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
await user.click(nextButton);
|
||||
|
||||
// Verify mutation was not called
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_NEGATIVE_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should close modal on successful submission", async () => {
|
||||
const createCheckoutSessionSpy = vi
|
||||
.spyOn(BillingService, "createCheckoutSession")
|
||||
.mockResolvedValue("https://checkout.stripe.com/test-session");
|
||||
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("add-credits-form"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow API call when validation passes and clear any previous errors", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
// First submit invalid value
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Then submit valid value
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "100");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle zero value correctly", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "0");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle whitespace-only input correctly", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
// Number inputs typically don't accept spaces, but test the behavior
|
||||
await user.type(amountInput, " ");
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should not call API (empty/invalid input)
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show add credits option for ADMIN role", async () => {
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
65
frontend/__tests__/routes/vscode-tab.test.tsx
Normal file
65
frontend/__tests__/routes/vscode-tab.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import VSCodeTab from "#/routes/vscode-tab";
|
||||
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
vi.mock("#/hooks/query/use-unified-vscode-url");
|
||||
vi.mock("#/hooks/use-agent-state");
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
VSCODE_IN_NEW_TAB: () => false,
|
||||
}));
|
||||
|
||||
function mockVSCodeUrlHook(
|
||||
value: Partial<ReturnType<typeof useUnifiedVSCodeUrl>>,
|
||||
) {
|
||||
vi.mocked(useUnifiedVSCodeUrl).mockReturnValue({
|
||||
data: { url: "http://localhost:3000/vscode", error: null },
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
status: "success",
|
||||
refetch: vi.fn(),
|
||||
...value,
|
||||
} as ReturnType<typeof useUnifiedVSCodeUrl>);
|
||||
}
|
||||
|
||||
describe("VSCodeTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps VSCode accessible when the agent is in an error state", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.ERROR,
|
||||
});
|
||||
mockVSCodeUrlHook({});
|
||||
|
||||
renderWithProviders(<VSCodeTab />);
|
||||
|
||||
expect(
|
||||
screen.queryByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("VSCODE$TITLE")).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:3000/vscode",
|
||||
);
|
||||
});
|
||||
|
||||
it("still waits while the runtime is starting", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.LOADING,
|
||||
});
|
||||
mockVSCodeUrlHook({});
|
||||
|
||||
renderWithProviders(<VSCodeTab />);
|
||||
|
||||
expect(
|
||||
screen.getByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("VSCODE$TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,13 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { test, expect, describe, vi, beforeEach } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -29,14 +34,12 @@ vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
}));
|
||||
|
||||
// Mock React Router hooks
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Mock other hooks that might be used by the component
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
|
||||
@@ -4,27 +4,96 @@ import { getGitPath } from "#/utils/get-git-path";
|
||||
describe("getGitPath", () => {
|
||||
const conversationId = "abc123";
|
||||
|
||||
it("should return /workspace/project/{conversationId} when no repository is selected", () => {
|
||||
expect(getGitPath(conversationId, null)).toBe(`/workspace/project/${conversationId}`);
|
||||
expect(getGitPath(conversationId, undefined)).toBe(`/workspace/project/${conversationId}`);
|
||||
describe("without sandbox grouping (NO_GROUPING)", () => {
|
||||
it("should return /workspace/project when no repository is selected", () => {
|
||||
expect(getGitPath(conversationId, null, false)).toBe("/workspace/project");
|
||||
expect(getGitPath(conversationId, undefined, false)).toBe(
|
||||
"/workspace/project",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle standard owner/repo format (GitHub)", () => {
|
||||
expect(getGitPath(conversationId, "OpenHands/OpenHands", false)).toBe(
|
||||
"/workspace/project/OpenHands",
|
||||
);
|
||||
expect(getGitPath(conversationId, "facebook/react", false)).toBe(
|
||||
"/workspace/project/react",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle nested group paths (GitLab)", () => {
|
||||
expect(
|
||||
getGitPath(conversationId, "modernhealth/frontend-guild/pan", false),
|
||||
).toBe("/workspace/project/pan");
|
||||
expect(getGitPath(conversationId, "group/subgroup/repo", false)).toBe(
|
||||
"/workspace/project/repo",
|
||||
);
|
||||
expect(getGitPath(conversationId, "a/b/c/d/repo", false)).toBe(
|
||||
"/workspace/project/repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle single segment paths", () => {
|
||||
expect(getGitPath(conversationId, "repo", false)).toBe(
|
||||
"/workspace/project/repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(getGitPath(conversationId, "", false)).toBe("/workspace/project");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle standard owner/repo format (GitHub)", () => {
|
||||
expect(getGitPath(conversationId, "OpenHands/OpenHands")).toBe(`/workspace/project/${conversationId}/OpenHands`);
|
||||
expect(getGitPath(conversationId, "facebook/react")).toBe(`/workspace/project/${conversationId}/react`);
|
||||
describe("with sandbox grouping enabled", () => {
|
||||
it("should return /workspace/project/{conversationId} when no repository is selected", () => {
|
||||
expect(getGitPath(conversationId, null, true)).toBe(
|
||||
`/workspace/project/${conversationId}`,
|
||||
);
|
||||
expect(getGitPath(conversationId, undefined, true)).toBe(
|
||||
`/workspace/project/${conversationId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle standard owner/repo format (GitHub)", () => {
|
||||
expect(getGitPath(conversationId, "OpenHands/OpenHands", true)).toBe(
|
||||
`/workspace/project/${conversationId}/OpenHands`,
|
||||
);
|
||||
expect(getGitPath(conversationId, "facebook/react", true)).toBe(
|
||||
`/workspace/project/${conversationId}/react`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle nested group paths (GitLab)", () => {
|
||||
expect(
|
||||
getGitPath(conversationId, "modernhealth/frontend-guild/pan", true),
|
||||
).toBe(`/workspace/project/${conversationId}/pan`);
|
||||
expect(getGitPath(conversationId, "group/subgroup/repo", true)).toBe(
|
||||
`/workspace/project/${conversationId}/repo`,
|
||||
);
|
||||
expect(getGitPath(conversationId, "a/b/c/d/repo", true)).toBe(
|
||||
`/workspace/project/${conversationId}/repo`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle single segment paths", () => {
|
||||
expect(getGitPath(conversationId, "repo", true)).toBe(
|
||||
`/workspace/project/${conversationId}/repo`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(getGitPath(conversationId, "", true)).toBe(
|
||||
`/workspace/project/${conversationId}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle nested group paths (GitLab)", () => {
|
||||
expect(getGitPath(conversationId, "modernhealth/frontend-guild/pan")).toBe(`/workspace/project/${conversationId}/pan`);
|
||||
expect(getGitPath(conversationId, "group/subgroup/repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
expect(getGitPath(conversationId, "a/b/c/d/repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
});
|
||||
|
||||
it("should handle single segment paths", () => {
|
||||
expect(getGitPath(conversationId, "repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(getGitPath(conversationId, "")).toBe(`/workspace/project/${conversationId}`);
|
||||
describe("default behavior (useSandboxGrouping defaults to false)", () => {
|
||||
it("should default to no sandbox grouping", () => {
|
||||
expect(getGitPath(conversationId, null)).toBe("/workspace/project");
|
||||
expect(getGitPath(conversationId, "owner/repo")).toBe(
|
||||
"/workspace/project/repo",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -15325,10 +15325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"license": "MIT",
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
|
||||
@@ -68,6 +68,8 @@ class V1ConversationService {
|
||||
trigger?: ConversationTrigger,
|
||||
parent_conversation_id?: string,
|
||||
agent_type?: "default" | "plan",
|
||||
sandbox_id?: string,
|
||||
llm_model?: string,
|
||||
): Promise<V1AppConversationStartTask> {
|
||||
const body: V1AppConversationStartRequest = {
|
||||
selected_repository: selectedRepository,
|
||||
@@ -78,6 +80,8 @@ class V1ConversationService {
|
||||
trigger,
|
||||
parent_conversation_id: parent_conversation_id || null,
|
||||
agent_type,
|
||||
sandbox_id: sandbox_id || null,
|
||||
llm_model: llm_model || null,
|
||||
};
|
||||
|
||||
// suggested_task implies the backend will construct the initial_message
|
||||
|
||||
@@ -38,6 +38,8 @@ import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import ChatStatusIndicator from "./chat-status-indicator";
|
||||
import { getStatusColor, getStatusText } from "#/utils/utils";
|
||||
import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -80,6 +82,10 @@ export function ChatInterface() {
|
||||
setHitBottom,
|
||||
} = useScrollToBottom(scrollRef);
|
||||
const { data: config } = useConfig();
|
||||
const {
|
||||
mutate: newConversationCommand,
|
||||
isPending: isNewConversationPending,
|
||||
} = useNewConversationCommand();
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
const { handleBuildPlanClick } = useHandleBuildPlanClick();
|
||||
@@ -146,6 +152,27 @@ export function ChatInterface() {
|
||||
originalImages: File[],
|
||||
originalFiles: File[],
|
||||
) => {
|
||||
// Handle /new command for V1 conversations
|
||||
if (content.trim() === "/new") {
|
||||
if (!isV1Conversation) {
|
||||
displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_V1_ONLY));
|
||||
return;
|
||||
}
|
||||
if (!params.conversationId) {
|
||||
displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_NO_ID));
|
||||
return;
|
||||
}
|
||||
if (totalEvents === 0) {
|
||||
displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_EMPTY));
|
||||
return;
|
||||
}
|
||||
if (isNewConversationPending) {
|
||||
return;
|
||||
}
|
||||
newConversationCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create mutable copies of the arrays
|
||||
const images = [...originalImages];
|
||||
const files = [...originalFiles];
|
||||
@@ -338,7 +365,10 @@ export function ChatInterface() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<InteractiveChatBox onSubmit={handleSendMessage} />
|
||||
<InteractiveChatBox
|
||||
onSubmit={handleSendMessage}
|
||||
disabled={isNewConversationPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config?.app_mode !== "saas" && !isV1Conversation && (
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ChatInputContainerProps {
|
||||
chatContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
isDragOver: boolean;
|
||||
disabled: boolean;
|
||||
isNewConversationPending?: boolean;
|
||||
showButton: boolean;
|
||||
buttonClassName: string;
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -36,6 +37,7 @@ export function ChatInputContainer({
|
||||
chatContainerRef,
|
||||
isDragOver,
|
||||
disabled,
|
||||
isNewConversationPending = false,
|
||||
showButton,
|
||||
buttonClassName,
|
||||
chatInputRef,
|
||||
@@ -89,6 +91,7 @@ export function ChatInputContainer({
|
||||
<ChatInputRow
|
||||
chatInputRef={chatInputRef}
|
||||
disabled={disabled}
|
||||
isNewConversationPending={isNewConversationPending}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
|
||||
@@ -2,9 +2,11 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ChatInputFieldProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
disabled?: boolean;
|
||||
onInput: () => void;
|
||||
onPaste: (e: React.ClipboardEvent) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
@@ -14,6 +16,7 @@ interface ChatInputFieldProps {
|
||||
|
||||
export function ChatInputField({
|
||||
chatInputRef,
|
||||
disabled = false,
|
||||
onInput,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
@@ -36,8 +39,11 @@ export function ChatInputField({
|
||||
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
|
||||
<div
|
||||
ref={chatInputRef}
|
||||
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
|
||||
contentEditable
|
||||
className={cn(
|
||||
"chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={
|
||||
isPlanMode
|
||||
? t(I18nKey.COMMON$LET_S_WORK_ON_A_PLAN)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChatInputField } from "./chat-input-field";
|
||||
interface ChatInputRowProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
disabled: boolean;
|
||||
isNewConversationPending?: boolean;
|
||||
showButton: boolean;
|
||||
buttonClassName: string;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
@@ -21,6 +22,7 @@ interface ChatInputRowProps {
|
||||
export function ChatInputRow({
|
||||
chatInputRef,
|
||||
disabled,
|
||||
isNewConversationPending = false,
|
||||
showButton,
|
||||
buttonClassName,
|
||||
handleFileIconClick,
|
||||
@@ -41,6 +43,7 @@ export function ChatInputRow({
|
||||
|
||||
<ChatInputField
|
||||
chatInputRef={chatInputRef}
|
||||
disabled={isNewConversationPending}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
export interface CustomChatInputProps {
|
||||
disabled?: boolean;
|
||||
isNewConversationPending?: boolean;
|
||||
showButton?: boolean;
|
||||
conversationStatus?: ConversationStatus | null;
|
||||
onSubmit: (message: string) => void;
|
||||
@@ -25,6 +26,7 @@ export interface CustomChatInputProps {
|
||||
|
||||
export function CustomChatInput({
|
||||
disabled = false,
|
||||
isNewConversationPending = false,
|
||||
showButton = true,
|
||||
conversationStatus = null,
|
||||
onSubmit,
|
||||
@@ -147,6 +149,7 @@ export function CustomChatInput({
|
||||
chatContainerRef={chatContainerRef}
|
||||
isDragOver={isDragOver}
|
||||
disabled={isDisabled}
|
||||
isNewConversationPending={isNewConversationPending}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
chatInputRef={chatInputRef}
|
||||
|
||||
@@ -13,9 +13,13 @@ import { isTaskPolling } from "#/utils/utils";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
export function InteractiveChatBox({
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
}: InteractiveChatBoxProps) {
|
||||
const {
|
||||
images,
|
||||
files,
|
||||
@@ -145,6 +149,7 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
// Allow users to submit messages during LOADING state - they will be
|
||||
// queued server-side and delivered when the conversation becomes ready
|
||||
const isDisabled =
|
||||
disabled ||
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION ||
|
||||
isTaskPolling(subConversationTaskStatus);
|
||||
|
||||
@@ -152,6 +157,7 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
<div data-testid="interactive-chat-box">
|
||||
<CustomChatInput
|
||||
disabled={isDisabled}
|
||||
isNewConversationPending={disabled}
|
||||
onSubmit={handleSubmit}
|
||||
onFilesPaste={handleUpload}
|
||||
conversationStatus={conversation?.status || null}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { FaExternalLinkAlt } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||
import { RUNTIME_STARTING_STATES } from "#/types/agent-state";
|
||||
|
||||
export function VSCodeTooltipContent() {
|
||||
const { curAgentState } = useAgentState();
|
||||
const { t } = useTranslation();
|
||||
const { data, refetch } = useUnifiedVSCodeUrl();
|
||||
const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
|
||||
|
||||
const handleVSCodeClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -29,7 +30,7 @@ export function VSCodeTooltipContent() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t(I18nKey.COMMON$CODE)}</span>
|
||||
{!RUNTIME_INACTIVE_STATES.includes(curAgentState) ? (
|
||||
{!isRuntimeStarting ? (
|
||||
<FaExternalLinkAlt
|
||||
className="w-3 h-3 text-inherit cursor-pointer"
|
||||
onClick={handleVSCodeClick}
|
||||
|
||||
103
frontend/src/components/features/org/add-credits-modal.tsx
Normal file
103
frontend/src/components/features/org/add-credits-modal.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
|
||||
interface AddCreditsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddCreditsModal({ onClose }: AddCreditsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: addBalance } = useCreateStripeCheckoutSession();
|
||||
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
|
||||
const getErrorMessage = (value: string): string | null => {
|
||||
if (!value.trim()) return null;
|
||||
|
||||
const numValue = parseInt(value, 10);
|
||||
if (Number.isNaN(numValue)) {
|
||||
return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER);
|
||||
}
|
||||
if (numValue < 0) {
|
||||
return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT);
|
||||
}
|
||||
if (numValue < 10) {
|
||||
return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT);
|
||||
}
|
||||
if (numValue > 25000) {
|
||||
return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT);
|
||||
}
|
||||
if (numValue !== parseFloat(value)) {
|
||||
return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const amount = formData.get("amount")?.toString();
|
||||
|
||||
if (amount?.trim()) {
|
||||
if (!amountIsValid(amount)) {
|
||||
const error = getErrorMessage(amount);
|
||||
setErrorMessage(error || "Invalid amount");
|
||||
return;
|
||||
}
|
||||
|
||||
const intValue = parseInt(amount, 10);
|
||||
|
||||
addBalance({ amount: intValue }, { onSuccess: onClose });
|
||||
|
||||
setErrorMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAmountInputChange = (value: string) => {
|
||||
setInputValue(value);
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<form
|
||||
data-testid="add-credits-form"
|
||||
action={formAction}
|
||||
noValidate
|
||||
className="w-sm rounded-xl bg-base-secondary flex flex-col p-6 gap-4 border border-tertiary"
|
||||
>
|
||||
<h3 className="text-xl font-bold">{t(I18nKey.ORG$ADD_CREDITS)}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SettingsInput
|
||||
testId="amount-input"
|
||||
name="amount"
|
||||
label={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)}
|
||||
type="number"
|
||||
min={10}
|
||||
max={25000}
|
||||
step={1}
|
||||
value={inputValue}
|
||||
onChange={(value) => handleAmountInputChange(value)}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1" data-testid="amount-error">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalButtonGroup
|
||||
primaryText={t(I18nKey.ORG$NEXT)}
|
||||
onSecondaryClick={onClose}
|
||||
primaryType="submit"
|
||||
/>
|
||||
</form>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { BrandButton } from "../brand-button";
|
||||
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import { OptionalTag } from "../optional-tag";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
interface SecretFormProps {
|
||||
mode: "add" | "edit";
|
||||
@@ -24,6 +25,7 @@ export function SecretForm({
|
||||
}: SecretFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
const { data: secrets } = useGetSecrets();
|
||||
const { mutate: createSecret } = useCreateSecret();
|
||||
@@ -49,7 +51,9 @@ export function SecretForm({
|
||||
{
|
||||
onSettled: onCancel,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["secrets"] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["secrets", organizationId],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -61,7 +65,7 @@ export function SecretForm({
|
||||
description?: string,
|
||||
) => {
|
||||
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
|
||||
["secrets"],
|
||||
["secrets", organizationId],
|
||||
(oldSecrets) => {
|
||||
if (!oldSecrets) return [];
|
||||
return oldSecrets.map((secret) => {
|
||||
@@ -79,7 +83,7 @@ export function SecretForm({
|
||||
};
|
||||
|
||||
const revertOptimisticUpdate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["secrets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["secrets", organizationId] });
|
||||
};
|
||||
|
||||
const handleEditSecret = (
|
||||
|
||||
@@ -22,20 +22,43 @@ export function UserActions({ user, isLoading }: UserActionsProps) {
|
||||
const [menuResetCount, setMenuResetCount] = React.useState(0);
|
||||
const [inviteMemberModalIsOpen, setInviteMemberModalIsOpen] =
|
||||
React.useState(false);
|
||||
const hideTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
// Use the shared hook to determine if user actions should be shown
|
||||
const shouldShowUserActions = useShouldShowUserFeatures();
|
||||
|
||||
// Clean up timeout on unmount
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const showAccountMenu = () => {
|
||||
// Cancel any pending hide to allow diagonal mouse movement to menu
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
setAccountContextMenuIsVisible(true);
|
||||
};
|
||||
|
||||
const hideAccountMenu = () => {
|
||||
setAccountContextMenuIsVisible(false);
|
||||
setMenuResetCount((c) => c + 1);
|
||||
// Delay hiding to allow diagonal mouse movement to menu
|
||||
hideTimeoutRef.current = window.setTimeout(() => {
|
||||
setAccountContextMenuIsVisible(false);
|
||||
setMenuResetCount((c) => c + 1);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const closeAccountMenu = () => {
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
if (accountContextMenuIsVisible) {
|
||||
setAccountContextMenuIsVisible(false);
|
||||
setMenuResetCount((c) => c + 1);
|
||||
@@ -61,9 +84,6 @@ export function UserActions({ user, isLoading }: UserActionsProps) {
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||
accountContextMenuIsVisible && "opacity-100 pointer-events-auto",
|
||||
// Invisible hover bridge: extends hover zone to create a "safe corridor"
|
||||
// for diagonal mouse movement to the menu (only active when menu is visible)
|
||||
"group-hover:before:content-[''] group-hover:before:block group-hover:before:absolute group-hover:before:inset-[-320px] group-hover:before:z-50 before:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<UserContextMenu
|
||||
|
||||
@@ -156,13 +156,16 @@ export function UserContextMenu({
|
||||
{t(I18nKey.SIDEBAR$DOCS)}
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleLogout}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoLogOutOutline className="text-white" size={16} />
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
{/* Only show logout in saas mode - oss mode has no session to invalidate */}
|
||||
{isSaasMode && (
|
||||
<ContextMenuListItem
|
||||
onClick={handleLogout}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoLogOutOutline className="text-white" size={16} />
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { Provider, ProviderToken } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const useAddGitProviders = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { trackGitProviderConnected } = useTracking();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -25,7 +27,9 @@ export const useAddGitProviders = () => {
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
},
|
||||
meta: {
|
||||
disableToast: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
|
||||
@@ -19,6 +20,7 @@ interface MCPServerConfig {
|
||||
export function useAddMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettings();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (server: MCPServerConfig): Promise<void> => {
|
||||
@@ -64,7 +66,9 @@ export function useAddMcpServer() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the settings query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys";
|
||||
import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (name: string): Promise<CreateApiKeyResponse> =>
|
||||
ApiKeysClient.createApiKey(name),
|
||||
onSuccess: () => {
|
||||
// Invalidate the API keys query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_KEYS_QUERY_KEY, organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import ApiKeysClient from "#/api/api-keys";
|
||||
import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export function useDeleteApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
@@ -11,7 +13,9 @@ export function useDeleteApiKey() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the API keys query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_KEYS_QUERY_KEY, organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export function useDeleteMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettings();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (serverId: string): Promise<void> => {
|
||||
@@ -32,7 +34,9 @@ export function useDeleteMcpServer() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the settings query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
115
frontend/src/hooks/mutation/use-new-conversation-command.ts
Normal file
115
frontend/src/hooks/mutation/use-new-conversation-command.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import toast from "react-hot-toast";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
TOAST_OPTIONS,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
export const useNewConversationCommand = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!conversation?.conversation_id || !conversation.sandbox_id) {
|
||||
throw new Error("No active conversation or sandbox");
|
||||
}
|
||||
|
||||
// Fetch V1 conversation data to get llm_model (not available in legacy type)
|
||||
const v1Conversations =
|
||||
await V1ConversationService.batchGetAppConversations([
|
||||
conversation.conversation_id,
|
||||
]);
|
||||
const llmModel = v1Conversations?.[0]?.llm_model;
|
||||
|
||||
// Start a new conversation reusing the existing sandbox directly.
|
||||
// We pass sandbox_id instead of parent_conversation_id so that the
|
||||
// new conversation is NOT marked as a sub-conversation and will
|
||||
// appear in the conversation list.
|
||||
const startTask = await V1ConversationService.createConversation(
|
||||
conversation.selected_repository ?? undefined, // selectedRepository
|
||||
conversation.git_provider ?? undefined, // git_provider
|
||||
undefined, // initialUserMsg
|
||||
conversation.selected_branch ?? undefined, // selected_branch
|
||||
undefined, // conversationInstructions
|
||||
undefined, // suggestedTask
|
||||
undefined, // trigger
|
||||
undefined, // parent_conversation_id
|
||||
undefined, // agent_type
|
||||
conversation.sandbox_id ?? undefined, // sandbox_id - reuse the same sandbox
|
||||
llmModel ?? undefined, // llm_model - preserve the LLM model
|
||||
);
|
||||
|
||||
// Poll for the task to complete and get the new conversation ID
|
||||
let task = await V1ConversationService.getStartTask(startTask.id);
|
||||
const maxAttempts = 60; // 60 seconds timeout
|
||||
let attempts = 0;
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
while (
|
||||
task &&
|
||||
!["READY", "ERROR"].includes(task.status) &&
|
||||
attempts < maxAttempts
|
||||
) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
task = await V1ConversationService.getStartTask(startTask.id);
|
||||
attempts += 1;
|
||||
}
|
||||
|
||||
if (!task || task.status !== "READY" || !task.app_conversation_id) {
|
||||
throw new Error(
|
||||
task?.detail || "Failed to create new conversation in sandbox",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
newConversationId: task.app_conversation_id,
|
||||
oldConversationId: conversation.conversation_id,
|
||||
};
|
||||
},
|
||||
onMutate: () => {
|
||||
toast.loading(t(I18nKey.CONVERSATION$CLEARING), {
|
||||
...TOAST_OPTIONS,
|
||||
id: "clear-conversation",
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.dismiss("clear-conversation");
|
||||
displaySuccessToast(t(I18nKey.CONVERSATION$CLEAR_SUCCESS));
|
||||
navigate(`/conversations/${data.newConversationId}`);
|
||||
|
||||
// Refresh the sidebar to show the new conversation.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["v1-batch-get-app-conversations"],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.dismiss("clear-conversation");
|
||||
let clearError = t(I18nKey.CONVERSATION$CLEAR_UNKNOWN_ERROR);
|
||||
if (error instanceof Error) {
|
||||
clearError = error.message;
|
||||
} else if (typeof error === "string") {
|
||||
clearError = error;
|
||||
}
|
||||
displayErrorToast(
|
||||
t(I18nKey.CONVERSATION$CLEAR_FAILED, { error: clearError }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { Settings } from "#/types/settings";
|
||||
import { useSettings } from "../query/use-settings";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
type SettingsUpdate = Partial<Settings> & Record<string, unknown>;
|
||||
|
||||
@@ -32,6 +33,7 @@ export const useSaveSettings = () => {
|
||||
const posthog = usePostHog();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: currentSettings } = useSettings();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: SettingsUpdate) => {
|
||||
@@ -58,7 +60,9 @@ export const useSaveSettings = () => {
|
||||
await saveSettingsMutationFn(newSettings);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
},
|
||||
meta: {
|
||||
disableToast: true,
|
||||
|
||||
@@ -17,10 +17,9 @@ export const useSwitchOrganization = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["organizations", orgId, "me"],
|
||||
});
|
||||
// Update local state
|
||||
// Update local state - this triggers automatic refetch for all org-scoped queries
|
||||
// since their query keys include organizationId (e.g., ["settings", orgId], ["secrets", orgId])
|
||||
setOrganizationId(orgId);
|
||||
// Invalidate settings for the new org context
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
// Invalidate conversations to fetch data for the new org context
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
// Remove all individual conversation queries to clear any stale/null data
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
|
||||
@@ -19,6 +20,7 @@ interface MCPServerConfig {
|
||||
export function useUpdateMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettings();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
@@ -66,7 +68,9 @@ export function useUpdateMcpServer() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the settings query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ApiKeysClient from "#/api/api-keys";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const API_KEYS_QUERY_KEY = "api-keys";
|
||||
|
||||
export function useApiKeys() {
|
||||
const { data: config } = useConfig();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [API_KEYS_QUERY_KEY],
|
||||
enabled: config?.app_mode === "saas",
|
||||
queryKey: [API_KEYS_QUERY_KEY, organizationId],
|
||||
enabled: config?.app_mode === "saas" && !!organizationId,
|
||||
queryFn: async () => {
|
||||
const keys = await ApiKeysClient.getApiKeys();
|
||||
return Array.isArray(keys) ? keys : [];
|
||||
|
||||
@@ -2,16 +2,18 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const useGetSecrets = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
const isOss = config?.app_mode === "oss";
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["secrets"],
|
||||
queryKey: ["secrets", organizationId],
|
||||
queryFn: SecretsService.getSecrets,
|
||||
enabled: isOss || isAuthed, // Enable regardless of providers
|
||||
enabled: isOss || (isAuthed && !!organizationId),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -35,13 +35,14 @@ export const useGitUser = () => {
|
||||
}
|
||||
}, [user.data]);
|
||||
|
||||
// If we get a 401 here, it means that the integration tokens need to be
|
||||
// In saas mode, a 401 means that the integration tokens need to be
|
||||
// refreshed. Since this happens at login, we log out.
|
||||
// In oss mode, skip auto-logout since there's no token refresh mechanism
|
||||
React.useEffect(() => {
|
||||
if (user?.error?.response?.status === 401) {
|
||||
if (user?.error?.response?.status === 401 && config?.app_mode === "saas") {
|
||||
logout.mutate();
|
||||
}
|
||||
}, [user.status]);
|
||||
}, [user.status, config?.app_mode]);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
|
||||
import { Settings } from "#/types/settings";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { useConfig } from "./use-config";
|
||||
|
||||
const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
const settings = await SettingsService.getSettings();
|
||||
@@ -29,9 +31,13 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
export const useSettings = () => {
|
||||
const isOnIntermediatePage = useIsOnIntermediatePage();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const isOss = config?.app_mode === "oss";
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryKey: ["settings", organizationId],
|
||||
queryFn: getSettingsQueryFn,
|
||||
// Only retry if the error is not a 404 because we
|
||||
// would want to show the modal immediately if the
|
||||
@@ -40,7 +46,10 @@ export const useSettings = () => {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: !isOnIntermediatePage && !!userIsAuthenticated,
|
||||
enabled:
|
||||
!isOnIntermediatePage &&
|
||||
!!userIsAuthenticated &&
|
||||
(isOss || !!organizationId),
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import V1GitService from "#/api/git-service/v1-git-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
import type { GitChange } from "#/api/open-hands.types";
|
||||
|
||||
@@ -16,6 +17,7 @@ import type { GitChange } from "#/api/open-hands.types";
|
||||
export const useUnifiedGetGitChanges = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: settings } = useSettings();
|
||||
const [orderedChanges, setOrderedChanges] = React.useState<GitChange[]>([]);
|
||||
const previousDataRef = React.useRef<GitChange[] | null>(null);
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
@@ -25,10 +27,15 @@ export const useUnifiedGetGitChanges = () => {
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
const selectedRepository = conversation?.selected_repository;
|
||||
|
||||
// Calculate git path based on selected repository
|
||||
// Sandbox grouping is enabled when strategy is not NO_GROUPING
|
||||
const useSandboxGrouping =
|
||||
settings?.sandbox_grouping_strategy !== "NO_GROUPING" &&
|
||||
settings?.sandbox_grouping_strategy !== undefined;
|
||||
|
||||
// Calculate git path based on selected repository and sandbox grouping strategy
|
||||
const gitPath = React.useMemo(
|
||||
() => getGitPath(conversationId, selectedRepository),
|
||||
[selectedRepository],
|
||||
() => getGitPath(conversationId, selectedRepository, useSandboxGrouping),
|
||||
[conversationId, selectedRepository, useSandboxGrouping],
|
||||
);
|
||||
|
||||
const result = useQuery({
|
||||
@@ -57,6 +64,7 @@ export const useUnifiedGetGitChanges = () => {
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
refetchOnMount: "always", // Always refetch when mounting (e.g. navigating between conversations that share a sandbox)
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import GitService from "#/api/git-service/git-service.api";
|
||||
import V1GitService from "#/api/git-service/v1-git-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
import type { GitChangeStatus } from "#/api/open-hands.types";
|
||||
|
||||
@@ -21,20 +22,36 @@ type UseUnifiedGitDiffConfig = {
|
||||
export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
const conversationUrl = conversation?.url;
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
const selectedRepository = conversation?.selected_repository;
|
||||
|
||||
// Sandbox grouping is enabled when strategy is not NO_GROUPING
|
||||
const useSandboxGrouping =
|
||||
settings?.sandbox_grouping_strategy !== "NO_GROUPING" &&
|
||||
settings?.sandbox_grouping_strategy !== undefined;
|
||||
|
||||
// For V1, we need to convert the relative file path to an absolute path
|
||||
// The diff endpoint expects: /workspace/project/RepoName/relative/path
|
||||
const absoluteFilePath = React.useMemo(() => {
|
||||
if (!isV1Conversation) return config.filePath;
|
||||
|
||||
const gitPath = getGitPath(conversationId, selectedRepository);
|
||||
const gitPath = getGitPath(
|
||||
conversationId,
|
||||
selectedRepository,
|
||||
useSandboxGrouping,
|
||||
);
|
||||
return `${gitPath}/${config.filePath}`;
|
||||
}, [isV1Conversation, selectedRepository, config.filePath]);
|
||||
}, [
|
||||
isV1Conversation,
|
||||
conversationId,
|
||||
selectedRepository,
|
||||
useSandboxGrouping,
|
||||
config.filePath,
|
||||
]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
|
||||
@@ -23,7 +23,7 @@ export const useUnifiedVSCodeUrl = () => {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const runtimeIsReady = useRuntimeIsReady({ allowAgentError: true });
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "./query/use-active-conversation";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import {
|
||||
RUNTIME_INACTIVE_STATES,
|
||||
RUNTIME_STARTING_STATES,
|
||||
} from "#/types/agent-state";
|
||||
import { useActiveConversation } from "./query/use-active-conversation";
|
||||
|
||||
interface UseRuntimeIsReadyOptions {
|
||||
allowAgentError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to determine if the runtime is ready for operations
|
||||
*
|
||||
* @returns boolean indicating if the runtime is ready
|
||||
*/
|
||||
export const useRuntimeIsReady = (): boolean => {
|
||||
export const useRuntimeIsReady = ({
|
||||
allowAgentError = false,
|
||||
}: UseRuntimeIsReadyOptions = {}): boolean => {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const inactiveStates = allowAgentError
|
||||
? RUNTIME_STARTING_STATES
|
||||
: RUNTIME_INACTIVE_STATES;
|
||||
|
||||
return (
|
||||
conversation?.status === "RUNNING" &&
|
||||
!RUNTIME_INACTIVE_STATES.includes(curAgentState)
|
||||
!inactiveStates.includes(curAgentState)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1156,6 +1156,14 @@ export enum I18nKey {
|
||||
ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON",
|
||||
ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON",
|
||||
ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON",
|
||||
CONVERSATION$CLEAR_V1_ONLY = "CONVERSATION$CLEAR_V1_ONLY",
|
||||
CONVERSATION$CLEAR_EMPTY = "CONVERSATION$CLEAR_EMPTY",
|
||||
CONVERSATION$CLEAR_NO_ID = "CONVERSATION$CLEAR_NO_ID",
|
||||
CONVERSATION$CLEAR_NO_NEW_ID = "CONVERSATION$CLEAR_NO_NEW_ID",
|
||||
CONVERSATION$CLEAR_UNKNOWN_ERROR = "CONVERSATION$CLEAR_UNKNOWN_ERROR",
|
||||
CONVERSATION$CLEAR_FAILED = "CONVERSATION$CLEAR_FAILED",
|
||||
CONVERSATION$CLEAR_SUCCESS = "CONVERSATION$CLEAR_SUCCESS",
|
||||
CONVERSATION$CLEARING = "CONVERSATION$CLEARING",
|
||||
CTA$ENTERPRISE = "CTA$ENTERPRISE",
|
||||
CTA$ENTERPRISE_DEPLOY = "CTA$ENTERPRISE_DEPLOY",
|
||||
CTA$FEATURE_ON_PREMISES = "CTA$FEATURE_ON_PREMISES",
|
||||
|
||||
@@ -19654,6 +19654,142 @@
|
||||
"uk": "Завершити",
|
||||
"ca": "Finalitza"
|
||||
},
|
||||
"CONVERSATION$CLEAR_V1_ONLY": {
|
||||
"en": "The /new command is only available for V1 conversations",
|
||||
"ja": "/newコマンドはV1会話でのみ使用できます",
|
||||
"zh-CN": "/new 命令仅适用于 V1 对话",
|
||||
"zh-TW": "/new 指令僅適用於 V1 對話",
|
||||
"ko-KR": "/new 명령은 V1 대화에서만 사용할 수 있습니다",
|
||||
"no": "/new-kommandoen er kun tilgjengelig for V1-samtaler",
|
||||
"it": "Il comando /new è disponibile solo per le conversazioni V1",
|
||||
"pt": "O comando /new está disponível apenas para conversas V1",
|
||||
"es": "El comando /new solo está disponible para conversaciones V1",
|
||||
"ar": "أمر /new متاح فقط لمحادثات V1",
|
||||
"fr": "La commande /new n'est disponible que pour les conversations V1",
|
||||
"tr": "/new komutu yalnızca V1 konuşmalarında kullanılabilir",
|
||||
"de": "Der /new-Befehl ist nur für V1-Konversationen verfügbar",
|
||||
"uk": "Команда /new доступна лише для розмов V1",
|
||||
"ca": "L'ordre /new només està disponible per a converses V1"
|
||||
},
|
||||
"CONVERSATION$CLEAR_EMPTY": {
|
||||
"en": "Nothing to clear. This conversation has no messages yet.",
|
||||
"ja": "クリアするものがありません。この会話にはまだメッセージがありません。",
|
||||
"zh-CN": "没有可清除的内容。此对话尚无消息。",
|
||||
"zh-TW": "沒有可清除的內容。此對話尚無訊息。",
|
||||
"ko-KR": "지울 내용이 없습니다. 이 대화에는 아직 메시지가 없습니다.",
|
||||
"no": "Ingenting å tømme. Denne samtalen har ingen meldinger ennå.",
|
||||
"it": "Niente da cancellare. Questa conversazione non ha ancora messaggi.",
|
||||
"pt": "Nada para limpar. Esta conversa ainda não tem mensagens.",
|
||||
"es": "Nada que borrar. Esta conversación aún no tiene mensajes.",
|
||||
"ar": "لا يوجد شيء لمسحه. لا تحتوي هذه المحادثة على رسائل بعد.",
|
||||
"fr": "Rien à effacer. Cette conversation n'a pas encore de messages.",
|
||||
"tr": "Temizlenecek bir şey yok. Bu konuşmada henüz mesaj yok.",
|
||||
"de": "Nichts zu löschen. Diese Konversation hat noch keine Nachrichten.",
|
||||
"uk": "Нічого очищувати. Ця розмова ще не має повідомлень.",
|
||||
"ca": "No hi ha res a esborrar. Aquesta conversa encara no té missatges."
|
||||
},
|
||||
"CONVERSATION$CLEAR_NO_ID": {
|
||||
"en": "No conversation ID found",
|
||||
"ja": "会話IDが見つかりません",
|
||||
"zh-CN": "未找到对话 ID",
|
||||
"zh-TW": "找不到對話 ID",
|
||||
"ko-KR": "대화 ID를 찾을 수 없습니다",
|
||||
"no": "Ingen samtale-ID funnet",
|
||||
"it": "Nessun ID conversazione trovato",
|
||||
"pt": "Nenhum ID de conversa encontrado",
|
||||
"es": "No se encontró el ID de conversación",
|
||||
"ar": "لم يتم العثور على معرف المحادثة",
|
||||
"fr": "Aucun identifiant de conversation trouvé",
|
||||
"tr": "Konuşma kimliği bulunamadı",
|
||||
"de": "Keine Konversations-ID gefunden",
|
||||
"uk": "Ідентифікатор розмови не знайдено",
|
||||
"ca": "No s'ha trobat l'identificador de la conversa"
|
||||
},
|
||||
"CONVERSATION$CLEAR_NO_NEW_ID": {
|
||||
"en": "Server did not return a new conversation ID",
|
||||
"ja": "サーバーが新しい会話IDを返しませんでした",
|
||||
"zh-CN": "服务器未返回新的对话 ID",
|
||||
"zh-TW": "伺服器未返回新的對話 ID",
|
||||
"ko-KR": "서버가 새 대화 ID를 반환하지 않았습니다",
|
||||
"no": "Serveren returnerte ikke en ny samtale-ID",
|
||||
"it": "Il server non ha restituito un nuovo ID conversazione",
|
||||
"pt": "O servidor não retornou um novo ID de conversa",
|
||||
"es": "El servidor no devolvió un nuevo ID de conversación",
|
||||
"ar": "لم يقم الخادم بإرجاع معرف محادثة جديد",
|
||||
"fr": "Le serveur n'a pas renvoyé un nouvel identifiant de conversation",
|
||||
"tr": "Sunucu yeni bir konuşma kimliği döndürmedi",
|
||||
"de": "Der Server hat keine neue Konversations-ID zurückgegeben",
|
||||
"uk": "Сервер не повернув новий ідентифікатор розмови",
|
||||
"ca": "El servidor no ha retornat un nou identificador de conversa"
|
||||
},
|
||||
"CONVERSATION$CLEAR_UNKNOWN_ERROR": {
|
||||
"en": "Unknown error",
|
||||
"ja": "不明なエラー",
|
||||
"zh-CN": "未知错误",
|
||||
"zh-TW": "未知錯誤",
|
||||
"ko-KR": "알 수 없는 오류",
|
||||
"no": "Ukjent feil",
|
||||
"it": "Errore sconosciuto",
|
||||
"pt": "Erro desconhecido",
|
||||
"es": "Error desconocido",
|
||||
"ar": "خطأ غير معروف",
|
||||
"fr": "Erreur inconnue",
|
||||
"tr": "Bilinmeyen hata",
|
||||
"de": "Unbekannter Fehler",
|
||||
"uk": "Невідома помилка",
|
||||
"ca": "Error desconegut"
|
||||
},
|
||||
"CONVERSATION$CLEAR_FAILED": {
|
||||
"en": "Failed to start new conversation: {{error}}",
|
||||
"ja": "新しい会話の開始に失敗しました: {{error}}",
|
||||
"zh-CN": "启动新对话失败: {{error}}",
|
||||
"zh-TW": "啟動新對話失敗: {{error}}",
|
||||
"ko-KR": "새 대화 시작 실패: {{error}}",
|
||||
"no": "Kunne ikke starte ny samtale: {{error}}",
|
||||
"it": "Impossibile avviare una nuova conversazione: {{error}}",
|
||||
"pt": "Falha ao iniciar nova conversa: {{error}}",
|
||||
"es": "Error al iniciar nueva conversación: {{error}}",
|
||||
"ar": "فشل في بدء محادثة جديدة: {{error}}",
|
||||
"fr": "Échec du démarrage d'une nouvelle conversation : {{error}}",
|
||||
"tr": "Yeni konuşma başlatılamadı: {{error}}",
|
||||
"de": "Neue Konversation konnte nicht gestartet werden: {{error}}",
|
||||
"uk": "Не вдалося розпочати нову розмову: {{error}}",
|
||||
"ca": "No s'ha pogut iniciar una nova conversa: {{error}}"
|
||||
},
|
||||
"CONVERSATION$CLEAR_SUCCESS": {
|
||||
"en": "Starting a new conversation in the same sandbox. These conversations share the same runtime.",
|
||||
"ja": "同じサンドボックスで新しい会話を開始します。これらの会話は同じランタイムを共有します。",
|
||||
"zh-CN": "正在同一沙箱中开始新对话。这些对话共享同一运行时。",
|
||||
"zh-TW": "正在同一沙盒中開始新對話。這些對話共享同一執行環境。",
|
||||
"ko-KR": "같은 샌드박스에서 새 대화를 시작합니다. 이 대화들은 같은 런타임을 공유합니다.",
|
||||
"no": "Starter ny samtale i samme sandbox. Disse samtalene deler samme kjøretid.",
|
||||
"it": "Avvio nuova conversazione nello stesso sandbox. Queste conversazioni condividono lo stesso runtime.",
|
||||
"pt": "Iniciando nova conversa no mesmo sandbox. Essas conversas compartilham o mesmo runtime.",
|
||||
"es": "Iniciando nueva conversación en el mismo sandbox. Estas conversaciones comparten el mismo runtime.",
|
||||
"ar": "بدء محادثة جديدة في نفس صندوق الحماية. هذه المحادثات تشارك نفس وقت التشغيل.",
|
||||
"fr": "Démarrage d'une nouvelle conversation dans le même bac à sable. Ces conversations partagent le même environnement d'exécution.",
|
||||
"tr": "Aynı korumalı alanda yeni konuşma başlatılıyor. Bu konuşmalar aynı çalışma ortamını paylaşır.",
|
||||
"de": "Starte neue Konversation in derselben Sandbox. Diese Konversationen teilen dieselbe Laufzeitumgebung.",
|
||||
"uk": "Починаю нову розмову в тому самому захищеному середовищі. Ці розмови використовують одне середовище виконання.",
|
||||
"ca": "S'està iniciant una nova conversa al mateix entorn aïllat. Aquestes converses comparteixen el mateix entorn d'execució."
|
||||
},
|
||||
"CONVERSATION$CLEARING": {
|
||||
"en": "Creating new conversation...",
|
||||
"ja": "新しい会話を作成中...",
|
||||
"zh-CN": "正在创建新对话...",
|
||||
"zh-TW": "正在建立新對話...",
|
||||
"ko-KR": "새 대화를 만드는 중...",
|
||||
"no": "Oppretter ny samtale...",
|
||||
"it": "Creazione nuova conversazione...",
|
||||
"pt": "Criando nova conversa...",
|
||||
"es": "Creando nueva conversación...",
|
||||
"ar": "جارٍ إنشاء محادثة جديدة...",
|
||||
"fr": "Création d'une nouvelle conversation...",
|
||||
"tr": "Yeni konuşma oluşturuluyor...",
|
||||
"de": "Neue Konversation wird erstellt...",
|
||||
"uk": "Створення нової розмови...",
|
||||
"ca": "S'està creant una nova conversa..."
|
||||
},
|
||||
"CTA$ENTERPRISE": {
|
||||
"en": "Enterprise",
|
||||
"ja": "エンタープライズ",
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { useOrganization } from "#/hooks/query/use-organization";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { useMe } from "#/hooks/query/use-me";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
import { CreditsChip } from "#/ui/credits-chip";
|
||||
import { InteractiveChip } from "#/ui/interactive-chip";
|
||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||
@@ -16,104 +11,10 @@ import { createPermissionGuard } from "#/utils/org/permission-guard";
|
||||
import { isBillingHidden } from "#/utils/org/billing-visibility";
|
||||
import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal";
|
||||
import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal";
|
||||
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface AddCreditsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AddCreditsModal({ onClose }: AddCreditsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: addBalance } = useCreateStripeCheckoutSession();
|
||||
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
|
||||
const getErrorMessage = (value: string): string | null => {
|
||||
if (!value.trim()) return null;
|
||||
|
||||
const numValue = parseInt(value, 10);
|
||||
if (Number.isNaN(numValue)) {
|
||||
return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER);
|
||||
}
|
||||
if (numValue < 0) {
|
||||
return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT);
|
||||
}
|
||||
if (numValue < 10) {
|
||||
return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT);
|
||||
}
|
||||
if (numValue > 25000) {
|
||||
return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT);
|
||||
}
|
||||
if (numValue !== parseFloat(value)) {
|
||||
return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const amount = formData.get("amount")?.toString();
|
||||
|
||||
if (amount?.trim()) {
|
||||
if (!amountIsValid(amount)) {
|
||||
const error = getErrorMessage(amount);
|
||||
setErrorMessage(error || "Invalid amount");
|
||||
return;
|
||||
}
|
||||
|
||||
const intValue = parseInt(amount, 10);
|
||||
|
||||
addBalance({ amount: intValue }, { onSuccess: onClose });
|
||||
|
||||
setErrorMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAmountInputChange = (value: string) => {
|
||||
setInputValue(value);
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<form
|
||||
data-testid="add-credits-form"
|
||||
action={formAction}
|
||||
noValidate
|
||||
className="w-sm rounded-xl bg-base-secondary flex flex-col p-6 gap-4 border border-tertiary"
|
||||
>
|
||||
<h3 className="text-xl font-bold">{t(I18nKey.ORG$ADD_CREDITS)}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SettingsInput
|
||||
testId="amount-input"
|
||||
name="amount"
|
||||
label={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)}
|
||||
type="number"
|
||||
min={10}
|
||||
max={25000}
|
||||
step={1}
|
||||
value={inputValue}
|
||||
onChange={(value) => handleAmountInputChange(value)}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1" data-testid="amount-error">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalButtonGroup
|
||||
primaryText={t(I18nKey.ORG$NEXT)}
|
||||
onSecondaryClick={onClose}
|
||||
primaryType="submit"
|
||||
/>
|
||||
</form>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
export const clientLoader = createPermissionGuard("view_billing");
|
||||
|
||||
function ManageOrg() {
|
||||
|
||||
@@ -13,12 +13,14 @@ import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { createPermissionGuard } from "#/utils/org/permission-guard";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const clientLoader = createPermissionGuard("manage_secrets");
|
||||
|
||||
function SecretsSettingsScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
|
||||
const { data: secrets, isLoading: isLoadingSecrets } = useGetSecrets();
|
||||
const { mutate: deleteSecret } = useDeleteSecret();
|
||||
@@ -34,7 +36,7 @@ function SecretsSettingsScreen() {
|
||||
|
||||
const deleteSecretOptimistically = (secret: string) => {
|
||||
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
|
||||
["secrets"],
|
||||
["secrets", organizationId],
|
||||
(oldSecrets) => {
|
||||
if (!oldSecrets) return [];
|
||||
return oldSecrets.filter((s) => s.name !== secret);
|
||||
@@ -43,7 +45,7 @@ function SecretsSettingsScreen() {
|
||||
};
|
||||
|
||||
const revertOptimisticUpdate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["secrets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["secrets", organizationId] });
|
||||
};
|
||||
|
||||
const handleDeleteSecret = (secret: string) => {
|
||||
|
||||
@@ -30,7 +30,6 @@ const SAAS_ONLY_PATHS = [
|
||||
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const { pathname } = url;
|
||||
console.log("clientLoader", { pathname });
|
||||
|
||||
// Step 1: Get config first (needed for all checks, no user data required)
|
||||
let config = queryClient.getQueryData<WebClientConfig>(["web-client-config"]);
|
||||
@@ -51,7 +50,6 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
// This handles hide_llm_settings, hide_users_page, hide_billing_page, hide_integrations_page
|
||||
if (isSettingsPageHidden(pathname, featureFlags)) {
|
||||
const fallbackPath = getFirstAvailablePath(isSaas, featureFlags);
|
||||
console.log("fallbackPath", fallbackPath);
|
||||
if (fallbackPath && fallbackPath !== pathname) {
|
||||
return redirect(fallbackPath);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { useEmailVerification } from "#/hooks/use-email-verification";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
// Email validation regex pattern
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
@@ -113,6 +114,7 @@ function VerificationAlert() {
|
||||
function UserSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings, isLoading, refetch } = useSettings();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [originalEmail, setOriginalEmail] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -144,7 +146,9 @@ function UserSettingsScreen() {
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -162,7 +166,7 @@ function UserSettingsScreen() {
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [settings?.email_verified, refetch, queryClient, t]);
|
||||
}, [settings?.email_verified, refetch, queryClient, t, organizationId]);
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value;
|
||||
@@ -178,7 +182,9 @@ function UserSettingsScreen() {
|
||||
setOriginalEmail(email);
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { RUNTIME_STARTING_STATES } from "#/types/agent-state";
|
||||
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
|
||||
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
function VSCodeTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error } = useUnifiedVSCodeUrl();
|
||||
const { curAgentState } = useAgentState();
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const [isCrossProtocol, setIsCrossProtocol] = useState(false);
|
||||
const [iframeError, setIframeError] = useState<string | null>(null);
|
||||
@@ -39,7 +39,7 @@ function VSCodeTab() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isRuntimeInactive) {
|
||||
if (isRuntimeStarting) {
|
||||
return <WaitingForRuntimeMessage />;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ export enum AgentState {
|
||||
USER_REJECTED = "user_rejected",
|
||||
}
|
||||
|
||||
export const RUNTIME_STARTING_STATES = [AgentState.INIT, AgentState.LOADING];
|
||||
|
||||
export const RUNTIME_INACTIVE_STATES = [
|
||||
AgentState.INIT,
|
||||
AgentState.LOADING,
|
||||
...RUNTIME_STARTING_STATES,
|
||||
// Removed AgentState.STOPPED to allow tabs to remain visible when agent is stopped
|
||||
AgentState.ERROR,
|
||||
];
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
/**
|
||||
* Get the git repository path for a conversation
|
||||
* If a repository is selected, returns /workspace/project/{repo-name}
|
||||
* Otherwise, returns /workspace/project
|
||||
*
|
||||
* When sandbox grouping is enabled (strategy != NO_GROUPING), each conversation
|
||||
* gets its own subdirectory: /workspace/project/{conversationId}[/{repoName}]
|
||||
*
|
||||
* When sandbox grouping is disabled (NO_GROUPING), the path is simply:
|
||||
* /workspace/project[/{repoName}]
|
||||
*
|
||||
* @param conversationId The conversation ID
|
||||
* @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands", "owner/repo", or "group/subgroup/repo")
|
||||
* @param useSandboxGrouping Whether sandbox grouping is enabled (strategy != NO_GROUPING)
|
||||
* @returns The git path to use
|
||||
*/
|
||||
export function getGitPath(
|
||||
conversationId: string,
|
||||
selectedRepository: string | null | undefined,
|
||||
useSandboxGrouping: boolean = false,
|
||||
): string {
|
||||
// Base path depends on sandbox grouping strategy
|
||||
const basePath = useSandboxGrouping
|
||||
? `/workspace/project/${conversationId}`
|
||||
: "/workspace/project";
|
||||
|
||||
if (!selectedRepository) {
|
||||
return `/workspace/project/${conversationId}`;
|
||||
return basePath;
|
||||
}
|
||||
|
||||
// Extract the repository name from the path
|
||||
@@ -19,5 +31,5 @@ export function getGitPath(
|
||||
const parts = selectedRepository.split("/");
|
||||
const repoName = parts[parts.length - 1];
|
||||
|
||||
return `/workspace/project/${conversationId}/${repoName}`;
|
||||
return `${basePath}/${repoName}`;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,19 @@ export function extractBaseHost(
|
||||
if (conversationUrl && !conversationUrl.startsWith("/")) {
|
||||
try {
|
||||
const url = new URL(conversationUrl);
|
||||
// If the conversation URL points to localhost but we're accessing from external,
|
||||
// use the browser's hostname with the conversation URL's port
|
||||
const urlHostname = url.hostname;
|
||||
const browserHostname =
|
||||
window.location.hostname ?? window.location.host?.split(":")[0];
|
||||
if (
|
||||
browserHostname &&
|
||||
(urlHostname === "localhost" || urlHostname === "127.0.0.1") &&
|
||||
browserHostname !== "localhost" &&
|
||||
browserHostname !== "127.0.0.1"
|
||||
) {
|
||||
return `${browserHostname}:${url.port}`;
|
||||
}
|
||||
return url.host; // e.g., "localhost:3000"
|
||||
} catch {
|
||||
return window.location.host;
|
||||
|
||||
@@ -36,6 +36,15 @@ vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
|
||||
useIsOnIntermediatePage: () => false,
|
||||
}));
|
||||
|
||||
// Mock useRevalidator from react-router to allow direct store manipulation
|
||||
// in tests instead of mocking useSelectedOrganizationId hook
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import the Zustand mock to enable automatic store resets
|
||||
vi.mock("zustand");
|
||||
|
||||
|
||||
@@ -84,6 +84,14 @@ class AppConversationInfoService(ABC):
|
||||
List of sub-conversation IDs
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def count_conversations_by_sandbox_id(self, sandbox_id: str) -> int:
|
||||
"""Count V1 conversations that reference the given sandbox.
|
||||
|
||||
Used to decide whether a sandbox can be safely deleted when a
|
||||
conversation is removed (only delete if count is 0).
|
||||
"""
|
||||
|
||||
# Mutators
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -77,8 +77,20 @@ class AppConversationService(ABC):
|
||||
id, starting a conversation, attaching a callback, and then running the
|
||||
conversation.
|
||||
|
||||
Yields an instance of AppConversationStartTask as updates occur, which can be used to determine
|
||||
the progress of the task.
|
||||
This method returns an async iterator that yields the same
|
||||
AppConversationStartTask repeatedly as status updates occur. Callers
|
||||
should iterate until the task reaches a terminal status::
|
||||
|
||||
async for task in service.start_app_conversation(request):
|
||||
if task.status in (
|
||||
AppConversationStartTaskStatus.READY,
|
||||
AppConversationStartTaskStatus.ERROR,
|
||||
):
|
||||
break
|
||||
|
||||
Status progression: WORKING → WAITING_FOR_SANDBOX → PREPARING_REPOSITORY
|
||||
→ RUNNING_SETUP_SCRIPT → SETTING_UP_GIT_HOOKS → SETTING_UP_SKILLS
|
||||
→ STARTING_CONVERSATION → READY (or ERROR at any point).
|
||||
"""
|
||||
# This is an abstract method - concrete implementations should provide real values
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
@@ -111,15 +123,21 @@ class AppConversationService(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_app_conversation(self, conversation_id: UUID) -> bool:
|
||||
async def delete_app_conversation(
|
||||
self, conversation_id: UUID, skip_agent_server_delete: bool = False
|
||||
) -> bool:
|
||||
"""Delete a V1 conversation and all its associated data.
|
||||
|
||||
Args:
|
||||
conversation_id: The UUID of the conversation to delete.
|
||||
skip_agent_server_delete: If True, skip the agent server DELETE call.
|
||||
This should be set when the sandbox is shared with other
|
||||
conversations (e.g. created via /new) to avoid destabilizing
|
||||
the shared runtime.
|
||||
|
||||
This method should:
|
||||
1. Delete the conversation from the database
|
||||
2. Call the agent server to delete the conversation
|
||||
2. Call the agent server to delete the conversation (unless skipped)
|
||||
3. Clean up any related data
|
||||
|
||||
Returns True if the conversation was deleted successfully, False otherwise.
|
||||
|
||||
@@ -1751,13 +1751,19 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
conversations = await self._build_app_conversations([info])
|
||||
return conversations[0]
|
||||
|
||||
async def delete_app_conversation(self, conversation_id: UUID) -> bool:
|
||||
async def delete_app_conversation(
|
||||
self, conversation_id: UUID, skip_agent_server_delete: bool = False
|
||||
) -> bool:
|
||||
"""Delete a V1 conversation and all its associated data.
|
||||
|
||||
This method will also cascade delete all sub-conversations of the parent.
|
||||
|
||||
Args:
|
||||
conversation_id: The UUID of the conversation to delete.
|
||||
skip_agent_server_delete: If True, skip the agent server DELETE call.
|
||||
This should be set when the sandbox is shared with other
|
||||
conversations (e.g. created via /new) to avoid destabilizing
|
||||
the shared runtime.
|
||||
"""
|
||||
# Check if we have the required SQL implementation for transactional deletion
|
||||
if not isinstance(
|
||||
@@ -1783,8 +1789,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
await self._delete_sub_conversations(conversation_id)
|
||||
|
||||
# Now delete the parent conversation
|
||||
# Delete from agent server if sandbox is running
|
||||
await self._delete_from_agent_server(app_conversation)
|
||||
# Delete from agent server if sandbox is running (skip if sandbox is shared)
|
||||
if not skip_agent_server_delete:
|
||||
await self._delete_from_agent_server(app_conversation)
|
||||
|
||||
# Delete from database using the conversation info from app_conversation
|
||||
# AppConversation extends AppConversationInfo, so we can use it directly
|
||||
|
||||
@@ -278,6 +278,14 @@ class SQLAppConversationInfoService(AppConversationInfoService):
|
||||
rows = result_set.scalars().all()
|
||||
return [UUID(row.conversation_id) for row in rows]
|
||||
|
||||
async def count_conversations_by_sandbox_id(self, sandbox_id: str) -> int:
|
||||
query = await self._secure_select()
|
||||
query = query.where(StoredConversationMetadata.sandbox_id == sandbox_id)
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
result = await self.db_session.execute(count_query)
|
||||
count = result.scalar()
|
||||
return count or 0
|
||||
|
||||
async def get_app_conversation_info(
|
||||
self, conversation_id: UUID
|
||||
) -> AppConversationInfo | None:
|
||||
|
||||
@@ -87,6 +87,19 @@ def get_default_web_url() -> str | None:
|
||||
return f'https://{web_host}'
|
||||
|
||||
|
||||
def get_default_permitted_cors_origins() -> list[str]:
|
||||
"""Get permitted CORS origins, falling back to legacy PERMITTED_CORS_ORIGINS env var.
|
||||
|
||||
The preferred configuration is via OH_PERMITTED_CORS_ORIGINS_0, _1, etc.
|
||||
(handled by the pydantic from_env parser). This fallback supports the legacy
|
||||
comma-separated PERMITTED_CORS_ORIGINS environment variable.
|
||||
"""
|
||||
legacy = os.getenv('PERMITTED_CORS_ORIGINS', '')
|
||||
if legacy:
|
||||
return [o.strip() for o in legacy.split(',') if o.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def get_openhands_provider_base_url() -> str | None:
|
||||
"""Return the base URL for the OpenHands provider, if configured."""
|
||||
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or None
|
||||
@@ -106,6 +119,14 @@ class AppServerConfig(OpenHandsModel):
|
||||
default_factory=get_default_web_url,
|
||||
description='The URL where OpenHands is running (e.g., http://localhost:3000)',
|
||||
)
|
||||
permitted_cors_origins: list[str] = Field(
|
||||
default_factory=get_default_permitted_cors_origins,
|
||||
description=(
|
||||
'Additional permitted CORS origins for both the app server and agent '
|
||||
'server containers. Configure via OH_PERMITTED_CORS_ORIGINS_0, _1, etc. '
|
||||
'Falls back to legacy PERMITTED_CORS_ORIGINS env var.'
|
||||
),
|
||||
)
|
||||
openhands_provider_base_url: str | None = Field(
|
||||
default_factory=get_openhands_provider_base_url,
|
||||
description='Base URL for the OpenHands provider',
|
||||
|
||||
@@ -27,7 +27,6 @@ from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_service import (
|
||||
ALLOW_CORS_ORIGINS_VARIABLE,
|
||||
SESSION_API_KEY_VARIABLE,
|
||||
WEBHOOK_CALLBACK_VARIABLE,
|
||||
SandboxService,
|
||||
@@ -91,6 +90,7 @@ class DockerSandboxService(SandboxService):
|
||||
httpx_client: httpx.AsyncClient
|
||||
max_num_sandboxes: int
|
||||
web_url: str | None = None
|
||||
permitted_cors_origins: list[str] = field(default_factory=list)
|
||||
extra_hosts: dict[str, str] = field(default_factory=dict)
|
||||
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
|
||||
startup_grace_seconds: int = STARTUP_GRACE_SECONDS
|
||||
@@ -386,8 +386,18 @@ class DockerSandboxService(SandboxService):
|
||||
# Set CORS origins for remote browser access when web_url is configured.
|
||||
# This allows the agent-server container to accept requests from the
|
||||
# frontend when running OpenHands on a remote machine.
|
||||
# Each origin gets its own indexed env var (OH_ALLOW_CORS_ORIGINS_0, _1, etc.)
|
||||
cors_origins: list[str] = []
|
||||
if self.web_url:
|
||||
env_vars[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url
|
||||
cors_origins.append(self.web_url)
|
||||
cors_origins.extend(self.permitted_cors_origins)
|
||||
# Deduplicate while preserving order
|
||||
seen: set[str] = set()
|
||||
for origin in cors_origins:
|
||||
if origin not in seen:
|
||||
seen.add(origin)
|
||||
idx = len(seen) - 1
|
||||
env_vars[f'OH_ALLOW_CORS_ORIGINS_{idx}'] = origin
|
||||
|
||||
# Prepare port mappings and add port environment variables
|
||||
# When using host network, container ports are directly accessible on the host
|
||||
@@ -621,7 +631,7 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
|
||||
get_sandbox_spec_service,
|
||||
)
|
||||
|
||||
# Get web_url from global config for CORS support
|
||||
# Get web_url and permitted_cors_origins from global config
|
||||
config = get_global_config()
|
||||
web_url = config.web_url
|
||||
|
||||
@@ -640,6 +650,7 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
|
||||
httpx_client=httpx_client,
|
||||
max_num_sandboxes=self.max_num_sandboxes,
|
||||
web_url=web_url,
|
||||
permitted_cors_origins=config.permitted_cors_origins,
|
||||
extra_hosts=self.extra_hosts,
|
||||
startup_grace_seconds=self.startup_grace_seconds,
|
||||
use_host_network=self.use_host_network,
|
||||
|
||||
@@ -46,6 +46,12 @@ RUN apt-get update && \
|
||||
(apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \
|
||||
# Install Docker dependencies
|
||||
apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \
|
||||
# Security upgrade: patch GLib CVE-2025-14087 (buffer underflow in GVariant parser)
|
||||
(apt-get install -y --no-install-recommends --only-upgrade \
|
||||
libglib2.0-0t64 libglib2.0-bin libglib2.0-dev libglib2.0-dev-bin || true) && \
|
||||
# Security upgrade: patch OpenSSL CVEs (CVE-2025-15467, CVE-2025-69419, CVE-2025-69421, et al.)
|
||||
(apt-get install -y --no-install-recommends --only-upgrade \
|
||||
openssl openssl-provider-legacy libssl3t64 || true) && \
|
||||
# Security upgrade: patch ImageMagick CVEs (CVE-2026-25897, CVE-2026-25968, CVE-2026-26284, et al.)
|
||||
(apt-get install -y --no-install-recommends --only-upgrade \
|
||||
imagemagick imagemagick-7-common imagemagick-7.q16 \
|
||||
@@ -357,6 +363,14 @@ RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Install VSCode extensions for build_from_scratch
|
||||
# (must be after setup_vscode_server and source file copy)
|
||||
# ================================================================
|
||||
{% if build_from_scratch %}
|
||||
{{ install_vscode_extensions() }}
|
||||
{% endif %}
|
||||
|
||||
# ================================================================
|
||||
# END: Build from versioned image
|
||||
# ================================================================
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# Tag: Legacy-V0
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlparse
|
||||
@@ -20,6 +20,8 @@ from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from openhands.app_server.config import get_global_config
|
||||
|
||||
|
||||
class LocalhostCORSMiddleware(CORSMiddleware):
|
||||
"""Custom CORS middleware that allows any request from localhost/127.0.0.1 domains,
|
||||
@@ -27,13 +29,8 @@ class LocalhostCORSMiddleware(CORSMiddleware):
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
allow_origins_str = os.getenv('PERMITTED_CORS_ORIGINS')
|
||||
if allow_origins_str:
|
||||
allow_origins = tuple(
|
||||
origin.strip() for origin in allow_origins_str.split(',')
|
||||
)
|
||||
else:
|
||||
allow_origins = ()
|
||||
config = get_global_config()
|
||||
allow_origins = tuple(config.permitted_cors_origins)
|
||||
super().__init__(
|
||||
app,
|
||||
allow_origins=allow_origins,
|
||||
@@ -51,6 +48,14 @@ class LocalhostCORSMiddleware(CORSMiddleware):
|
||||
if hostname in ['localhost', '127.0.0.1']:
|
||||
return True
|
||||
|
||||
# Allow any origin when no specific origins are configured (development mode)
|
||||
# WARNING: This disables CORS protection. Use explicit CORS origins in production.
|
||||
logging.getLogger(__name__).warning(
|
||||
f'No CORS origins configured, allowing origin: {origin}. '
|
||||
'Set OH_PERMITTED_CORS_ORIGINS for production environments.'
|
||||
)
|
||||
return True
|
||||
|
||||
# For missing origin or other origins, use the parent class's logic
|
||||
result: bool = super().is_allowed_origin(origin)
|
||||
return result
|
||||
|
||||
@@ -603,16 +603,28 @@ async def _try_delete_v1_conversation(
|
||||
)
|
||||
)
|
||||
if app_conversation_info:
|
||||
# Check if the sandbox is shared with other conversations
|
||||
# (e.g. multiple conversations can share a sandbox via /new).
|
||||
# If shared, skip the agent server DELETE call to avoid
|
||||
# destabilizing the runtime for the remaining conversations.
|
||||
sandbox_id = app_conversation_info.sandbox_id
|
||||
sandbox_is_shared = False
|
||||
if sandbox_id:
|
||||
conversation_count = await app_conversation_info_service.count_conversations_by_sandbox_id(
|
||||
sandbox_id
|
||||
)
|
||||
sandbox_is_shared = conversation_count > 1
|
||||
|
||||
# This is a V1 conversation, delete it using the app conversation service
|
||||
# Pass the conversation ID for secure deletion
|
||||
result = await app_conversation_service.delete_app_conversation(
|
||||
app_conversation_info.id
|
||||
app_conversation_info.id,
|
||||
skip_agent_server_delete=sandbox_is_shared,
|
||||
)
|
||||
|
||||
# Manually commit so that the conversation will vanish from the list
|
||||
await db_session.commit()
|
||||
|
||||
# Delete the sandbox in the background
|
||||
# Delete the sandbox in the background (checks remaining conversations first)
|
||||
asyncio.create_task(
|
||||
_finalize_delete_and_close_connections(
|
||||
sandbox_service,
|
||||
|
||||
8
poetry.lock
generated
8
poetry.lock
generated
@@ -11581,14 +11581,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.8.0"
|
||||
version = "6.9.1"
|
||||
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
|
||||
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
|
||||
{file = "pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f"},
|
||||
{file = "pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -14850,4 +14850,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "b4cfb64e0c5ecf27e45b41182ec190a3459f1e8833211cb883f92aa6ec9b6588"
|
||||
content-hash = "23e71d979b992aa0dd50a4cd14a4b6ed7d83dc24768b5987a2bc7d3b0e9dd996"
|
||||
|
||||
@@ -76,7 +76,7 @@ dependencies = [
|
||||
"pygithub>=2.5",
|
||||
"pyjwt>=2.12",
|
||||
"pylatexenc",
|
||||
"pypdf>=6.7.2",
|
||||
"pypdf>=6.9.1",
|
||||
"python-docx",
|
||||
"python-dotenv",
|
||||
"python-frontmatter>=1.1",
|
||||
@@ -224,7 +224,7 @@ python-docx = "*"
|
||||
bashlex = "^0.18"
|
||||
|
||||
# Explicitly pinned packages for latest versions
|
||||
pypdf = "^6.7.2"
|
||||
pypdf = "^6.9.1"
|
||||
pillow = "^12.1.1"
|
||||
starlette = "^0.49.1"
|
||||
urllib3 = "^2.6.3"
|
||||
|
||||
@@ -286,6 +286,54 @@ class TestSQLAppConversationInfoService:
|
||||
results = await service.batch_get_app_conversation_info([])
|
||||
assert results == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_conversations_by_sandbox_id(
|
||||
self,
|
||||
service: SQLAppConversationInfoService,
|
||||
):
|
||||
"""Test count by sandbox_id: only delete sandbox when no conversation uses it."""
|
||||
base_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
shared_sandbox = 'shared_sandbox_1'
|
||||
other_sandbox = 'other_sandbox'
|
||||
for i in range(3):
|
||||
info = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=None,
|
||||
sandbox_id=shared_sandbox,
|
||||
selected_repository='https://github.com/test/repo',
|
||||
selected_branch='main',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
title=f'Conversation {i}',
|
||||
trigger=ConversationTrigger.GUI,
|
||||
pr_number=[],
|
||||
llm_model='gpt-4',
|
||||
metrics=None,
|
||||
created_at=base_time,
|
||||
updated_at=base_time,
|
||||
)
|
||||
await service.save_app_conversation_info(info)
|
||||
for i in range(2):
|
||||
info = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=None,
|
||||
sandbox_id=other_sandbox,
|
||||
selected_repository='https://github.com/test/repo',
|
||||
selected_branch='main',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
title=f'Other {i}',
|
||||
trigger=ConversationTrigger.GUI,
|
||||
pr_number=[],
|
||||
llm_model='gpt-4',
|
||||
metrics=None,
|
||||
created_at=base_time,
|
||||
updated_at=base_time,
|
||||
)
|
||||
await service.save_app_conversation_info(info)
|
||||
|
||||
assert await service.count_conversations_by_sandbox_id(shared_sandbox) == 3
|
||||
assert await service.count_conversations_by_sandbox_id(other_sandbox) == 2
|
||||
assert await service.count_conversations_by_sandbox_id('no_such_sandbox') == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_conversation_info_no_filters(
|
||||
self,
|
||||
|
||||
@@ -1038,6 +1038,9 @@ async def test_delete_v1_conversation_success():
|
||||
return_value=mock_app_conversation_info
|
||||
)
|
||||
mock_service.delete_app_conversation = AsyncMock(return_value=True)
|
||||
mock_info_service.count_conversations_by_sandbox_id = AsyncMock(
|
||||
return_value=1
|
||||
)
|
||||
|
||||
# Call delete_conversation with V1 conversation ID
|
||||
result = await delete_conversation(
|
||||
@@ -1059,7 +1062,8 @@ async def test_delete_v1_conversation_success():
|
||||
|
||||
# Verify that delete_app_conversation was called with the conversation ID
|
||||
mock_service.delete_app_conversation.assert_called_once_with(
|
||||
conversation_uuid
|
||||
conversation_uuid,
|
||||
skip_agent_server_delete=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -1357,6 +1361,9 @@ async def test_delete_v1_conversation_with_agent_server():
|
||||
return_value=mock_app_conversation_info
|
||||
)
|
||||
mock_service.delete_app_conversation = AsyncMock(return_value=True)
|
||||
mock_info_service.count_conversations_by_sandbox_id = AsyncMock(
|
||||
return_value=1
|
||||
)
|
||||
|
||||
# Call delete_conversation with V1 conversation ID
|
||||
result = await delete_conversation(
|
||||
@@ -1378,7 +1385,8 @@ async def test_delete_v1_conversation_with_agent_server():
|
||||
|
||||
# Verify that delete_app_conversation was called with the conversation ID
|
||||
mock_service.delete_app_conversation.assert_called_once_with(
|
||||
conversation_uuid
|
||||
conversation_uuid,
|
||||
skip_agent_server_delete=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
@@ -21,34 +20,46 @@ def app():
|
||||
return app
|
||||
|
||||
|
||||
def test_localhost_cors_middleware_init_with_env_var():
|
||||
"""Test that the middleware correctly parses PERMITTED_CORS_ORIGINS environment variable."""
|
||||
with patch.dict(
|
||||
os.environ, {'PERMITTED_CORS_ORIGINS': 'https://example.com,https://test.com'}
|
||||
def test_localhost_cors_middleware_init_with_config():
|
||||
"""Test that the middleware correctly reads permitted_cors_origins from global config."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.permitted_cors_origins = [
|
||||
'https://example.com',
|
||||
'https://test.com',
|
||||
]
|
||||
with patch(
|
||||
'openhands.server.middleware.get_global_config', return_value=mock_config
|
||||
):
|
||||
app = FastAPI()
|
||||
middleware = LocalhostCORSMiddleware(app)
|
||||
|
||||
# Check that the origins were correctly parsed from the environment variable
|
||||
# Check that the origins were correctly read from the config
|
||||
assert 'https://example.com' in middleware.allow_origins
|
||||
assert 'https://test.com' in middleware.allow_origins
|
||||
assert len(middleware.allow_origins) == 2
|
||||
|
||||
|
||||
def test_localhost_cors_middleware_init_without_env_var():
|
||||
"""Test that the middleware works correctly without PERMITTED_CORS_ORIGINS environment variable."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
def test_localhost_cors_middleware_init_without_config():
|
||||
"""Test that the middleware works correctly without permitted_cors_origins configured."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.permitted_cors_origins = []
|
||||
with patch(
|
||||
'openhands.server.middleware.get_global_config', return_value=mock_config
|
||||
):
|
||||
app = FastAPI()
|
||||
middleware = LocalhostCORSMiddleware(app)
|
||||
|
||||
# Check that allow_origins is empty when no environment variable is set
|
||||
# Check that allow_origins is empty when no origins are configured
|
||||
assert middleware.allow_origins == ()
|
||||
|
||||
|
||||
def test_localhost_cors_middleware_is_allowed_origin_localhost(app):
|
||||
"""Test that localhost origins are allowed regardless of port when no specific origins are configured."""
|
||||
# Test without setting PERMITTED_CORS_ORIGINS to trigger localhost behavior
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
mock_config = MagicMock()
|
||||
mock_config.permitted_cors_origins = []
|
||||
with patch(
|
||||
'openhands.server.middleware.get_global_config', return_value=mock_config
|
||||
):
|
||||
app.add_middleware(LocalhostCORSMiddleware)
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -76,8 +87,11 @@ def test_localhost_cors_middleware_is_allowed_origin_localhost(app):
|
||||
|
||||
def test_localhost_cors_middleware_is_allowed_origin_non_localhost(app):
|
||||
"""Test that non-localhost origins follow the standard CORS rules."""
|
||||
# Set up the middleware with specific allowed origins
|
||||
with patch.dict(os.environ, {'PERMITTED_CORS_ORIGINS': 'https://example.com'}):
|
||||
mock_config = MagicMock()
|
||||
mock_config.permitted_cors_origins = ['https://example.com']
|
||||
with patch(
|
||||
'openhands.server.middleware.get_global_config', return_value=mock_config
|
||||
):
|
||||
app.add_middleware(LocalhostCORSMiddleware)
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -95,7 +109,11 @@ def test_localhost_cors_middleware_is_allowed_origin_non_localhost(app):
|
||||
|
||||
def test_localhost_cors_middleware_missing_origin(app):
|
||||
"""Test behavior when Origin header is missing."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
mock_config = MagicMock()
|
||||
mock_config.permitted_cors_origins = []
|
||||
with patch(
|
||||
'openhands.server.middleware.get_global_config', return_value=mock_config
|
||||
):
|
||||
app.add_middleware(LocalhostCORSMiddleware)
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -113,17 +131,22 @@ def test_localhost_cors_middleware_inheritance():
|
||||
|
||||
def test_localhost_cors_middleware_cors_parameters():
|
||||
"""Test that CORS parameters are set correctly in the middleware."""
|
||||
# We need to inspect the initialization parameters rather than attributes
|
||||
# since CORSMiddleware doesn't expose these as attributes
|
||||
with patch('fastapi.middleware.cors.CORSMiddleware.__init__') as mock_init:
|
||||
mock_init.return_value = None
|
||||
app = FastAPI()
|
||||
LocalhostCORSMiddleware(app)
|
||||
mock_config = MagicMock()
|
||||
mock_config.permitted_cors_origins = []
|
||||
with patch(
|
||||
'openhands.server.middleware.get_global_config', return_value=mock_config
|
||||
):
|
||||
# We need to inspect the initialization parameters rather than attributes
|
||||
# since CORSMiddleware doesn't expose these as attributes
|
||||
with patch('fastapi.middleware.cors.CORSMiddleware.__init__') as mock_init:
|
||||
mock_init.return_value = None
|
||||
app = FastAPI()
|
||||
LocalhostCORSMiddleware(app)
|
||||
|
||||
# Check that the parent class was initialized with the correct parameters
|
||||
mock_init.assert_called_once()
|
||||
_, kwargs = mock_init.call_args
|
||||
# Check that the parent class was initialized with the correct parameters
|
||||
mock_init.assert_called_once()
|
||||
_, kwargs = mock_init.call_args
|
||||
|
||||
assert kwargs['allow_credentials'] is True
|
||||
assert kwargs['allow_methods'] == ['*']
|
||||
assert kwargs['allow_headers'] == ['*']
|
||||
assert kwargs['allow_credentials'] is True
|
||||
assert kwargs['allow_methods'] == ['*']
|
||||
assert kwargs['allow_headers'] == ['*']
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -3842,7 +3842,7 @@ requires-dist = [
|
||||
{ name = "pygithub", specifier = ">=2.5" },
|
||||
{ name = "pyjwt", specifier = ">=2.12" },
|
||||
{ name = "pylatexenc" },
|
||||
{ name = "pypdf", specifier = ">=6.7.2" },
|
||||
{ name = "pypdf", specifier = ">=6.9.1" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-frontmatter", specifier = ">=1.1" },
|
||||
@@ -7373,11 +7373,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.8.0"
|
||||
version = "6.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/fb/dc2e8cb006e80b0020ed20d8649106fe4274e82d8e756ad3e24ade19c0df/pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d", size = 5311551, upload-time = "2026-03-17T10:46:07.876Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/f4/75543fa802b86e72f87e9395440fe1a89a6d149887e3e55745715c3352ac/pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f", size = 333661, upload-time = "2026-03-17T10:46:06.286Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user