mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 13:52:43 +08:00
Merge branch 'main' into ALL-2596/org-support
This commit is contained in:
commit
fc3dd517df
@ -22,6 +22,7 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
@ -164,8 +165,13 @@ class GithubManager(Manager):
|
||||
)
|
||||
|
||||
if await self.is_job_requested(message):
|
||||
payload = message.message.get('payload', {})
|
||||
user_id = payload['sender']['id']
|
||||
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
|
||||
user_id, ProviderType.GITHUB
|
||||
)
|
||||
github_view = await GithubFactory.create_github_view_from_payload(
|
||||
message, self.token_manager
|
||||
message, keycloak_user_id
|
||||
)
|
||||
logger.info(
|
||||
f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}'
|
||||
@ -282,8 +288,15 @@ class GithubManager(Manager):
|
||||
f'[Github]: Error summarizing issue solvability: {str(e)}'
|
||||
)
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
github_view.user_info.keycloak_user_id, self.token_manager
|
||||
)
|
||||
|
||||
await github_view.create_new_conversation(
|
||||
self.jinja_env, secret_store.provider_tokens, convo_metadata
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
convo_metadata,
|
||||
saas_user_auth,
|
||||
)
|
||||
|
||||
conversation_id = github_view.conversation_id
|
||||
@ -292,14 +305,7 @@ class GithubManager(Manager):
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, github_view.user_info.keycloak_user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
if metadata.conversation_version != 'v1':
|
||||
if not github_view.v1:
|
||||
# Create a GithubCallbackProcessor
|
||||
processor = GithubCallbackProcessor(
|
||||
github_view=github_view,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from github import Github, GithubIntegration
|
||||
@ -8,6 +9,7 @@ from integrations.github.github_types import (
|
||||
WorkflowRunStatus,
|
||||
)
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
@ -17,7 +19,6 @@ from integrations.utils import (
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from pydantic.dataclasses import dataclass
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
@ -34,18 +35,16 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.sdk.conversation.secret_source import SecretSource
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
@ -55,52 +54,6 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
class GithubUserContext(UserContext):
|
||||
"""User context for GitHub integration that provides user info without web request."""
|
||||
|
||||
def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE):
|
||||
self.keycloak_user_id = keycloak_user_id
|
||||
self.git_provider_tokens = git_provider_tokens
|
||||
self.settings_store = SaasSettingsStore(
|
||||
user_id=self.keycloak_user_id,
|
||||
session_maker=session_maker,
|
||||
config=get_config(),
|
||||
)
|
||||
|
||||
self.secrets_store = SaasSecretsStore(
|
||||
self.keycloak_user_id, session_maker, get_config()
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.keycloak_user_id
|
||||
|
||||
async def get_user_info(self) -> UserInfo:
|
||||
user_settings = await self.settings_store.load()
|
||||
return UserInfo(
|
||||
id=self.keycloak_user_id,
|
||||
**user_settings.model_dump(context={'expose_secrets': True}),
|
||||
)
|
||||
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
# This would need to be implemented based on the git provider tokens
|
||||
# For now, return a basic HTTPS URL
|
||||
return f'https://github.com/{repository}.git'
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
# Return the appropriate token from git_provider_tokens
|
||||
if provider_type == ProviderType.GITHUB and self.git_provider_tokens:
|
||||
return self.git_provider_tokens.get(ProviderType.GITHUB)
|
||||
return None
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
# Return empty dict for now - GitHub integration handles secrets separately
|
||||
user_secrets = await self.secrets_store.load()
|
||||
return dict(user_secrets.custom_secrets) if user_secrets else {}
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's proactive conversation setting.
|
||||
|
||||
@ -134,7 +87,7 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
return settings.enable_proactive_conversation_starters
|
||||
|
||||
|
||||
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
async def get_user_v1_enabled_setting(user_id: str) -> bool:
|
||||
"""Get the user's V1 conversation API setting.
|
||||
|
||||
Args:
|
||||
@ -143,11 +96,6 @@ async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
Returns:
|
||||
True if V1 conversations are enabled for this user, False otherwise
|
||||
"""
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
@ -183,6 +131,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
title: str
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
v1: bool
|
||||
|
||||
async def _load_resolver_context(self):
|
||||
github_service = GithubServiceImpl(
|
||||
@ -229,6 +178,19 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
return ConversationMetadata(
|
||||
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
@ -245,14 +207,17 @@ class GithubIssue(ResolverViewInterface):
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
try:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
return
|
||||
|
||||
@ -271,6 +236,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the legacy V0 system."""
|
||||
logger.info('[GitHub V1]: Creating V0 conversation')
|
||||
custom_secrets = await self._get_user_secrets()
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
@ -292,10 +258,12 @@ class GithubIssue(ResolverViewInterface):
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
saas_user_auth: UserAuth,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitHub V1]: Creating V1 conversation')
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
@ -326,10 +294,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitHub user context for the V1 system
|
||||
github_user_context = GithubUserContext(
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
)
|
||||
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@ -344,6 +309,8 @@ class GithubIssue(ResolverViewInterface):
|
||||
f'Failed to start V1 conversation: {task.detail}'
|
||||
)
|
||||
|
||||
self.v1 = True
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from openhands.app_server.event_callback.github_v1_callback_processor import (
|
||||
@ -806,7 +773,7 @@ class GithubFactory:
|
||||
|
||||
@staticmethod
|
||||
async def create_github_view_from_payload(
|
||||
message: Message, token_manager: TokenManager
|
||||
message: Message, keycloak_user_id: str
|
||||
) -> ResolverViewInterface:
|
||||
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
|
||||
Also return metadata about the event (e.g., action type).
|
||||
@ -816,17 +783,10 @@ class GithubFactory:
|
||||
user_id = payload['sender']['id']
|
||||
username = payload['sender']['login']
|
||||
|
||||
keyloak_user_id = await token_manager.get_user_id_from_idp_user_id(
|
||||
user_id, ProviderType.GITHUB
|
||||
)
|
||||
|
||||
if keyloak_user_id is None:
|
||||
logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ')
|
||||
|
||||
selected_repo = GithubFactory.get_full_repo_name(repo_obj)
|
||||
is_public_repo = not repo_obj.get('private', True)
|
||||
user_info = UserData(
|
||||
user_id=user_id, username=username, keycloak_user_id=keyloak_user_id
|
||||
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
|
||||
)
|
||||
|
||||
installation_id = message.message['installation']
|
||||
@ -850,6 +810,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_issue_comment(message):
|
||||
@ -875,6 +836,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_pr_comment(message):
|
||||
@ -916,6 +878,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_inline_pr_comment(message):
|
||||
@ -949,6 +912,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
63
enterprise/integrations/resolver_context.py
Normal file
63
enterprise/integrations/resolver_context.py
Normal file
@ -0,0 +1,63 @@
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class ResolverUserContext(UserContext):
|
||||
"""User context for resolver operations that inherits from UserContext."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
self.saas_user_auth = saas_user_auth
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return await self.saas_user_auth.get_user_id()
|
||||
|
||||
async def get_user_info(self) -> UserInfo:
|
||||
user_settings = await self.saas_user_auth.get_user_settings()
|
||||
user_id = await self.saas_user_auth.get_user_id()
|
||||
if user_settings:
|
||||
return UserInfo(
|
||||
id=user_id,
|
||||
**user_settings.model_dump(context={'expose_secrets': True}),
|
||||
)
|
||||
|
||||
return UserInfo(id=user_id)
|
||||
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
# This would need to be implemented based on the git provider tokens
|
||||
# For now, return a basic HTTPS URL
|
||||
return f'https://github.com/{repository}.git'
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
# Return the appropriate token from git_provider_tokens
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if provider_tokens:
|
||||
return provider_tokens.get(provider_type)
|
||||
return None
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
return await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
"""Get secrets for the user, including custom secrets."""
|
||||
secrets = await self.saas_user_auth.get_secrets()
|
||||
if secrets:
|
||||
# Convert custom secrets to StaticSecret objects for SDK compatibility
|
||||
# secrets.custom_secrets is of type Mapping[str, CustomSecret]
|
||||
converted_secrets = {}
|
||||
for key, custom_secret in secrets.custom_secrets.items():
|
||||
# Extract the secret value from CustomSecret and convert to StaticSecret
|
||||
secret_value = custom_secret.secret.get_secret_value()
|
||||
converted_secrets[key] = StaticSecret(value=secret_value)
|
||||
return converted_secrets
|
||||
return {}
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
return await self.saas_user_auth.get_mcp_api_key()
|
||||
@ -19,7 +19,7 @@ class PRStatus(Enum):
|
||||
class UserData(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
keycloak_user_id: str | None
|
||||
keycloak_user_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
20
enterprise/integrations/v1_utils.py
Normal file
20
enterprise/integrations/v1_utils.py
Normal file
@ -0,0 +1,20 @@
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
async def get_saas_user_auth(
|
||||
keycloak_user_id: str, token_manager: TokenManager
|
||||
) -> UserAuth:
|
||||
offline_token = await token_manager.load_offline_token(keycloak_user_id)
|
||||
if offline_token is None:
|
||||
logger.info('no_offline_token_found')
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
user_id=keycloak_user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
)
|
||||
return user_auth
|
||||
@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
@ -58,7 +59,8 @@ async def github_events(
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await request.body()
|
||||
# Add timeout to prevent hanging on slow/stalled clients
|
||||
payload = await asyncio.wait_for(request.body(), timeout=15.0)
|
||||
verify_github_signature(payload, x_hub_signature_256)
|
||||
|
||||
payload_data = await request.json()
|
||||
@ -78,6 +80,12 @@ async def github_events(
|
||||
status_code=200,
|
||||
content={'message': 'GitHub events endpoint reached successfully.'},
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning('GitHub webhook request timed out waiting for request body')
|
||||
return JSONResponse(
|
||||
status_code=408,
|
||||
content={'error': 'Request timeout - client took too long to send data.'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f'Error processing GitHub event: {e}')
|
||||
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
|
||||
|
||||
133
enterprise/tests/unit/integrations/test_resolver_context.py
Normal file
133
enterprise/tests/unit/integrations/test_resolver_context.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""Test for ResolverUserContext get_secrets conversion logic.
|
||||
|
||||
This test focuses on testing the actual ResolverUserContext implementation.
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from enterprise.integrations.resolver_context import ResolverUserContext
|
||||
|
||||
# Import the real classes we want to test
|
||||
from openhands.integrations.provider import CustomSecret
|
||||
|
||||
# Import the SDK types we need for testing
|
||||
from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_saas_user_auth():
|
||||
"""Mock SaasUserAuth for testing."""
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver_context(mock_saas_user_auth):
|
||||
"""Create a ResolverUserContext instance for testing."""
|
||||
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
|
||||
|
||||
|
||||
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
|
||||
"""Helper to create CustomSecret instances."""
|
||||
return CustomSecret(secret=SecretStr(value), description=description)
|
||||
|
||||
|
||||
def create_secrets(custom_secrets_dict: dict[str, CustomSecret]) -> Secrets:
|
||||
"""Helper to create Secrets instances."""
|
||||
return Secrets(custom_secrets=MappingProxyType(custom_secrets_dict))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_secrets_converts_custom_to_static(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that get_secrets correctly converts CustomSecret objects to StaticSecret objects."""
|
||||
# Arrange
|
||||
secrets = create_secrets(
|
||||
{
|
||||
'TEST_SECRET_1': create_custom_secret('secret_value_1'),
|
||||
'TEST_SECRET_2': create_custom_secret('secret_value_2'),
|
||||
}
|
||||
)
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(secret, StaticSecret) for secret in result.values())
|
||||
assert result['TEST_SECRET_1'].value.get_secret_value() == 'secret_value_1'
|
||||
assert result['TEST_SECRET_2'].value.get_secret_value() == 'secret_value_2'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_secrets_with_special_characters(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that secret values with special characters are preserved during conversion."""
|
||||
# Arrange
|
||||
special_value = 'very_secret_password_123!@#$%^&*()'
|
||||
secrets = create_secrets({'SPECIAL_SECRET': create_custom_secret(special_value)})
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert isinstance(result['SPECIAL_SECRET'], StaticSecret)
|
||||
assert result['SPECIAL_SECRET'].value.get_secret_value() == special_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'secrets_input,expected_result',
|
||||
[
|
||||
(None, {}), # No secrets available
|
||||
(create_secrets({}), {}), # Empty custom secrets
|
||||
],
|
||||
)
|
||||
async def test_get_secrets_empty_cases(
|
||||
resolver_context, mock_saas_user_auth, secrets_input, expected_result
|
||||
):
|
||||
"""Test that get_secrets handles empty cases correctly."""
|
||||
# Arrange
|
||||
mock_saas_user_auth.get_secrets.return_value = secrets_input
|
||||
|
||||
# Act
|
||||
result = await resolver_context.get_secrets()
|
||||
|
||||
# Assert
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
def test_static_secret_is_valid_secret_source():
|
||||
"""Test that StaticSecret is a valid SecretSource for SDK validation."""
|
||||
# Arrange & Act
|
||||
static_secret = StaticSecret(value='test_secret_123')
|
||||
|
||||
# Assert
|
||||
assert isinstance(static_secret, StaticSecret)
|
||||
assert isinstance(static_secret, SecretSource)
|
||||
assert static_secret.value.get_secret_value() == 'test_secret_123'
|
||||
|
||||
|
||||
def test_custom_to_static_conversion():
|
||||
"""Test the complete conversion flow from CustomSecret to StaticSecret."""
|
||||
# Arrange
|
||||
secret_value = 'conversion_test_secret'
|
||||
custom_secret = create_custom_secret(secret_value, 'Conversion test')
|
||||
|
||||
# Act - simulate the conversion logic from the actual method
|
||||
extracted_value = custom_secret.secret.get_secret_value()
|
||||
static_secret = StaticSecret(value=extracted_value)
|
||||
|
||||
# Assert
|
||||
assert isinstance(static_secret, StaticSecret)
|
||||
assert isinstance(static_secret, SecretSource)
|
||||
assert static_secret.value.get_secret_value() == secret_value
|
||||
@ -1,6 +1,7 @@
|
||||
from unittest import TestCase, mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import UserData
|
||||
@ -114,8 +115,10 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
title='Test Issue',
|
||||
description='Test issue description',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
@ -144,6 +147,7 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
)
|
||||
mock_create_v1.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
@ -172,6 +176,7 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
)
|
||||
mock_create_v0.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
|
||||
@ -61,7 +61,7 @@ describe("ExpandableMessage", () => {
|
||||
expect(icon).toHaveClass("fill-success");
|
||||
});
|
||||
|
||||
it("should render with error icon for failed action messages", () => {
|
||||
it("should render with no icon for failed action messages", () => {
|
||||
renderWithProviders(
|
||||
<ExpandableMessage
|
||||
id="OBSERVATION_MESSAGE$RUN"
|
||||
@ -75,8 +75,7 @@ describe("ExpandableMessage", () => {
|
||||
"div.flex.gap-2.items-center.justify-start",
|
||||
);
|
||||
expect(container).toHaveClass("border-neutral-300");
|
||||
const icon = screen.getByTestId("status-icon");
|
||||
expect(icon).toHaveClass("fill-danger");
|
||||
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with neutral border and no icon for action messages without success prop", () => {
|
||||
|
||||
@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
|
||||
describe("AnalyticsConsentFormModal", () => {
|
||||
it("should call saveUserSettings with consent", async () => {
|
||||
|
||||
@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
@ -404,7 +404,7 @@ describe("RepoConnector", () => {
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => { })); // Never resolves to keep loading state
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
"retrieveUserGitRepositories",
|
||||
|
||||
@ -3,7 +3,7 @@ import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
|
||||
// These tests will now fail because the conversation panel is rendered through a portal
|
||||
// and technically not a child of the Sidebar component.
|
||||
|
||||
@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen } from "@testing-library/react";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useBrowserStore } from "#/stores/browser-store";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import {
|
||||
createMockMessageEvent,
|
||||
createMockUserMessageEvent,
|
||||
createMockAgentErrorEvent,
|
||||
createMockBrowserObservationEvent,
|
||||
createMockBrowserNavigateActionEvent,
|
||||
createMockExecuteBashActionEvent,
|
||||
createMockExecuteBashObservationEvent,
|
||||
} from "#/mocks/mock-ws-helpers";
|
||||
import {
|
||||
ConnectionStatusComponent,
|
||||
@ -461,7 +475,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
function HistoryLoadingComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
@ -474,7 +488,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
<div data-testid="expected-event-count">{expectedEventCount}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
@ -484,7 +498,9 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"true",
|
||||
);
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
@ -523,7 +539,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
function HistoryLoadingComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
@ -533,7 +549,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
@ -583,7 +599,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
function HistoryLoadingComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
@ -595,7 +611,7 @@ describe("Conversation WebSocket Handler", () => {
|
||||
<div data-testid="events-received">{events.length}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
@ -605,7 +621,9 @@ describe("Conversation WebSocket Handler", () => {
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"true",
|
||||
);
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
@ -621,17 +639,133 @@ describe("Conversation WebSocket Handler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
// 9. Browser State Tests (BrowserObservation)
|
||||
describe("Browser State Integration", () => {
|
||||
beforeEach(() => {
|
||||
useBrowserStore.getState().reset();
|
||||
});
|
||||
|
||||
it("should update browser store with screenshot when BrowserObservation event is received", async () => {
|
||||
// Create a mock BrowserObservation event with screenshot data
|
||||
const mockBrowserObsEvent = createMockBrowserObservationEvent(
|
||||
"base64-screenshot-data",
|
||||
"Page loaded successfully",
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBrowserObsEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the browser store to be updated with screenshot
|
||||
await waitFor(() => {
|
||||
const { screenshotSrc } = useBrowserStore.getState();
|
||||
expect(screenshotSrc).toBe(
|
||||
"data:image/png;base64,base64-screenshot-data",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update browser store with URL when BrowserNavigateAction followed by BrowserObservation", async () => {
|
||||
// Create mock events - action first, then observation
|
||||
const mockBrowserActionEvent = createMockBrowserNavigateActionEvent(
|
||||
"https://example.com/test-page",
|
||||
);
|
||||
const mockBrowserObsEvent = createMockBrowserObservationEvent(
|
||||
"base64-screenshot-data",
|
||||
"Page loaded successfully",
|
||||
);
|
||||
|
||||
// Set up MSW to send both events when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send action first, then observation
|
||||
client.send(JSON.stringify(mockBrowserActionEvent));
|
||||
client.send(JSON.stringify(mockBrowserObsEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the browser store to be updated with both screenshot and URL
|
||||
await waitFor(() => {
|
||||
const { screenshotSrc, url } = useBrowserStore.getState();
|
||||
expect(screenshotSrc).toBe(
|
||||
"data:image/png;base64,base64-screenshot-data",
|
||||
);
|
||||
expect(url).toBe("https://example.com/test-page");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update browser store when BrowserObservation has no screenshot data", async () => {
|
||||
const initialScreenshot = useBrowserStore.getState().screenshotSrc;
|
||||
|
||||
// Create a mock BrowserObservation event WITHOUT screenshot data
|
||||
const mockBrowserObsEvent = createMockBrowserObservationEvent(
|
||||
null, // no screenshot
|
||||
"Browser action completed",
|
||||
);
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBrowserObsEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Give some time for any potential updates
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
// Screenshot should remain unchanged (empty/initial value)
|
||||
const { screenshotSrc } = useBrowserStore.getState();
|
||||
expect(screenshotSrc).toBe(initialScreenshot);
|
||||
});
|
||||
});
|
||||
|
||||
// 10. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
beforeEach(() => {
|
||||
useCommandStore.getState().clearTerminal();
|
||||
});
|
||||
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
// Create a mock ExecuteBashAction event
|
||||
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
|
||||
|
||||
@ -667,14 +801,6 @@ describe("Conversation WebSocket Handler", () => {
|
||||
});
|
||||
|
||||
it("should append output to store when ExecuteBashObservation event is received", async () => {
|
||||
const { createMockExecuteBashObservationEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashObservation event
|
||||
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
describe("useSaveSettings", () => {
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
/**
|
||||
* TODO: Fix flaky WebSocket tests (https://github.com/OpenHands/OpenHands/issues/11944)
|
||||
*
|
||||
* Several tests in this file are skipped because they fail intermittently in CI
|
||||
* but pass locally. The SUSPECTED root cause is that `wsLink.broadcast()` sends messages
|
||||
* to ALL connected clients across all tests, causing cross-test contamination
|
||||
* when tests run in parallel with Vitest v4.
|
||||
*/
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
describe,
|
||||
@ -51,7 +59,7 @@ describe("useWebSocket", () => {
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle incoming messages correctly", async () => {
|
||||
it.skip("should handle incoming messages correctly", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Wait for connection to be established
|
||||
@ -114,7 +122,7 @@ describe("useWebSocket", () => {
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should close the WebSocket connection on unmount", async () => {
|
||||
it.skip("should close the WebSocket connection on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws"),
|
||||
);
|
||||
@ -204,7 +212,7 @@ describe("useWebSocket", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onMessage handler when WebSocket receives a message", async () => {
|
||||
it.skip("should call onMessage handler when WebSocket receives a message", async () => {
|
||||
const onMessageSpy = vi.fn();
|
||||
const options = { onMessage: onMessageSpy };
|
||||
|
||||
@ -271,7 +279,7 @@ describe("useWebSocket", () => {
|
||||
expect(onErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should provide sendMessage function to send messages to WebSocket", async () => {
|
||||
it.skip("should provide sendMessage function to send messages to WebSocket", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Wait for connection to be established
|
||||
|
||||
@ -10,7 +10,7 @@ import MainApp from "#/routes/root-layout";
|
||||
import i18n from "#/i18n";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
describe("frontend/routes/_oh", () => {
|
||||
|
||||
@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AppSettingsScreen from "#/routes/app-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
|
||||
@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
|
||||
@ -6,7 +6,7 @@ import { createRoutesStub } from "react-router";
|
||||
import { createAxiosNotFoundErrorObject } from "test-utils";
|
||||
import HomeScreen from "#/routes/home";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import MainApp from "#/routes/root-layout";
|
||||
|
||||
@ -3,13 +3,14 @@ import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import {
|
||||
MOCK_DEFAULT_USER_SETTINGS,
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
@ -255,6 +256,210 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it.todo("should render an indicator if the llm api key is set");
|
||||
|
||||
describe("API key visibility in Basic Settings", () => {
|
||||
it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Verify OpenHands is selected by default
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
});
|
||||
|
||||
// API key input should not be visible when OpenHands provider is selected in SaaS mode
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Select OpenAI provider
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// API key input should be visible when non-OpenHands provider is selected in SaaS mode
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Verify OpenHands is selected by default
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
});
|
||||
|
||||
// API key input should be visible when OSS mode is enabled (even with OpenHands provider)
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Select OpenAI provider
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// API key input should be visible when OSS mode is enabled
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Start with OpenAI provider
|
||||
await userEvent.click(provider);
|
||||
const openAIOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(openAIOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// API key input should be visible with OpenAI
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Switch to OpenHands provider
|
||||
await userEvent.click(provider);
|
||||
const openHandsOption = screen.getByText("OpenHands");
|
||||
await userEvent.click(openHandsOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
});
|
||||
|
||||
// API key input should now be hidden
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
const provider = within(basicForm).getByTestId("llm-provider-input");
|
||||
|
||||
// Verify OpenHands is selected by default
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
});
|
||||
|
||||
// API key input should be hidden with OpenHands
|
||||
expect(
|
||||
within(basicForm).queryByTestId("llm-api-key-input"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Switch to OpenAI provider
|
||||
await userEvent.click(provider);
|
||||
const openAIOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(openAIOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// API key input should now be visible
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-input"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
|
||||
@ -6,7 +6,7 @@ import { createRoutesStub, Outlet } from "react-router";
|
||||
import SecretsSettingsScreen from "#/routes/secrets-settings";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
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";
|
||||
|
||||
|
||||
433
frontend/package-lock.json
generated
433
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,17 +11,17 @@
|
||||
"@heroui/use-infinite-scroll": "^2.2.12",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@posthog/react": "^1.5.0",
|
||||
"@react-router/node": "^7.9.6",
|
||||
"@react-router/serve": "^7.9.6",
|
||||
"@posthog/react": "^1.5.2",
|
||||
"@react-router/node": "^7.10.1",
|
||||
"@react-router/serve": "^7.10.1",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.13.2",
|
||||
@ -31,22 +31,22 @@
|
||||
"downshift": "^9.0.12",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.25",
|
||||
"i18next": "^25.7.1",
|
||||
"i18next": "^25.7.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.32",
|
||||
"jose": "^6.1.3",
|
||||
"lucide-react": "^0.555.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"posthog-js": "^1.299.0",
|
||||
"lucide-react": "^0.556.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"posthog-js": "^1.302.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-i18next": "^16.4.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.6",
|
||||
"react-router": "^7.10.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@ -54,7 +54,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"vite": "^7.2.6",
|
||||
"vite": "^7.2.7",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2",
|
||||
"zustand": "^5.0.9"
|
||||
@ -97,7 +97,7 @@
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@react-router/dev": "^7.9.6",
|
||||
"@react-router/dev": "^7.10.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
@ -127,7 +127,7 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.2.0",
|
||||
"jsdom": "^27.3.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.7.3",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { openHands } from "../api/open-hands-axios";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings } from "./settings.types";
|
||||
|
||||
/**
|
||||
@ -22,6 +22,13 @@ const getCommandObservationContent = (
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
const command = event.observation === "run" ? event.extras.command : null;
|
||||
|
||||
if (command) {
|
||||
return `Command:\n\`\`\`sh\n${command}\n\`\`\`\n\nOutput:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
|
||||
}
|
||||
|
||||
return `Output:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
|
||||
};
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import ArrowDown from "#/icons/angle-down-solid.svg?react";
|
||||
import ArrowUp from "#/icons/angle-up-solid.svg?react";
|
||||
import CheckCircle from "#/icons/check-circle-solid.svg?react";
|
||||
import XCircle from "#/icons/x-circle-solid.svg?react";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import { cn } from "#/utils/utils";
|
||||
@ -169,19 +168,12 @@ export function ExpandableMessage({
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
{type === "action" && success !== undefined && (
|
||||
{type === "action" && success && (
|
||||
<span className="flex-shrink-0">
|
||||
{success ? (
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-success")}
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-danger")}
|
||||
/>
|
||||
)}
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-success")}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { FaClock } from "react-icons/fa";
|
||||
import CheckCircle from "#/icons/check-circle-solid.svg?react";
|
||||
import XCircle from "#/icons/x-circle-solid.svg?react";
|
||||
import { ObservationResultStatus } from "./event-content-helpers/get-observation-result";
|
||||
|
||||
interface SuccessIndicatorProps {
|
||||
@ -17,13 +16,6 @@ export function SuccessIndicator({ status }: SuccessIndicatorProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<XCircle
|
||||
data-testid="status-icon"
|
||||
className="h-4 w-4 ml-2 inline fill-danger"
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "timeout" && (
|
||||
<FaClock
|
||||
data-testid="status-icon"
|
||||
|
||||
@ -19,8 +19,10 @@ import {
|
||||
} from "#/state/conversation-store";
|
||||
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
export function ConversationTabs() {
|
||||
const { conversationId } = useConversationId();
|
||||
const {
|
||||
selectedTab,
|
||||
isRightPanelShown,
|
||||
@ -30,18 +32,21 @@ export function ConversationTabs() {
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// Persist selectedTab and isRightPanelShown in localStorage
|
||||
// Persist selectedTab and isRightPanelShown in localStorage per conversation
|
||||
const [persistedSelectedTab, setPersistedSelectedTab] =
|
||||
useLocalStorage<ConversationTab | null>(
|
||||
"conversation-selected-tab",
|
||||
`conversation-selected-tab-${conversationId}`,
|
||||
"editor",
|
||||
);
|
||||
|
||||
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
|
||||
useLocalStorage<boolean>("conversation-right-panel-shown", true);
|
||||
useLocalStorage<boolean>(
|
||||
`conversation-right-panel-shown-${conversationId}`,
|
||||
true,
|
||||
);
|
||||
|
||||
const [persistedUnpinnedTabs] = useLocalStorage<string[]>(
|
||||
"conversation-unpinned-tabs",
|
||||
`conversation-unpinned-tabs-${conversationId}`,
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getObservationContent } from "../get-observation-content";
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import { BrowserObservation } from "#/types/v1/core/base/observation";
|
||||
|
||||
describe("getObservationContent - BrowserObservation", () => {
|
||||
it("should return output content when available", () => {
|
||||
const mockEvent: ObservationEvent<BrowserObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output: "Browser action completed",
|
||||
error: null,
|
||||
screenshot_data: "base64data",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
expect(result).toContain("**Output:**");
|
||||
expect(result).toContain("Browser action completed");
|
||||
});
|
||||
|
||||
it("should handle error cases properly", () => {
|
||||
const mockEvent: ObservationEvent<BrowserObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output: "",
|
||||
error: "Browser action failed",
|
||||
screenshot_data: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
expect(result).toContain("**Error:**");
|
||||
expect(result).toContain("Browser action failed");
|
||||
});
|
||||
|
||||
it("should provide default message when no output or error", () => {
|
||||
const mockEvent: ObservationEvent<BrowserObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output: "",
|
||||
error: null,
|
||||
screenshot_data: "base64data",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
expect(result).toBe("Browser action completed successfully.");
|
||||
});
|
||||
|
||||
it("should return output when screenshot_data is null", () => {
|
||||
const mockEvent: ObservationEvent<BrowserObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output: "Page loaded successfully",
|
||||
error: null,
|
||||
screenshot_data: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
expect(result).toBe("**Output:**\nPage loaded successfully");
|
||||
});
|
||||
});
|
||||
@ -98,14 +98,16 @@ const getBrowserObservationContent = (
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
: observation.output || "";
|
||||
|
||||
let contentDetails = "";
|
||||
|
||||
if ("is_error" in observation && observation.is_error) {
|
||||
contentDetails += `**Error:**\n${textContent}`;
|
||||
} else {
|
||||
if (observation.error) {
|
||||
contentDetails += `**Error:**\n${observation.error}`;
|
||||
} else if (textContent) {
|
||||
contentDetails += `**Output:**\n${textContent}`;
|
||||
} else {
|
||||
contentDetails += "Browser action completed successfully.";
|
||||
}
|
||||
|
||||
if (contentDetails.length > MAX_CONTENT_LENGTH) {
|
||||
|
||||
@ -14,6 +14,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { useBrowserStore } from "#/stores/browser-store";
|
||||
import {
|
||||
isV1Event,
|
||||
isAgentErrorEvent,
|
||||
@ -27,6 +28,8 @@ import {
|
||||
isExecuteBashObservationEvent,
|
||||
isConversationErrorEvent,
|
||||
isPlanningFileEditorObservationEvent,
|
||||
isBrowserObservationEvent,
|
||||
isBrowserNavigateActionEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { ConversationStateUpdateEventStats } from "#/types/v1/core/events/conversation-state-event";
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
@ -383,6 +386,22 @@ export function ConversationWebSocketProvider({
|
||||
.join("\n");
|
||||
appendOutput(textContent);
|
||||
}
|
||||
|
||||
// Handle BrowserObservation events - update browser store with screenshot
|
||||
if (isBrowserObservationEvent(event)) {
|
||||
const { screenshot_data: screenshotData } = event.observation;
|
||||
if (screenshotData) {
|
||||
const screenshotSrc = screenshotData.startsWith("data:")
|
||||
? screenshotData
|
||||
: `data:image/png;base64,${screenshotData}`;
|
||||
useBrowserStore.getState().setScreenshotSrc(screenshotSrc);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle BrowserNavigateAction events - update browser store with URL
|
||||
if (isBrowserNavigateActionEvent(event)) {
|
||||
useBrowserStore.getState().setUrl(event.action.url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
@ -57,6 +57,7 @@ export function useAddMcpServer() {
|
||||
|
||||
const apiSettings = {
|
||||
mcp_config: newConfig,
|
||||
v1_enabled: settings.V1_ENABLED,
|
||||
};
|
||||
|
||||
await SettingsService.saveSettings(apiSettings);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
|
||||
export function useDeleteMcpServer() {
|
||||
@ -25,6 +25,7 @@ export function useDeleteMcpServer() {
|
||||
|
||||
const apiSettings = {
|
||||
mcp_config: newConfig,
|
||||
v1_enabled: settings.V1_ENABLED,
|
||||
};
|
||||
|
||||
await SettingsService.saveSettings(apiSettings);
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { PostSettings } from "#/types/settings";
|
||||
import { PostApiSettings } from "#/settings-service/settings.types";
|
||||
import { PostApiSettings } from "#/api/settings-service/settings.types";
|
||||
import { useSettings } from "../query/use-settings";
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
@ -59,6 +59,7 @@ export function useUpdateMcpServer() {
|
||||
|
||||
const apiSettings = {
|
||||
mcp_config: newConfig,
|
||||
v1_enabled: settings.V1_ENABLED,
|
||||
};
|
||||
|
||||
await SettingsService.saveSettings(apiSettings);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
@ -7,7 +7,7 @@ import { Provider } from "#/types/settings";
|
||||
import {
|
||||
ApiSettings,
|
||||
PostApiSettings,
|
||||
} from "#/settings-service/settings.types";
|
||||
} from "#/api/settings-service/settings.types";
|
||||
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
|
||||
import { GitUser } from "#/types/git";
|
||||
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
|
||||
|
||||
@ -184,3 +184,55 @@ export const createMockExecuteBashObservationEvent = (
|
||||
},
|
||||
action_id: "bash-action-123",
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock BrowserObservation event for testing browser state handling
|
||||
*/
|
||||
export const createMockBrowserObservationEvent = (
|
||||
screenshotData: string | null = "base64-screenshot-data",
|
||||
output: string = "Browser action completed",
|
||||
error: string | null = null,
|
||||
) => ({
|
||||
id: "browser-obs-123",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "environment",
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "browser-call-456",
|
||||
observation: {
|
||||
kind: "BrowserObservation",
|
||||
output,
|
||||
error,
|
||||
screenshot_data: screenshotData,
|
||||
},
|
||||
action_id: "browser-action-123",
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock BrowserNavigateAction event for testing browser URL extraction
|
||||
*/
|
||||
export const createMockBrowserNavigateActionEvent = (
|
||||
url: string = "https://example.com",
|
||||
) => ({
|
||||
id: "browser-action-123",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "agent",
|
||||
thought: [{ type: "text", text: "Navigating to URL" }],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "BrowserNavigateAction",
|
||||
url,
|
||||
new_tab: false,
|
||||
},
|
||||
tool_name: "browser_navigate",
|
||||
tool_call_id: "browser-call-456",
|
||||
tool_call: {
|
||||
id: "browser-call-456",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "browser_navigate",
|
||||
arguments: JSON.stringify({ url, new_tab: false }),
|
||||
},
|
||||
},
|
||||
llm_response_id: "llm-response-789",
|
||||
security_risk: { level: "low" },
|
||||
});
|
||||
|
||||
@ -239,18 +239,20 @@ function AppSettingsScreen() {
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="max-budget-per-task-input"
|
||||
name="max-budget-per-task-input"
|
||||
type="number"
|
||||
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
|
||||
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
|
||||
onChange={checkIfMaxBudgetPerTaskHasChanged}
|
||||
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
|
||||
min={1}
|
||||
step={1}
|
||||
className="w-full max-w-[680px]" // Match the width of the language field
|
||||
/>
|
||||
{!settings?.V1_ENABLED && (
|
||||
<SettingsInput
|
||||
testId="max-budget-per-task-input"
|
||||
name="max-budget-per-task-input"
|
||||
type="number"
|
||||
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
|
||||
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
|
||||
onChange={checkIfMaxBudgetPerTaskHasChanged}
|
||||
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
|
||||
min={1}
|
||||
step={1}
|
||||
className="w-full max-w-[680px]" // Match the width of the language field
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="border-t border-t-tertiary pt-6 mt-2">
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
|
||||
@ -112,11 +112,25 @@ function LlmSettingsScreen() {
|
||||
|
||||
// Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode)
|
||||
const currentModel = currentSelectedModel || settings?.LLM_MODEL;
|
||||
const isOpenHandsProvider =
|
||||
(view === "basic" && selectedProvider === "openhands") ||
|
||||
(view === "advanced" && currentModel?.startsWith("openhands/"));
|
||||
|
||||
const isSaasMode = config?.APP_MODE === "saas";
|
||||
const shouldUseOpenHandsKey = isOpenHandsProvider && isSaasMode;
|
||||
|
||||
const isOpenHandsProvider = () => {
|
||||
if (view === "basic") {
|
||||
return selectedProvider === "openhands";
|
||||
}
|
||||
|
||||
if (view === "advanced") {
|
||||
if (dirtyInputs.model) {
|
||||
return currentModel?.startsWith("openhands/");
|
||||
}
|
||||
return settings?.LLM_MODEL?.startsWith("openhands/");
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const shouldUseOpenHandsKey = isOpenHandsProvider() && isSaasMode;
|
||||
|
||||
// Determine if we should hide the agent dropdown when V1 conversation API is enabled
|
||||
const isV1Enabled = settings?.V1_ENABLED;
|
||||
|
||||
@ -61,10 +61,48 @@ interface ConversationActions {
|
||||
|
||||
type ConversationStore = ConversationState & ConversationActions;
|
||||
|
||||
// Helper function to get initial right panel state from localStorage
|
||||
const getConversationIdFromLocation = (): string | null => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/\/conversations\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const parseStoredBoolean = (value: string | null): boolean | null => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialRightPanelState = (): boolean => {
|
||||
const stored = localStorage.getItem("conversation-right-panel-shown");
|
||||
return stored !== null ? JSON.parse(stored) : true;
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const conversationId = getConversationIdFromLocation();
|
||||
const keysToCheck = conversationId
|
||||
? [`conversation-right-panel-shown-${conversationId}`]
|
||||
: [];
|
||||
|
||||
// Fallback to legacy global key for users who haven't switched tabs yet
|
||||
keysToCheck.push("conversation-right-panel-shown");
|
||||
|
||||
for (const key of keysToCheck) {
|
||||
const parsed = parseStoredBoolean(localStorage.getItem(key));
|
||||
if (parsed !== null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const useConversationStore = create<ConversationStore>()(
|
||||
|
||||
@ -7,6 +7,8 @@ import {
|
||||
ExecuteBashObservation,
|
||||
PlanningFileEditorObservation,
|
||||
TerminalObservation,
|
||||
BrowserObservation,
|
||||
BrowserNavigateAction,
|
||||
} from "./core";
|
||||
import { AgentErrorEvent } from "./core/events/observation-event";
|
||||
import { MessageEvent } from "./core/events/message-event";
|
||||
@ -126,6 +128,22 @@ export const isPlanningFileEditorObservationEvent = (
|
||||
isObservationEvent(event) &&
|
||||
event.observation.kind === "PlanningFileEditorObservation";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an observation event is a BrowserObservation
|
||||
*/
|
||||
export const isBrowserObservationEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is ObservationEvent<BrowserObservation> =>
|
||||
isObservationEvent(event) && event.observation.kind === "BrowserObservation";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an action event is a BrowserNavigateAction
|
||||
*/
|
||||
export const isBrowserNavigateActionEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is ActionEvent<BrowserNavigateAction> =>
|
||||
isActionEvent(event) && event.action.kind === "BrowserNavigateAction";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is a system prompt event
|
||||
*/
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
|
||||
This directory contains the core components of OpenHands.
|
||||
|
||||
This diagram provides an overview of the roles of each component and how they communicate and collaborate.
|
||||

|
||||
For an overview of the system architecture, see the [architecture documentation](https://docs.openhands.dev/usage/architecture/backend) (v0 backend architecture).
|
||||
|
||||
## Classes
|
||||
|
||||
|
||||
@ -102,6 +102,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
sandbox_startup_poll_frequency: int
|
||||
httpx_client: httpx.AsyncClient
|
||||
web_url: str | None
|
||||
openhands_provider_base_url: str | None
|
||||
access_token_hard_timeout: timedelta | None
|
||||
app_mode: str | None = None
|
||||
keycloak_auth_cookie: str | None = None
|
||||
@ -526,13 +527,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
if not request.llm_model and parent_info.llm_model:
|
||||
request.llm_model = parent_info.llm_model
|
||||
|
||||
async def _setup_secrets_for_git_provider(
|
||||
self, git_provider: ProviderType | None, user: UserInfo
|
||||
) -> dict:
|
||||
"""Set up secrets for git provider authentication.
|
||||
async def _setup_secrets_for_git_providers(self, user: UserInfo) -> dict:
|
||||
"""Set up secrets for all git provider authentication.
|
||||
|
||||
Args:
|
||||
git_provider: The git provider type (GitHub, GitLab, etc.)
|
||||
user: User information containing authentication details
|
||||
|
||||
Returns:
|
||||
@ -540,35 +538,42 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
"""
|
||||
secrets = await self.user_context.get_secrets()
|
||||
|
||||
if not git_provider:
|
||||
# Get all provider tokens from user authentication
|
||||
provider_tokens = await self.user_context.get_provider_tokens()
|
||||
if not provider_tokens:
|
||||
return secrets
|
||||
|
||||
secret_name = f'{git_provider.name}_TOKEN'
|
||||
# Create secrets for each provider token
|
||||
for provider_type, provider_token in provider_tokens.items():
|
||||
if not provider_token.token:
|
||||
continue
|
||||
|
||||
if self.web_url:
|
||||
# Create an access token for web-based authentication
|
||||
access_token = self.jwt_service.create_jws_token(
|
||||
payload={
|
||||
'user_id': user.id,
|
||||
'provider_type': git_provider.value,
|
||||
},
|
||||
expires_in=self.access_token_hard_timeout,
|
||||
)
|
||||
headers = {'X-Access-Token': access_token}
|
||||
secret_name = f'{provider_type.name}_TOKEN'
|
||||
|
||||
# Include keycloak_auth cookie in headers if app_mode is SaaS
|
||||
if self.app_mode == 'saas' and self.keycloak_auth_cookie:
|
||||
headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}'
|
||||
if self.web_url:
|
||||
# Create an access token for web-based authentication
|
||||
access_token = self.jwt_service.create_jws_token(
|
||||
payload={
|
||||
'user_id': user.id,
|
||||
'provider_type': provider_type.value,
|
||||
},
|
||||
expires_in=self.access_token_hard_timeout,
|
||||
)
|
||||
headers = {'X-Access-Token': access_token}
|
||||
|
||||
secrets[secret_name] = LookupSecret(
|
||||
url=self.web_url + '/api/v1/webhooks/secrets',
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
# Use static token for environments without web URL access
|
||||
static_token = await self.user_context.get_latest_token(git_provider)
|
||||
if static_token:
|
||||
secrets[secret_name] = StaticSecret(value=static_token)
|
||||
# Include keycloak_auth cookie in headers if app_mode is SaaS
|
||||
if self.app_mode == 'saas' and self.keycloak_auth_cookie:
|
||||
headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}'
|
||||
|
||||
secrets[secret_name] = LookupSecret(
|
||||
url=self.web_url + '/api/v1/webhooks/secrets',
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
# Use static token for environments without web URL access
|
||||
static_token = await self.user_context.get_latest_token(provider_type)
|
||||
if static_token:
|
||||
secrets[secret_name] = StaticSecret(value=static_token)
|
||||
|
||||
return secrets
|
||||
|
||||
@ -586,9 +591,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
"""
|
||||
# Configure LLM
|
||||
model = llm_model or user.llm_model
|
||||
base_url = user.llm_base_url
|
||||
if model and model.startswith('openhands/'):
|
||||
base_url = user.llm_base_url or self.openhands_provider_base_url
|
||||
llm = LLM(
|
||||
model=model,
|
||||
base_url=user.llm_base_url,
|
||||
base_url=base_url,
|
||||
api_key=user.llm_api_key,
|
||||
usage_id='agent',
|
||||
)
|
||||
@ -768,8 +776,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
user = await self.user_context.get_user_info()
|
||||
workspace = LocalWorkspace(working_dir=working_dir)
|
||||
|
||||
# Set up secrets for git provider
|
||||
secrets = await self._setup_secrets_for_git_provider(git_provider, user)
|
||||
# Set up secrets for all git providers
|
||||
secrets = await self._setup_secrets_for_git_providers(user)
|
||||
|
||||
# Configure LLM and MCP
|
||||
llm, mcp_config = await self._configure_llm_and_mcp(user, llm_model)
|
||||
@ -1078,6 +1086,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,
|
||||
httpx_client=httpx_client,
|
||||
web_url=web_url,
|
||||
openhands_provider_base_url=config.openhands_provider_base_url,
|
||||
access_token_hard_timeout=access_token_hard_timeout,
|
||||
app_mode=app_mode,
|
||||
keycloak_auth_cookie=keycloak_auth_cookie,
|
||||
|
||||
@ -74,6 +74,11 @@ def get_default_web_url() -> str | None:
|
||||
return f'https://{web_host}'
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _get_default_lifespan():
|
||||
# Check legacy parameters for saas mode. If we are in SAAS mode do not apply
|
||||
# OSS alembic migrations
|
||||
@ -88,6 +93,10 @@ class AppServerConfig(OpenHandsModel):
|
||||
default_factory=get_default_web_url,
|
||||
description='The URL where OpenHands is running (e.g., http://localhost:3000)',
|
||||
)
|
||||
openhands_provider_base_url: str | None = Field(
|
||||
default_factory=get_openhands_provider_base_url,
|
||||
description='Base URL for the OpenHands provider',
|
||||
)
|
||||
# Dependency Injection Injectors
|
||||
event: EventServiceInjector | None = None
|
||||
event_callback: EventCallbackServiceInjector | None = None
|
||||
|
||||
@ -60,16 +60,22 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def valid_sandbox(
|
||||
sandbox_id: str,
|
||||
user_context: UserContext = Depends(as_admin),
|
||||
session_api_key: str = Depends(
|
||||
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
|
||||
),
|
||||
sandbox_service: SandboxService = sandbox_service_dependency,
|
||||
) -> SandboxInfo:
|
||||
sandbox_info = await sandbox_service.get_sandbox(sandbox_id)
|
||||
if sandbox_info is None or sandbox_info.session_api_key != session_api_key:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
||||
if session_api_key is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
|
||||
)
|
||||
|
||||
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key)
|
||||
if sandbox_info is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
|
||||
)
|
||||
return sandbox_info
|
||||
|
||||
|
||||
@ -94,7 +100,7 @@ async def valid_conversation(
|
||||
return app_conversation_info
|
||||
|
||||
|
||||
@router.post('/{sandbox_id}/conversations')
|
||||
@router.post('/conversations')
|
||||
async def on_conversation_update(
|
||||
conversation_info: ConversationInfo,
|
||||
sandbox_info: SandboxInfo = Depends(valid_sandbox),
|
||||
@ -125,7 +131,7 @@ async def on_conversation_update(
|
||||
return Success()
|
||||
|
||||
|
||||
@router.post('/{sandbox_id}/events/{conversation_id}')
|
||||
@router.post('/events/{conversation_id}')
|
||||
async def on_event(
|
||||
events: list[Event],
|
||||
conversation_id: UUID,
|
||||
|
||||
@ -260,6 +260,29 @@ class DockerSandboxService(SandboxService):
|
||||
except (NotFound, APIError):
|
||||
return None
|
||||
|
||||
async def get_sandbox_by_session_api_key(
|
||||
self, session_api_key: str
|
||||
) -> SandboxInfo | None:
|
||||
"""Get a single sandbox by session API key."""
|
||||
try:
|
||||
# Get all containers with our prefix
|
||||
all_containers = self.docker_client.containers.list(all=True)
|
||||
|
||||
for container in all_containers:
|
||||
if container.name and container.name.startswith(
|
||||
self.container_name_prefix
|
||||
):
|
||||
# Check if this container has the matching session API key
|
||||
env_vars = self._get_container_env_vars(container)
|
||||
container_session_key = env_vars.get(SESSION_API_KEY_VARIABLE)
|
||||
|
||||
if container_session_key == session_api_key:
|
||||
return await self._container_to_checked_sandbox_info(container)
|
||||
|
||||
return None
|
||||
except (NotFound, APIError):
|
||||
return None
|
||||
|
||||
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
|
||||
"""Start a new sandbox."""
|
||||
# Enforce sandbox limits by cleaning up old sandboxes
|
||||
@ -285,8 +308,7 @@ class DockerSandboxService(SandboxService):
|
||||
env_vars = sandbox_spec.initial_env.copy()
|
||||
env_vars[SESSION_API_KEY_VARIABLE] = session_api_key
|
||||
env_vars[WEBHOOK_CALLBACK_VARIABLE] = (
|
||||
f'http://host.docker.internal:{self.host_port}'
|
||||
f'/api/v1/webhooks/{container_name}'
|
||||
f'http://host.docker.internal:{self.host_port}/api/v1/webhooks'
|
||||
)
|
||||
|
||||
# Prepare port mappings and add port environment variables
|
||||
|
||||
@ -275,6 +275,17 @@ class ProcessSandboxService(SandboxService):
|
||||
|
||||
return await self._process_to_sandbox_info(sandbox_id, process_info)
|
||||
|
||||
async def get_sandbox_by_session_api_key(
|
||||
self, session_api_key: str
|
||||
) -> SandboxInfo | None:
|
||||
"""Get a single sandbox by session API key."""
|
||||
# Search through all processes to find one with matching session_api_key
|
||||
for sandbox_id, process_info in _processes.items():
|
||||
if process_info.session_api_key == session_api_key:
|
||||
return await self._process_to_sandbox_info(sandbox_id, process_info)
|
||||
|
||||
return None
|
||||
|
||||
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
|
||||
"""Start a new sandbox."""
|
||||
# Get sandbox spec
|
||||
|
||||
@ -240,9 +240,7 @@ class RemoteSandboxService(SandboxService):
|
||||
|
||||
# If a public facing url is defined, add a callback to the agent server environment.
|
||||
if self.web_url:
|
||||
environment[WEBHOOK_CALLBACK_VARIABLE] = (
|
||||
f'{self.web_url}/api/v1/webhooks/{sandbox_id}'
|
||||
)
|
||||
environment[WEBHOOK_CALLBACK_VARIABLE] = f'{self.web_url}/api/v1/webhooks'
|
||||
# We specify CORS settings only if there is a public facing url - otherwise
|
||||
# we are probably in local development and the only url in use is localhost
|
||||
environment[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url
|
||||
@ -301,6 +299,27 @@ class RemoteSandboxService(SandboxService):
|
||||
return None
|
||||
return await self._to_sandbox_info(stored_sandbox)
|
||||
|
||||
async def get_sandbox_by_session_api_key(
|
||||
self, session_api_key: str
|
||||
) -> Union[SandboxInfo, None]:
|
||||
"""Get a single sandbox by session API key."""
|
||||
# Get all stored sandboxes for the current user
|
||||
stmt = await self._secure_select()
|
||||
result = await self.db_session.execute(stmt)
|
||||
stored_sandboxes = result.scalars().all()
|
||||
|
||||
# Check each sandbox's runtime data for matching session_api_key
|
||||
for stored_sandbox in stored_sandboxes:
|
||||
try:
|
||||
runtime = await self._get_runtime(stored_sandbox.id)
|
||||
if runtime and runtime.get('session_api_key') == session_api_key:
|
||||
return await self._to_sandbox_info(stored_sandbox, runtime)
|
||||
except Exception:
|
||||
# Continue checking other sandboxes if one fails
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
|
||||
"""Start a new sandbox by creating a remote runtime."""
|
||||
try:
|
||||
|
||||
@ -25,6 +25,12 @@ class SandboxService(ABC):
|
||||
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
|
||||
"""Get a single sandbox. Return None if the sandbox was not found."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_sandbox_by_session_api_key(
|
||||
self, session_api_key: str
|
||||
) -> SandboxInfo | None:
|
||||
"""Get a single sandbox by session API key. Return None if the sandbox was not found."""
|
||||
|
||||
async def batch_get_sandboxes(
|
||||
self, sandbox_ids: list[str]
|
||||
) -> list[SandboxInfo | None]:
|
||||
|
||||
@ -9,7 +9,11 @@ from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext, UserContextInjector
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth, get_user_auth
|
||||
|
||||
@ -44,6 +48,9 @@ class AuthUserContext(UserContext):
|
||||
self._user_info = user_info
|
||||
return user_info
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
return await self.user_auth.get_provider_tokens()
|
||||
|
||||
async def get_provider_handler(self):
|
||||
provider_handler = self._provider_handler
|
||||
if not provider_handler:
|
||||
|
||||
@ -5,7 +5,7 @@ from fastapi import Request
|
||||
from openhands.app_server.errors import OpenHandsError
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.sdk.conversation.secret_source import SecretSource
|
||||
|
||||
|
||||
@ -24,6 +24,9 @@ class SpecifyUserContext(UserContext):
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ from openhands.app_server.services.injector import Injector
|
||||
from openhands.app_server.user.user_models import (
|
||||
UserInfo,
|
||||
)
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.sdk.conversation.secret_source import SecretSource
|
||||
from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
@ -26,6 +26,10 @@ class UserContext(ABC):
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
"""Get the provider tokens for the user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
"""Get the latest tokens for all provider types"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
"""Get the latest token for the provider type given"""
|
||||
|
||||
@ -90,4 +90,4 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
|
||||
]
|
||||
model_list = clarifai_models + model_list
|
||||
|
||||
return list(sorted(set(model_list)))
|
||||
return sorted(set(model_list))
|
||||
|
||||
@ -28,6 +28,8 @@ class TestLiveStatusAppConversationService:
|
||||
"""Set up test fixtures."""
|
||||
# Create mock dependencies
|
||||
self.mock_user_context = Mock(spec=UserContext)
|
||||
self.mock_user_auth = Mock()
|
||||
self.mock_user_context.user_auth = self.mock_user_auth
|
||||
self.mock_jwt_service = Mock()
|
||||
self.mock_sandbox_service = Mock()
|
||||
self.mock_sandbox_spec_service = Mock()
|
||||
@ -50,6 +52,7 @@ class TestLiveStatusAppConversationService:
|
||||
sandbox_startup_poll_frequency=1,
|
||||
httpx_client=self.mock_httpx_client,
|
||||
web_url='https://test.example.com',
|
||||
openhands_provider_base_url='https://provider.example.com',
|
||||
access_token_hard_timeout=None,
|
||||
app_mode='test',
|
||||
keycloak_auth_cookie=None,
|
||||
@ -64,6 +67,7 @@ class TestLiveStatusAppConversationService:
|
||||
self.mock_user.confirmation_mode = False
|
||||
self.mock_user.search_api_key = None # Default to None
|
||||
self.mock_user.condenser_max_size = None # Default to None
|
||||
self.mock_user.llm_base_url = 'https://api.openai.com/v1'
|
||||
|
||||
# Mock sandbox
|
||||
self.mock_sandbox = Mock(spec=SandboxInfo)
|
||||
@ -71,67 +75,83 @@ class TestLiveStatusAppConversationService:
|
||||
self.mock_sandbox.status = SandboxStatus.RUNNING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_secrets_for_git_provider_no_provider(self):
|
||||
"""Test _setup_secrets_for_git_provider with no git provider."""
|
||||
async def test_setup_secrets_for_git_providers_no_provider_tokens(self):
|
||||
"""Test _setup_secrets_for_git_providers with no provider tokens."""
|
||||
# Arrange
|
||||
base_secrets = {'existing': 'secret'}
|
||||
self.mock_user_context.get_secrets.return_value = base_secrets
|
||||
self.mock_user_context.get_provider_tokens = AsyncMock(return_value=None)
|
||||
|
||||
# Act
|
||||
result = await self.service._setup_secrets_for_git_provider(
|
||||
None, self.mock_user
|
||||
)
|
||||
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
|
||||
|
||||
# Assert
|
||||
assert result == base_secrets
|
||||
self.mock_user_context.get_secrets.assert_called_once()
|
||||
self.mock_user_context.get_provider_tokens.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_secrets_for_git_provider_with_web_url(self):
|
||||
"""Test _setup_secrets_for_git_provider with web URL (creates access token)."""
|
||||
async def test_setup_secrets_for_git_providers_with_web_url(self):
|
||||
"""Test _setup_secrets_for_git_providers with web URL (creates access token)."""
|
||||
# Arrange
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
|
||||
base_secrets = {}
|
||||
self.mock_user_context.get_secrets.return_value = base_secrets
|
||||
self.mock_jwt_service.create_jws_token.return_value = 'test_access_token'
|
||||
git_provider = ProviderType.GITHUB
|
||||
|
||||
# Mock provider tokens
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
|
||||
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
|
||||
}
|
||||
self.mock_user_context.get_provider_tokens = AsyncMock(
|
||||
return_value=provider_tokens
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await self.service._setup_secrets_for_git_provider(
|
||||
git_provider, self.mock_user
|
||||
)
|
||||
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
|
||||
|
||||
# Assert
|
||||
assert 'GITHUB_TOKEN' in result
|
||||
assert 'GITLAB_TOKEN' in result
|
||||
assert isinstance(result['GITHUB_TOKEN'], LookupSecret)
|
||||
assert isinstance(result['GITLAB_TOKEN'], LookupSecret)
|
||||
assert (
|
||||
result['GITHUB_TOKEN'].url
|
||||
== 'https://test.example.com/api/v1/webhooks/secrets'
|
||||
)
|
||||
assert result['GITHUB_TOKEN'].headers['X-Access-Token'] == 'test_access_token'
|
||||
|
||||
self.mock_jwt_service.create_jws_token.assert_called_once_with(
|
||||
payload={
|
||||
'user_id': self.mock_user.id,
|
||||
'provider_type': git_provider.value,
|
||||
},
|
||||
expires_in=None,
|
||||
)
|
||||
# Should be called twice, once for each provider
|
||||
assert self.mock_jwt_service.create_jws_token.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_secrets_for_git_provider_with_saas_mode(self):
|
||||
"""Test _setup_secrets_for_git_provider with SaaS mode (includes keycloak cookie)."""
|
||||
async def test_setup_secrets_for_git_providers_with_saas_mode(self):
|
||||
"""Test _setup_secrets_for_git_providers with SaaS mode (includes keycloak cookie)."""
|
||||
# Arrange
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
|
||||
self.service.app_mode = 'saas'
|
||||
self.service.keycloak_auth_cookie = 'test_cookie'
|
||||
base_secrets = {}
|
||||
self.mock_user_context.get_secrets.return_value = base_secrets
|
||||
self.mock_jwt_service.create_jws_token.return_value = 'test_access_token'
|
||||
git_provider = ProviderType.GITLAB
|
||||
|
||||
# Mock provider tokens
|
||||
provider_tokens = {
|
||||
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
|
||||
}
|
||||
self.mock_user_context.get_provider_tokens = AsyncMock(
|
||||
return_value=provider_tokens
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await self.service._setup_secrets_for_git_provider(
|
||||
git_provider, self.mock_user
|
||||
)
|
||||
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
|
||||
|
||||
# Assert
|
||||
assert 'GITLAB_TOKEN' in result
|
||||
@ -141,40 +161,60 @@ class TestLiveStatusAppConversationService:
|
||||
assert lookup_secret.headers['Cookie'] == 'keycloak_auth=test_cookie'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_secrets_for_git_provider_without_web_url(self):
|
||||
"""Test _setup_secrets_for_git_provider without web URL (uses static token)."""
|
||||
async def test_setup_secrets_for_git_providers_without_web_url(self):
|
||||
"""Test _setup_secrets_for_git_providers without web URL (uses static token)."""
|
||||
# Arrange
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
|
||||
self.service.web_url = None
|
||||
base_secrets = {}
|
||||
self.mock_user_context.get_secrets.return_value = base_secrets
|
||||
self.mock_user_context.get_latest_token.return_value = 'static_token_value'
|
||||
git_provider = ProviderType.GITHUB
|
||||
|
||||
# Mock provider tokens
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
|
||||
}
|
||||
self.mock_user_context.get_provider_tokens = AsyncMock(
|
||||
return_value=provider_tokens
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await self.service._setup_secrets_for_git_provider(
|
||||
git_provider, self.mock_user
|
||||
)
|
||||
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
|
||||
|
||||
# Assert
|
||||
assert 'GITHUB_TOKEN' in result
|
||||
assert isinstance(result['GITHUB_TOKEN'], StaticSecret)
|
||||
assert result['GITHUB_TOKEN'].value.get_secret_value() == 'static_token_value'
|
||||
self.mock_user_context.get_latest_token.assert_called_once_with(git_provider)
|
||||
self.mock_user_context.get_latest_token.assert_called_once_with(
|
||||
ProviderType.GITHUB
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_secrets_for_git_provider_no_static_token(self):
|
||||
"""Test _setup_secrets_for_git_provider when no static token is available."""
|
||||
async def test_setup_secrets_for_git_providers_no_static_token(self):
|
||||
"""Test _setup_secrets_for_git_providers when no static token is available."""
|
||||
# Arrange
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
|
||||
self.service.web_url = None
|
||||
base_secrets = {}
|
||||
self.mock_user_context.get_secrets.return_value = base_secrets
|
||||
self.mock_user_context.get_latest_token.return_value = None
|
||||
git_provider = ProviderType.GITHUB
|
||||
|
||||
# Mock provider tokens
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
|
||||
}
|
||||
self.mock_user_context.get_provider_tokens = AsyncMock(
|
||||
return_value=provider_tokens
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await self.service._setup_secrets_for_git_provider(
|
||||
git_provider, self.mock_user
|
||||
)
|
||||
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
|
||||
|
||||
# Assert
|
||||
assert 'GITHUB_TOKEN' not in result
|
||||
@ -203,6 +243,70 @@ class TestLiveStatusAppConversationService:
|
||||
assert mcp_config['default']['url'] == 'https://test.example.com/mcp/mcp'
|
||||
assert mcp_config['default']['headers']['X-Session-API-Key'] == 'mcp_api_key'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_openhands_model_prefers_user_base_url(self):
|
||||
"""openhands/* model uses user.llm_base_url when provided."""
|
||||
# Arrange
|
||||
self.mock_user.llm_model = 'openhands/special'
|
||||
self.mock_user.llm_base_url = 'https://user-llm.example.com'
|
||||
self.mock_user_context.get_mcp_api_key.return_value = None
|
||||
|
||||
# Act
|
||||
llm, _ = await self.service._configure_llm_and_mcp(
|
||||
self.mock_user, self.mock_user.llm_model
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert llm.base_url == 'https://user-llm.example.com'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_openhands_model_uses_provider_default(self):
|
||||
"""openhands/* model falls back to configured provider base URL."""
|
||||
# Arrange
|
||||
self.mock_user.llm_model = 'openhands/default'
|
||||
self.mock_user.llm_base_url = None
|
||||
self.mock_user_context.get_mcp_api_key.return_value = None
|
||||
|
||||
# Act
|
||||
llm, _ = await self.service._configure_llm_and_mcp(
|
||||
self.mock_user, self.mock_user.llm_model
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert llm.base_url == 'https://provider.example.com'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_openhands_model_no_base_urls(self):
|
||||
"""openhands/* model sets base_url to None when no sources available."""
|
||||
# Arrange
|
||||
self.mock_user.llm_model = 'openhands/default'
|
||||
self.mock_user.llm_base_url = None
|
||||
self.service.openhands_provider_base_url = None
|
||||
self.mock_user_context.get_mcp_api_key.return_value = None
|
||||
|
||||
# Act
|
||||
llm, _ = await self.service._configure_llm_and_mcp(
|
||||
self.mock_user, self.mock_user.llm_model
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert llm.base_url is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_non_openhands_model_ignores_provider(self):
|
||||
"""Non-openhands model ignores provider base URL and uses user base URL."""
|
||||
# Arrange
|
||||
self.mock_user.llm_model = 'gpt-4'
|
||||
self.mock_user.llm_base_url = 'https://user-llm.example.com'
|
||||
self.service.openhands_provider_base_url = 'https://provider.example.com'
|
||||
self.mock_user_context.get_mcp_api_key.return_value = None
|
||||
|
||||
# Act
|
||||
llm, _ = await self.service._configure_llm_and_mcp(self.mock_user, None)
|
||||
|
||||
# Assert
|
||||
assert llm.base_url == 'https://user-llm.example.com'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_with_user_default_model(self):
|
||||
"""Test _configure_llm_and_mcp using user's default model."""
|
||||
@ -677,7 +781,7 @@ class TestLiveStatusAppConversationService:
|
||||
mock_agent = Mock(spec=Agent)
|
||||
mock_final_request = Mock(spec=StartConversationRequest)
|
||||
|
||||
self.service._setup_secrets_for_git_provider = AsyncMock(
|
||||
self.service._setup_secrets_for_git_providers = AsyncMock(
|
||||
return_value=mock_secrets
|
||||
)
|
||||
self.service._configure_llm_and_mcp = AsyncMock(
|
||||
@ -705,8 +809,8 @@ class TestLiveStatusAppConversationService:
|
||||
# Assert
|
||||
assert result == mock_final_request
|
||||
|
||||
self.service._setup_secrets_for_git_provider.assert_called_once_with(
|
||||
ProviderType.GITHUB, self.mock_user
|
||||
self.service._setup_secrets_for_git_providers.assert_called_once_with(
|
||||
self.mock_user
|
||||
)
|
||||
self.service._configure_llm_and_mcp.assert_called_once_with(
|
||||
self.mock_user, 'gpt-4'
|
||||
|
||||
@ -291,9 +291,7 @@ class TestEnvironmentInitialization:
|
||||
)
|
||||
|
||||
# Verify
|
||||
expected_webhook_url = (
|
||||
'https://web.example.com/api/v1/webhooks/test-sandbox-123'
|
||||
)
|
||||
expected_webhook_url = 'https://web.example.com/api/v1/webhooks'
|
||||
assert environment['EXISTING_VAR'] == 'existing_value'
|
||||
assert environment[WEBHOOK_CALLBACK_VARIABLE] == expected_webhook_url
|
||||
assert environment[ALLOW_CORS_ORIGINS_VARIABLE] == 'https://web.example.com'
|
||||
|
||||
@ -27,6 +27,7 @@ class MockSandboxService(SandboxService):
|
||||
def __init__(self):
|
||||
self.search_sandboxes_mock = AsyncMock()
|
||||
self.get_sandbox_mock = AsyncMock()
|
||||
self.get_sandbox_by_session_api_key_mock = AsyncMock()
|
||||
self.start_sandbox_mock = AsyncMock()
|
||||
self.resume_sandbox_mock = AsyncMock()
|
||||
self.pause_sandbox_mock = AsyncMock()
|
||||
@ -40,6 +41,11 @@ class MockSandboxService(SandboxService):
|
||||
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
|
||||
return await self.get_sandbox_mock(sandbox_id)
|
||||
|
||||
async def get_sandbox_by_session_api_key(
|
||||
self, session_api_key: str
|
||||
) -> SandboxInfo | None:
|
||||
return await self.get_sandbox_by_session_api_key_mock(session_api_key)
|
||||
|
||||
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
|
||||
return await self.start_sandbox_mock(sandbox_spec_id)
|
||||
|
||||
|
||||
@ -188,6 +188,7 @@ class TestExperimentManagerIntegration:
|
||||
sandbox_startup_poll_frequency=1,
|
||||
httpx_client=httpx_client,
|
||||
web_url=None,
|
||||
openhands_provider_base_url=None,
|
||||
access_token_hard_timeout=None,
|
||||
)
|
||||
|
||||
@ -203,7 +204,7 @@ class TestExperimentManagerIntegration:
|
||||
with (
|
||||
patch.object(
|
||||
service,
|
||||
'_setup_secrets_for_git_provider',
|
||||
'_setup_secrets_for_git_providers',
|
||||
return_value={},
|
||||
),
|
||||
patch.object(
|
||||
|
||||
@ -2166,6 +2166,7 @@ async def test_delete_v1_conversation_with_sub_conversations():
|
||||
sandbox_startup_poll_frequency=2,
|
||||
httpx_client=mock_httpx_client,
|
||||
web_url=None,
|
||||
openhands_provider_base_url=None,
|
||||
access_token_hard_timeout=None,
|
||||
)
|
||||
|
||||
@ -2287,6 +2288,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations():
|
||||
sandbox_startup_poll_frequency=2,
|
||||
httpx_client=mock_httpx_client,
|
||||
web_url=None,
|
||||
openhands_provider_base_url=None,
|
||||
access_token_hard_timeout=None,
|
||||
)
|
||||
|
||||
@ -2438,6 +2440,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error():
|
||||
sandbox_startup_poll_frequency=2,
|
||||
httpx_client=mock_httpx_client,
|
||||
web_url=None,
|
||||
openhands_provider_base_url=None,
|
||||
access_token_hard_timeout=None,
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user