diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 21782ad1d8..3da4c3ee88 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,12 +1,8 @@ # CODEOWNERS file for OpenHands repository # See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -# Frontend code owners -/frontend/ @amanape -/openhands-ui/ @amanape - -# Evaluation code owners +/frontend/ @amanape @hieptl +/openhands-ui/ @amanape @hieptl +/openhands/ @tofarr @malhotra5 @hieptl +/enterprise/ @chuckbutkus @tofarr @malhotra5 /evaluation/ @xingyaoww @neubig - -# Documentation code owners -/docs/ @mamoodi diff --git a/enterprise/Dockerfile b/enterprise/Dockerfile index b0ca56a7f6..7b50748ffb 100644 --- a/enterprise/Dockerfile +++ b/enterprise/Dockerfile @@ -31,9 +31,8 @@ RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gsprea "pillow>=11.3.0" WORKDIR /app -COPY enterprise . +COPY --chown=openhands:openhands --chmod=770 enterprise . -RUN chown -R openhands:openhands /app && chmod -R 770 /app USER openhands # Command will be overridden by Kubernetes deployment template diff --git a/enterprise/enterprise_local/convert_to_env.py b/enterprise/enterprise_local/convert_to_env.py index 54e2c5d71b..cbd04b6449 100644 --- a/enterprise/enterprise_local/convert_to_env.py +++ b/enterprise/enterprise_local/convert_to_env.py @@ -116,7 +116,7 @@ lines.append('POSTHOG_CLIENT_KEY=test') lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true') lines.append('MAX_CONCURRENT_CONVERSATIONS=10') lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev') -lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514') +lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-opus-4-5-20251101') lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}') lines.append('LOCAL_DEPLOYMENT=true') lines.append('DB_HOST=localhost') diff --git a/enterprise/server/auth/constants.py b/enterprise/server/auth/constants.py index 15d3b0f704..242237e93d 100644 --- a/enterprise/server/auth/constants.py +++ b/enterprise/server/auth/constants.py @@ -38,3 +38,8 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in ( 'y', 'on', ) +BLOCKED_EMAIL_DOMAINS = [ + domain.strip().lower() + for domain in os.getenv('BLOCKED_EMAIL_DOMAINS', '').split(',') + if domain.strip() +] diff --git a/enterprise/server/auth/domain_blocker.py b/enterprise/server/auth/domain_blocker.py new file mode 100644 index 0000000000..169545ae2d --- /dev/null +++ b/enterprise/server/auth/domain_blocker.py @@ -0,0 +1,56 @@ +from server.auth.constants import BLOCKED_EMAIL_DOMAINS + +from openhands.core.logger import openhands_logger as logger + + +class DomainBlocker: + def __init__(self) -> None: + logger.debug('Initializing DomainBlocker') + self.blocked_domains: list[str] = BLOCKED_EMAIL_DOMAINS + if self.blocked_domains: + logger.info( + f'Successfully loaded {len(self.blocked_domains)} blocked email domains: {self.blocked_domains}' + ) + + def is_active(self) -> bool: + """Check if domain blocking is enabled""" + return bool(self.blocked_domains) + + def _extract_domain(self, email: str) -> str | None: + """Extract and normalize email domain from email address""" + if not email: + return None + try: + # Extract domain part after @ + if '@' not in email: + return None + domain = email.split('@')[1].strip().lower() + return domain if domain else None + except Exception: + logger.debug(f'Error extracting domain from email: {email}', exc_info=True) + return None + + def is_domain_blocked(self, email: str) -> bool: + """Check if email domain is blocked""" + if not self.is_active(): + return False + + if not email: + logger.debug('No email provided for domain check') + return False + + domain = self._extract_domain(email) + if not domain: + logger.debug(f'Could not extract domain from email: {email}') + return False + + is_blocked = domain in self.blocked_domains + if is_blocked: + logger.warning(f'Email domain {domain} is blocked for email: {email}') + else: + logger.debug(f'Email domain {domain} is not blocked') + + return is_blocked + + +domain_blocker = DomainBlocker() diff --git a/enterprise/server/auth/saas_user_auth.py b/enterprise/server/auth/saas_user_auth.py index 2f399a74cf..b51d336997 100644 --- a/enterprise/server/auth/saas_user_auth.py +++ b/enterprise/server/auth/saas_user_auth.py @@ -13,6 +13,7 @@ from server.auth.auth_error import ( ExpiredError, NoCredentialsError, ) +from server.auth.domain_blocker import domain_blocker from server.auth.token_manager import TokenManager from server.config import get_config from server.logger import logger @@ -312,6 +313,16 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth: user_id = access_token_payload['sub'] email = access_token_payload['email'] email_verified = access_token_payload['email_verified'] + + # Check if email domain is blocked + if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email): + logger.warning( + f'Blocked authentication attempt for existing user with email: {email}' + ) + raise AuthError( + 'Access denied: Your email domain is not allowed to access this service' + ) + logger.debug('saas_user_auth_from_signed_token:return') return SaasUserAuth( diff --git a/enterprise/server/auth/token_manager.py b/enterprise/server/auth/token_manager.py index 0b873bc7fc..04bfae0767 100644 --- a/enterprise/server/auth/token_manager.py +++ b/enterprise/server/auth/token_manager.py @@ -527,6 +527,49 @@ class TokenManager: github_id = github_ids[0] return github_id + async def disable_keycloak_user( + self, user_id: str, email: str | None = None + ) -> None: + """Disable a Keycloak user account. + + Args: + user_id: The Keycloak user ID to disable + email: Optional email address for logging purposes + + This method attempts to disable the user account but will not raise exceptions. + Errors are logged but do not prevent the operation from completing. + """ + try: + keycloak_admin = get_keycloak_admin(self.external) + # Get current user to preserve other fields + user = await keycloak_admin.a_get_user(user_id) + if user: + # Update user with enabled=False to disable the account + await keycloak_admin.a_update_user( + user_id=user_id, + payload={ + 'enabled': False, + 'username': user.get('username', ''), + 'email': user.get('email', ''), + 'emailVerified': user.get('emailVerified', False), + }, + ) + email_str = f', email: {email}' if email else '' + logger.info( + f'Disabled Keycloak account for user_id: {user_id}{email_str}' + ) + else: + logger.warning( + f'User not found in Keycloak when attempting to disable: {user_id}' + ) + except Exception as e: + # Log error but don't raise - the caller should handle the blocking regardless + email_str = f', email: {email}' if email else '' + logger.error( + f'Failed to disable Keycloak account for user_id: {user_id}{email_str}: {str(e)}', + exc_info=True, + ) + def store_org_token(self, installation_id: int, installation_token: str): """Store a GitHub App installation token. diff --git a/enterprise/server/constants.py b/enterprise/server/constants.py index 74176d19a1..e2020ba2b9 100644 --- a/enterprise/server/constants.py +++ b/enterprise/server/constants.py @@ -25,6 +25,7 @@ USER_SETTINGS_VERSION_TO_MODEL = { 2: 'claude-3-7-sonnet-20250219', 3: 'claude-sonnet-4-20250514', 4: 'claude-sonnet-4-20250514', + 5: 'claude-opus-4-5-20251101', } LITELLM_DEFAULT_MODEL = os.getenv('LITELLM_DEFAULT_MODEL') diff --git a/enterprise/server/routes/auth.py b/enterprise/server/routes/auth.py index ba7aadb883..2ee50bbd2d 100644 --- a/enterprise/server/routes/auth.py +++ b/enterprise/server/routes/auth.py @@ -14,6 +14,7 @@ from server.auth.constants import ( KEYCLOAK_SERVER_URL_EXT, ROLE_CHECK_ENABLED, ) +from server.auth.domain_blocker import domain_blocker from server.auth.gitlab_sync import schedule_gitlab_repo_sync from server.auth.saas_user_auth import SaasUserAuth from server.auth.token_manager import TokenManager @@ -145,7 +146,24 @@ async def keycloak_callback( content={'error': 'Missing user ID or username in response'}, ) + # Check if email domain is blocked + email = user_info.get('email') user_id = user_info['sub'] + if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email): + logger.warning( + f'Blocked authentication attempt for email: {email}, user_id: {user_id}' + ) + + # Disable the Keycloak account + await token_manager.disable_keycloak_user(user_id, email) + + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + 'error': 'Access denied: Your email domain is not allowed to access this service' + }, + ) + # default to github IDP for now. # TODO: remove default once Keycloak is updated universally with the new attribute. idp: str = user_info.get('identity_provider', ProviderType.GITHUB.value) diff --git a/enterprise/server/saas_nested_conversation_manager.py b/enterprise/server/saas_nested_conversation_manager.py index d92e67b9ff..59c6d4e981 100644 --- a/enterprise/server/saas_nested_conversation_manager.py +++ b/enterprise/server/saas_nested_conversation_manager.py @@ -804,6 +804,8 @@ class SaasNestedConversationManager(ConversationManager): env_vars['ENABLE_V1'] = '0' env_vars['SU_TO_USER'] = SU_TO_USER env_vars['DISABLE_VSCODE_PLUGIN'] = str(DISABLE_VSCODE_PLUGIN).lower() + env_vars['BROWSERGYM_DOWNLOAD_DIR'] = '/workspace/.downloads/' + env_vars['PLAYWRIGHT_BROWSERS_PATH'] = '/opt/playwright-browsers' # We need this for LLM traces tracking to identify the source of the LLM calls env_vars['WEB_HOST'] = WEB_HOST diff --git a/enterprise/storage/api_key_store.py b/enterprise/storage/api_key_store.py index 693bfdb321..9714d7476a 100644 --- a/enterprise/storage/api_key_store.py +++ b/enterprise/storage/api_key_store.py @@ -17,10 +17,13 @@ from openhands.core.logger import openhands_logger as logger class ApiKeyStore: session_maker: sessionmaker + API_KEY_PREFIX = 'sk-oh-' + def generate_api_key(self, length: int = 32) -> str: - """Generate a random API key.""" + """Generate a random API key with the sk-oh- prefix.""" alphabet = string.ascii_letters + string.digits - return ''.join(secrets.choice(alphabet) for _ in range(length)) + random_part = ''.join(secrets.choice(alphabet) for _ in range(length)) + return f'{self.API_KEY_PREFIX}{random_part}' def create_api_key( self, user_id: str, name: str | None = None, expires_at: datetime | None = None diff --git a/enterprise/storage/saas_settings_store.py b/enterprise/storage/saas_settings_store.py index cfcbec7583..fd64924263 100644 --- a/enterprise/storage/saas_settings_store.py +++ b/enterprise/storage/saas_settings_store.py @@ -95,8 +95,6 @@ class SaasSettingsStore(SettingsStore): self._decrypt_kwargs(kwargs) settings = Settings(**kwargs) - settings.v1_enabled = True - return settings async def store(self, item: Settings): diff --git a/enterprise/tests/unit/test_api_key_store.py b/enterprise/tests/unit/test_api_key_store.py index c1c6a98f3d..df0481937d 100644 --- a/enterprise/tests/unit/test_api_key_store.py +++ b/enterprise/tests/unit/test_api_key_store.py @@ -25,10 +25,12 @@ def api_key_store(mock_session_maker): def test_generate_api_key(api_key_store): - """Test that generate_api_key returns a string of the expected length.""" + """Test that generate_api_key returns a string with sk-oh- prefix and expected length.""" key = api_key_store.generate_api_key(length=32) assert isinstance(key, str) - assert len(key) == 32 + assert key.startswith('sk-oh-') + # Total length should be prefix (6 chars) + random part (32 chars) = 38 chars + assert len(key) == len('sk-oh-') + 32 def test_create_api_key(api_key_store, mock_session): diff --git a/enterprise/tests/unit/test_auth_routes.py b/enterprise/tests/unit/test_auth_routes.py index bf74f0055c..d3e8f47fbe 100644 --- a/enterprise/tests/unit/test_auth_routes.py +++ b/enterprise/tests/unit/test_auth_routes.py @@ -442,3 +442,196 @@ async def test_logout_without_refresh_token(): mock_token_manager.logout.assert_not_called() assert 'set-cookie' in result.headers + + +@pytest.mark.asyncio +async def test_keycloak_callback_blocked_email_domain(mock_request): + """Test keycloak_callback when email domain is blocked.""" + # Arrange + with ( + patch('server.routes.auth.token_manager') as mock_token_manager, + patch('server.routes.auth.domain_blocker') as mock_domain_blocker, + ): + mock_token_manager.get_keycloak_tokens = AsyncMock( + return_value=('test_access_token', 'test_refresh_token') + ) + mock_token_manager.get_user_info = AsyncMock( + return_value={ + 'sub': 'test_user_id', + 'preferred_username': 'test_user', + 'email': 'user@colsch.us', + 'identity_provider': 'github', + } + ) + mock_token_manager.disable_keycloak_user = AsyncMock() + + mock_domain_blocker.is_active.return_value = True + mock_domain_blocker.is_domain_blocked.return_value = True + + # Act + result = await keycloak_callback( + code='test_code', state='test_state', request=mock_request + ) + + # Assert + assert isinstance(result, JSONResponse) + assert result.status_code == status.HTTP_401_UNAUTHORIZED + assert 'error' in result.body.decode() + assert 'email domain is not allowed' in result.body.decode() + mock_domain_blocker.is_domain_blocked.assert_called_once_with('user@colsch.us') + mock_token_manager.disable_keycloak_user.assert_called_once_with( + 'test_user_id', 'user@colsch.us' + ) + + +@pytest.mark.asyncio +async def test_keycloak_callback_allowed_email_domain(mock_request): + """Test keycloak_callback when email domain is not blocked.""" + # Arrange + with ( + patch('server.routes.auth.token_manager') as mock_token_manager, + patch('server.routes.auth.domain_blocker') as mock_domain_blocker, + patch('server.routes.auth.user_verifier') as mock_verifier, + patch('server.routes.auth.session_maker') as mock_session_maker, + ): + mock_session = MagicMock() + mock_session_maker.return_value.__enter__.return_value = mock_session + mock_query = MagicMock() + mock_session.query.return_value = mock_query + mock_query.filter.return_value = mock_query + + mock_user_settings = MagicMock() + mock_user_settings.accepted_tos = '2025-01-01' + mock_query.first.return_value = mock_user_settings + + mock_token_manager.get_keycloak_tokens = AsyncMock( + return_value=('test_access_token', 'test_refresh_token') + ) + mock_token_manager.get_user_info = AsyncMock( + return_value={ + 'sub': 'test_user_id', + 'preferred_username': 'test_user', + 'email': 'user@example.com', + 'identity_provider': 'github', + } + ) + mock_token_manager.store_idp_tokens = AsyncMock() + mock_token_manager.validate_offline_token = AsyncMock(return_value=True) + + mock_domain_blocker.is_active.return_value = True + mock_domain_blocker.is_domain_blocked.return_value = False + + mock_verifier.is_active.return_value = True + mock_verifier.is_user_allowed.return_value = True + + # Act + result = await keycloak_callback( + code='test_code', state='test_state', request=mock_request + ) + + # Assert + assert isinstance(result, RedirectResponse) + mock_domain_blocker.is_domain_blocked.assert_called_once_with( + 'user@example.com' + ) + mock_token_manager.disable_keycloak_user.assert_not_called() + + +@pytest.mark.asyncio +async def test_keycloak_callback_domain_blocking_inactive(mock_request): + """Test keycloak_callback when domain blocking is not active.""" + # Arrange + with ( + patch('server.routes.auth.token_manager') as mock_token_manager, + patch('server.routes.auth.domain_blocker') as mock_domain_blocker, + patch('server.routes.auth.user_verifier') as mock_verifier, + patch('server.routes.auth.session_maker') as mock_session_maker, + ): + mock_session = MagicMock() + mock_session_maker.return_value.__enter__.return_value = mock_session + mock_query = MagicMock() + mock_session.query.return_value = mock_query + mock_query.filter.return_value = mock_query + + mock_user_settings = MagicMock() + mock_user_settings.accepted_tos = '2025-01-01' + mock_query.first.return_value = mock_user_settings + + mock_token_manager.get_keycloak_tokens = AsyncMock( + return_value=('test_access_token', 'test_refresh_token') + ) + mock_token_manager.get_user_info = AsyncMock( + return_value={ + 'sub': 'test_user_id', + 'preferred_username': 'test_user', + 'email': 'user@colsch.us', + 'identity_provider': 'github', + } + ) + mock_token_manager.store_idp_tokens = AsyncMock() + mock_token_manager.validate_offline_token = AsyncMock(return_value=True) + + mock_domain_blocker.is_active.return_value = False + + mock_verifier.is_active.return_value = True + mock_verifier.is_user_allowed.return_value = True + + # Act + result = await keycloak_callback( + code='test_code', state='test_state', request=mock_request + ) + + # Assert + assert isinstance(result, RedirectResponse) + mock_domain_blocker.is_domain_blocked.assert_not_called() + mock_token_manager.disable_keycloak_user.assert_not_called() + + +@pytest.mark.asyncio +async def test_keycloak_callback_missing_email(mock_request): + """Test keycloak_callback when user info does not contain email.""" + # Arrange + with ( + patch('server.routes.auth.token_manager') as mock_token_manager, + patch('server.routes.auth.domain_blocker') as mock_domain_blocker, + patch('server.routes.auth.user_verifier') as mock_verifier, + patch('server.routes.auth.session_maker') as mock_session_maker, + ): + mock_session = MagicMock() + mock_session_maker.return_value.__enter__.return_value = mock_session + mock_query = MagicMock() + mock_session.query.return_value = mock_query + mock_query.filter.return_value = mock_query + + mock_user_settings = MagicMock() + mock_user_settings.accepted_tos = '2025-01-01' + mock_query.first.return_value = mock_user_settings + + mock_token_manager.get_keycloak_tokens = AsyncMock( + return_value=('test_access_token', 'test_refresh_token') + ) + mock_token_manager.get_user_info = AsyncMock( + return_value={ + 'sub': 'test_user_id', + 'preferred_username': 'test_user', + 'identity_provider': 'github', + # No email field + } + ) + mock_token_manager.store_idp_tokens = AsyncMock() + mock_token_manager.validate_offline_token = AsyncMock(return_value=True) + + mock_domain_blocker.is_active.return_value = True + + mock_verifier.is_active.return_value = True + mock_verifier.is_user_allowed.return_value = True + + # Act + result = await keycloak_callback( + code='test_code', state='test_state', request=mock_request + ) + + # Assert + assert isinstance(result, RedirectResponse) + mock_domain_blocker.is_domain_blocked.assert_not_called() + mock_token_manager.disable_keycloak_user.assert_not_called() diff --git a/enterprise/tests/unit/test_domain_blocker.py b/enterprise/tests/unit/test_domain_blocker.py new file mode 100644 index 0000000000..e199a80b9b --- /dev/null +++ b/enterprise/tests/unit/test_domain_blocker.py @@ -0,0 +1,181 @@ +"""Unit tests for DomainBlocker class.""" + +import pytest +from server.auth.domain_blocker import DomainBlocker + + +@pytest.fixture +def domain_blocker(): + """Create a DomainBlocker instance for testing.""" + return DomainBlocker() + + +@pytest.mark.parametrize( + 'blocked_domains,expected', + [ + (['colsch.us', 'other-domain.com'], True), + (['example.com'], True), + ([], False), + ], +) +def test_is_active(domain_blocker, blocked_domains, expected): + """Test that is_active returns correct value based on blocked domains configuration.""" + # Arrange + domain_blocker.blocked_domains = blocked_domains + + # Act + result = domain_blocker.is_active() + + # Assert + assert result == expected + + +@pytest.mark.parametrize( + 'email,expected_domain', + [ + ('user@example.com', 'example.com'), + ('test@colsch.us', 'colsch.us'), + ('user.name@other-domain.com', 'other-domain.com'), + ('USER@EXAMPLE.COM', 'example.com'), # Case insensitive + ('user@EXAMPLE.COM', 'example.com'), + (' user@example.com ', 'example.com'), # Whitespace handling + ], +) +def test_extract_domain_valid_emails(domain_blocker, email, expected_domain): + """Test that _extract_domain correctly extracts and normalizes domains from valid emails.""" + # Act + result = domain_blocker._extract_domain(email) + + # Assert + assert result == expected_domain + + +@pytest.mark.parametrize( + 'email,expected', + [ + (None, None), + ('', None), + ('invalid-email', None), + ('user@', None), # Empty domain after @ + ('no-at-sign', None), + ], +) +def test_extract_domain_invalid_emails(domain_blocker, email, expected): + """Test that _extract_domain returns None for invalid email formats.""" + # Act + result = domain_blocker._extract_domain(email) + + # Assert + assert result == expected + + +def test_is_domain_blocked_when_inactive(domain_blocker): + """Test that is_domain_blocked returns False when blocking is not active.""" + # Arrange + domain_blocker.blocked_domains = [] + + # Act + result = domain_blocker.is_domain_blocked('user@colsch.us') + + # Assert + assert result is False + + +def test_is_domain_blocked_with_none_email(domain_blocker): + """Test that is_domain_blocked returns False when email is None.""" + # Arrange + domain_blocker.blocked_domains = ['colsch.us'] + + # Act + result = domain_blocker.is_domain_blocked(None) + + # Assert + assert result is False + + +def test_is_domain_blocked_with_empty_email(domain_blocker): + """Test that is_domain_blocked returns False when email is empty.""" + # Arrange + domain_blocker.blocked_domains = ['colsch.us'] + + # Act + result = domain_blocker.is_domain_blocked('') + + # Assert + assert result is False + + +def test_is_domain_blocked_with_invalid_email(domain_blocker): + """Test that is_domain_blocked returns False when email format is invalid.""" + # Arrange + domain_blocker.blocked_domains = ['colsch.us'] + + # Act + result = domain_blocker.is_domain_blocked('invalid-email') + + # Assert + assert result is False + + +def test_is_domain_blocked_domain_not_blocked(domain_blocker): + """Test that is_domain_blocked returns False when domain is not in blocked list.""" + # Arrange + domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com'] + + # Act + result = domain_blocker.is_domain_blocked('user@example.com') + + # Assert + assert result is False + + +def test_is_domain_blocked_domain_blocked(domain_blocker): + """Test that is_domain_blocked returns True when domain is in blocked list.""" + # Arrange + domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com'] + + # Act + result = domain_blocker.is_domain_blocked('user@colsch.us') + + # Assert + assert result is True + + +def test_is_domain_blocked_case_insensitive(domain_blocker): + """Test that is_domain_blocked performs case-insensitive domain matching.""" + # Arrange + domain_blocker.blocked_domains = ['colsch.us'] + + # Act + result = domain_blocker.is_domain_blocked('user@COLSCH.US') + + # Assert + assert result is True + + +def test_is_domain_blocked_multiple_blocked_domains(domain_blocker): + """Test that is_domain_blocked correctly checks against multiple blocked domains.""" + # Arrange + domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com', 'blocked.org'] + + # Act + result1 = domain_blocker.is_domain_blocked('user@other-domain.com') + result2 = domain_blocker.is_domain_blocked('user@blocked.org') + result3 = domain_blocker.is_domain_blocked('user@allowed.com') + + # Assert + assert result1 is True + assert result2 is True + assert result3 is False + + +def test_is_domain_blocked_with_whitespace(domain_blocker): + """Test that is_domain_blocked handles emails with whitespace correctly.""" + # Arrange + domain_blocker.blocked_domains = ['colsch.us'] + + # Act + result = domain_blocker.is_domain_blocked(' user@colsch.us ') + + # Assert + assert result is True diff --git a/enterprise/tests/unit/test_saas_user_auth.py b/enterprise/tests/unit/test_saas_user_auth.py index d4ba902677..a518beb28e 100644 --- a/enterprise/tests/unit/test_saas_user_auth.py +++ b/enterprise/tests/unit/test_saas_user_auth.py @@ -5,7 +5,12 @@ import jwt import pytest from fastapi import Request from pydantic import SecretStr -from server.auth.auth_error import BearerTokenError, CookieError, NoCredentialsError +from server.auth.auth_error import ( + AuthError, + BearerTokenError, + CookieError, + NoCredentialsError, +) from server.auth.saas_user_auth import ( SaasUserAuth, get_api_key_from_header, @@ -647,3 +652,97 @@ def test_get_api_key_from_header_bearer_with_empty_token(): # Assert that empty string from Bearer is returned (current behavior) # This tests the current implementation behavior assert api_key == '' + + +@pytest.mark.asyncio +async def test_saas_user_auth_from_signed_token_blocked_domain(mock_config): + """Test that saas_user_auth_from_signed_token raises AuthError when email domain is blocked.""" + # Arrange + access_payload = { + 'sub': 'test_user_id', + 'exp': int(time.time()) + 3600, + 'email': 'user@colsch.us', + 'email_verified': True, + } + access_token = jwt.encode(access_payload, 'access_secret', algorithm='HS256') + + token_payload = { + 'access_token': access_token, + 'refresh_token': 'test_refresh_token', + } + signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256') + + with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker: + mock_domain_blocker.is_active.return_value = True + mock_domain_blocker.is_domain_blocked.return_value = True + + # Act & Assert + with pytest.raises(AuthError) as exc_info: + await saas_user_auth_from_signed_token(signed_token) + + assert 'email domain is not allowed' in str(exc_info.value) + mock_domain_blocker.is_domain_blocked.assert_called_once_with('user@colsch.us') + + +@pytest.mark.asyncio +async def test_saas_user_auth_from_signed_token_allowed_domain(mock_config): + """Test that saas_user_auth_from_signed_token succeeds when email domain is not blocked.""" + # Arrange + access_payload = { + 'sub': 'test_user_id', + 'exp': int(time.time()) + 3600, + 'email': 'user@example.com', + 'email_verified': True, + } + access_token = jwt.encode(access_payload, 'access_secret', algorithm='HS256') + + token_payload = { + 'access_token': access_token, + 'refresh_token': 'test_refresh_token', + } + signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256') + + with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker: + mock_domain_blocker.is_active.return_value = True + mock_domain_blocker.is_domain_blocked.return_value = False + + # Act + result = await saas_user_auth_from_signed_token(signed_token) + + # Assert + assert isinstance(result, SaasUserAuth) + assert result.user_id == 'test_user_id' + assert result.email == 'user@example.com' + mock_domain_blocker.is_domain_blocked.assert_called_once_with( + 'user@example.com' + ) + + +@pytest.mark.asyncio +async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_config): + """Test that saas_user_auth_from_signed_token succeeds when domain blocking is not active.""" + # Arrange + access_payload = { + 'sub': 'test_user_id', + 'exp': int(time.time()) + 3600, + 'email': 'user@colsch.us', + 'email_verified': True, + } + access_token = jwt.encode(access_payload, 'access_secret', algorithm='HS256') + + token_payload = { + 'access_token': access_token, + 'refresh_token': 'test_refresh_token', + } + signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256') + + with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker: + mock_domain_blocker.is_active.return_value = False + + # Act + result = await saas_user_auth_from_signed_token(signed_token) + + # Assert + assert isinstance(result, SaasUserAuth) + assert result.user_id == 'test_user_id' + mock_domain_blocker.is_domain_blocked.assert_not_called() diff --git a/enterprise/tests/unit/test_token_manager_extended.py b/enterprise/tests/unit/test_token_manager_extended.py index 744f208b02..c3b09434a3 100644 --- a/enterprise/tests/unit/test_token_manager_extended.py +++ b/enterprise/tests/unit/test_token_manager_extended.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from server.auth.token_manager import TokenManager, create_encryption_utility @@ -246,3 +246,103 @@ async def test_refresh(token_manager): mock_keycloak.return_value.a_refresh_token.assert_called_once_with( 'test_refresh_token' ) + + +@pytest.mark.asyncio +async def test_disable_keycloak_user_success(token_manager): + """Test successful disabling of a Keycloak user account.""" + # Arrange + user_id = 'test_user_id' + email = 'user@colsch.us' + mock_user = { + 'id': user_id, + 'username': 'testuser', + 'email': email, + 'emailVerified': True, + } + + with patch('server.auth.token_manager.get_keycloak_admin') as mock_get_admin: + mock_admin = MagicMock() + mock_admin.a_get_user = AsyncMock(return_value=mock_user) + mock_admin.a_update_user = AsyncMock() + mock_get_admin.return_value = mock_admin + + # Act + await token_manager.disable_keycloak_user(user_id, email) + + # Assert + mock_admin.a_get_user.assert_called_once_with(user_id) + mock_admin.a_update_user.assert_called_once_with( + user_id=user_id, + payload={ + 'enabled': False, + 'username': 'testuser', + 'email': email, + 'emailVerified': True, + }, + ) + + +@pytest.mark.asyncio +async def test_disable_keycloak_user_without_email(token_manager): + """Test disabling Keycloak user without providing email.""" + # Arrange + user_id = 'test_user_id' + mock_user = { + 'id': user_id, + 'username': 'testuser', + 'email': 'user@example.com', + 'emailVerified': False, + } + + with patch('server.auth.token_manager.get_keycloak_admin') as mock_get_admin: + mock_admin = MagicMock() + mock_admin.a_get_user = AsyncMock(return_value=mock_user) + mock_admin.a_update_user = AsyncMock() + mock_get_admin.return_value = mock_admin + + # Act + await token_manager.disable_keycloak_user(user_id) + + # Assert + mock_admin.a_get_user.assert_called_once_with(user_id) + mock_admin.a_update_user.assert_called_once() + + +@pytest.mark.asyncio +async def test_disable_keycloak_user_not_found(token_manager): + """Test disabling Keycloak user when user is not found.""" + # Arrange + user_id = 'nonexistent_user_id' + email = 'user@colsch.us' + + with patch('server.auth.token_manager.get_keycloak_admin') as mock_get_admin: + mock_admin = MagicMock() + mock_admin.a_get_user = AsyncMock(return_value=None) + mock_get_admin.return_value = mock_admin + + # Act + await token_manager.disable_keycloak_user(user_id, email) + + # Assert + mock_admin.a_get_user.assert_called_once_with(user_id) + mock_admin.a_update_user.assert_not_called() + + +@pytest.mark.asyncio +async def test_disable_keycloak_user_exception_handling(token_manager): + """Test that disable_keycloak_user handles exceptions gracefully without raising.""" + # Arrange + user_id = 'test_user_id' + email = 'user@colsch.us' + + with patch('server.auth.token_manager.get_keycloak_admin') as mock_get_admin: + mock_admin = MagicMock() + mock_admin.a_get_user = AsyncMock(side_effect=Exception('Connection error')) + mock_get_admin.return_value = mock_admin + + # Act & Assert - should not raise exception + await token_manager.disable_keycloak_user(user_id, email) + + # Verify the method was called + mock_admin.a_get_user.assert_called_once_with(user_id) diff --git a/frontend/.eslintrc b/frontend/.eslintrc index c89d89c857..3efd6aea69 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -18,6 +18,8 @@ "i18next/no-literal-string": "error", "unused-imports/no-unused-imports": "error", "prettier/prettier": ["error"], + // Enforce using optional chaining (?.) instead of && chains for null/undefined checks + "@typescript-eslint/prefer-optional-chain": "error", // Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871 "import/extensions": [ "error", diff --git a/frontend/__tests__/components/features/conversation/agent-status.test.tsx b/frontend/__tests__/components/features/conversation/agent-status.test.tsx index a121ed37a8..3bfb70be24 100644 --- a/frontend/__tests__/components/features/conversation/agent-status.test.tsx +++ b/frontend/__tests__/components/features/conversation/agent-status.test.tsx @@ -5,7 +5,7 @@ import { MemoryRouter } from "react-router"; import { AgentStatus } from "#/components/features/controls/agent-status"; import { AgentState } from "#/types/agent-state"; import { useAgentState } from "#/hooks/use-agent-state"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; vi.mock("#/hooks/use-agent-state"); diff --git a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx index afdb8e84ba..b256fa14d9 100644 --- a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx +++ b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx @@ -12,7 +12,7 @@ import GitService from "#/api/git-service/git-service.api"; import { GitRepository } from "#/types/git"; import { RepositoryMicroagent } from "#/types/microagent-management"; import { Conversation } from "#/api/open-hands.types"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; // Mock hooks const mockUseUserProviders = vi.fn(); diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index 34ba1eaafd..cb164123c1 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -6,7 +6,7 @@ import { InteractiveChatBox } from "#/components/features/chat/interactive-chat- import { renderWithProviders } from "../../test-utils"; import { AgentState } from "#/types/agent-state"; import { useAgentState } from "#/hooks/use-agent-state"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; vi.mock("#/hooks/use-agent-state", () => ({ useAgentState: vi.fn(), diff --git a/frontend/__tests__/components/terminal/terminal.test.tsx b/frontend/__tests__/components/terminal/terminal.test.tsx index 15fb6357b2..ae25748a8b 100644 --- a/frontend/__tests__/components/terminal/terminal.test.tsx +++ b/frontend/__tests__/components/terminal/terminal.test.tsx @@ -1,7 +1,7 @@ import { act, screen } from "@testing-library/react"; import { renderWithProviders } from "test-utils"; import { vi, describe, afterEach, it, expect } from "vitest"; -import { Command, useCommandStore } from "#/state/command-store"; +import { Command, useCommandStore } from "#/stores/command-store"; import Terminal from "#/components/features/terminal/terminal"; const renderTerminal = (commands: Command[] = []) => { diff --git a/frontend/__tests__/conversation-websocket-handler.test.tsx b/frontend/__tests__/conversation-websocket-handler.test.tsx index f922a8876c..d3df1676fa 100644 --- a/frontend/__tests__/conversation-websocket-handler.test.tsx +++ b/frontend/__tests__/conversation-websocket-handler.test.tsx @@ -12,7 +12,7 @@ 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 { useCommandStore } from "#/stores/command-store"; import { createMockMessageEvent, createMockUserMessageEvent, @@ -453,18 +453,10 @@ describe("Conversation WebSocket Handler", () => { // Set up MSW to mock both the HTTP API and WebSocket connection mswServer.use( - http.get("/api/v1/events/count", ({ request }) => { - const url = new URL(request.url); - const conversationIdParam = url.searchParams.get( - "conversation_id__eq", - ); - - if (conversationIdParam === conversationId) { - return HttpResponse.json(expectedEventCount); - } - - return HttpResponse.json(0); - }), + http.get( + `http://localhost:3000/api/conversations/${conversationId}/events/count`, + () => HttpResponse.json(expectedEventCount), + ), wsLink.addEventListener("connection", ({ client, server }) => { server.connect(); // Send all history events @@ -520,18 +512,10 @@ describe("Conversation WebSocket Handler", () => { // Set up MSW to mock both the HTTP API and WebSocket connection mswServer.use( - http.get("/api/v1/events/count", ({ request }) => { - const url = new URL(request.url); - const conversationIdParam = url.searchParams.get( - "conversation_id__eq", - ); - - if (conversationIdParam === conversationId) { - return HttpResponse.json(0); - } - - return HttpResponse.json(0); - }), + http.get( + `http://localhost:3000/api/conversations/${conversationId}/events/count`, + () => HttpResponse.json(0), + ), wsLink.addEventListener("connection", ({ server }) => { server.connect(); // No events sent for empty history @@ -577,18 +561,10 @@ describe("Conversation WebSocket Handler", () => { // Set up MSW to mock both the HTTP API and WebSocket connection mswServer.use( - http.get("/api/v1/events/count", ({ request }) => { - const url = new URL(request.url); - const conversationIdParam = url.searchParams.get( - "conversation_id__eq", - ); - - if (conversationIdParam === conversationId) { - return HttpResponse.json(expectedEventCount); - } - - return HttpResponse.json(0); - }), + http.get( + `http://localhost:3000/api/conversations/${conversationId}/events/count`, + () => HttpResponse.json(expectedEventCount), + ), wsLink.addEventListener("connection", ({ client, server }) => { server.connect(); // Send all history events diff --git a/frontend/__tests__/hooks/use-terminal.test.tsx b/frontend/__tests__/hooks/use-terminal.test.tsx index 08144503b5..0e4761b21b 100644 --- a/frontend/__tests__/hooks/use-terminal.test.tsx +++ b/frontend/__tests__/hooks/use-terminal.test.tsx @@ -1,7 +1,6 @@ -/* eslint-disable max-classes-per-file */ import { beforeAll, describe, expect, it, vi, afterEach } from "vitest"; import { useTerminal } from "#/hooks/use-terminal"; -import { Command, useCommandStore } from "#/state/command-store"; +import { Command, useCommandStore } from "#/stores/command-store"; import { renderWithProviders } from "../../test-utils"; // Mock the WsClient context @@ -43,6 +42,11 @@ describe("useTerminal", () => { write: vi.fn(), writeln: vi.fn(), dispose: vi.fn(), + element: document.createElement("div"), + })); + + const mockFitAddon = vi.hoisted(() => ({ + fit: vi.fn(), })); beforeAll(() => { @@ -68,6 +72,15 @@ describe("useTerminal", () => { writeln = mockTerminal.writeln; dispose = mockTerminal.dispose; + + element = mockTerminal.element; + }, + })); + + // mock FitAddon + vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit = mockFitAddon.fit; }, })); }); @@ -96,4 +109,18 @@ describe("useTerminal", () => { expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello"); expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello"); }); + + it("should not call fit() when terminal.element is null", () => { + // Temporarily set element to null to simulate terminal not being opened + const originalElement = mockTerminal.element; + mockTerminal.element = null as unknown as HTMLDivElement; + + renderWithProviders(); + + // fit() should not be called because terminal.element is null + expect(mockFitAddon.fit).not.toHaveBeenCalled(); + + // Restore original element + mockTerminal.element = originalElement; + }); }); diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 8cb1239c0a..68e44d73e9 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -72,7 +72,7 @@ describe("Content", () => { await waitFor(() => { expect(provider).toHaveValue("OpenHands"); - expect(model).toHaveValue("claude-sonnet-4-20250514"); + expect(model).toHaveValue("claude-opus-4-5-20251101"); expect(apiKey).toHaveValue(""); expect(apiKey).toHaveProperty("placeholder", ""); @@ -190,7 +190,7 @@ describe("Content", () => { const agent = screen.getByTestId("agent-input"); const condensor = screen.getByTestId("enable-memory-condenser-switch"); - expect(model).toHaveValue("openhands/claude-sonnet-4-20250514"); + expect(model).toHaveValue("openhands/claude-opus-4-5-20251101"); expect(baseUrl).toHaveValue(""); expect(apiKey).toHaveValue(""); expect(apiKey).toHaveProperty("placeholder", ""); @@ -910,6 +910,162 @@ describe("Form submission", () => { }); }); +describe("View persistence after saving advanced settings", () => { + it("should remain on Advanced view after saving when memory condenser is disabled", async () => { + // Arrange: Start with default settings (basic view) + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); + saveSettingsSpy.mockResolvedValue(true); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + // Verify we start in basic view + expect(screen.getByTestId("llm-settings-form-basic")).toBeInTheDocument(); + + // Act: User manually switches to Advanced view + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + await userEvent.click(advancedSwitch); + await screen.findByTestId("llm-settings-form-advanced"); + + // User disables memory condenser (advanced-only setting) + const condenserSwitch = screen.getByTestId( + "enable-memory-condenser-switch", + ); + expect(condenserSwitch).toBeChecked(); + await userEvent.click(condenserSwitch); + expect(condenserSwitch).not.toBeChecked(); + + // Mock the updated settings that will be returned after save + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + enable_default_condenser: false, // Now disabled + }); + + // User saves settings + const submitButton = screen.getByTestId("submit-button"); + await userEvent.click(submitButton); + + // Assert: View should remain on Advanced after save + await waitFor(() => { + expect( + screen.getByTestId("llm-settings-form-advanced"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("llm-settings-form-basic"), + ).not.toBeInTheDocument(); + expect(advancedSwitch).toBeChecked(); + }); + }); + + it("should remain on Advanced view after saving when condenser max size is customized", async () => { + // Arrange: Start with default settings + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); + saveSettingsSpy.mockResolvedValue(true); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + // Act: User manually switches to Advanced view + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + await userEvent.click(advancedSwitch); + await screen.findByTestId("llm-settings-form-advanced"); + + // User sets custom condenser max size (advanced-only setting) + const condenserMaxSizeInput = screen.getByTestId( + "condenser-max-size-input", + ); + await userEvent.clear(condenserMaxSizeInput); + await userEvent.type(condenserMaxSizeInput, "200"); + + // Mock the updated settings that will be returned after save + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + condenser_max_size: 200, // Custom value + }); + + // User saves settings + const submitButton = screen.getByTestId("submit-button"); + await userEvent.click(submitButton); + + // Assert: View should remain on Advanced after save + await waitFor(() => { + expect( + screen.getByTestId("llm-settings-form-advanced"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("llm-settings-form-basic"), + ).not.toBeInTheDocument(); + expect(advancedSwitch).toBeChecked(); + }); + }); + + it("should remain on Advanced view after saving when search API key is set", async () => { + // Arrange: Start with default settings (non-SaaS mode to show search API key field) + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + GITHUB_CLIENT_ID: "fake-github-client-id", + POSTHOG_CLIENT_KEY: "fake-posthog-client-key", + FEATURE_FLAGS: { + ENABLE_BILLING: false, + HIDE_LLM_SETTINGS: false, + ENABLE_JIRA: false, + ENABLE_JIRA_DC: false, + ENABLE_LINEAR: false, + }, + }); + + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + search_api_key: "", // Default empty value + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); + saveSettingsSpy.mockResolvedValue(true); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + // Act: User manually switches to Advanced view + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + await userEvent.click(advancedSwitch); + await screen.findByTestId("llm-settings-form-advanced"); + + // User sets search API key (advanced-only setting) + const searchApiKeyInput = screen.getByTestId("search-api-key-input"); + await userEvent.type(searchApiKeyInput, "test-search-api-key"); + + // Mock the updated settings that will be returned after save + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + search_api_key: "test-search-api-key", // Now set + }); + + // User saves settings + const submitButton = screen.getByTestId("submit-button"); + await userEvent.click(submitButton); + + // Assert: View should remain on Advanced after save + await waitFor(() => { + expect( + screen.getByTestId("llm-settings-form-advanced"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("llm-settings-form-basic"), + ).not.toBeInTheDocument(); + expect(advancedSwitch).toBeChecked(); + }); + }); +}); + describe("Status toasts", () => { describe("Basic form", () => { it("should call displaySuccessToast when the settings are saved", async () => { diff --git a/frontend/__tests__/services/actions.test.ts b/frontend/__tests__/services/actions.test.ts index 44700aef2c..8054b999d1 100644 --- a/frontend/__tests__/services/actions.test.ts +++ b/frontend/__tests__/services/actions.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { handleStatusMessage } from "#/services/actions"; import { StatusMessage } from "#/types/message"; import { queryClient } from "#/query-client-config"; -import { useStatusStore } from "#/state/status-store"; +import { useStatusStore } from "#/stores/status-store"; import { trackError } from "#/utils/error-handler"; // Mock dependencies @@ -12,7 +12,7 @@ vi.mock("#/query-client-config", () => ({ }, })); -vi.mock("#/state/status-store", () => ({ +vi.mock("#/stores/status-store", () => ({ useStatusStore: { getState: vi.fn(() => ({ setCurStatusMessage: vi.fn(), diff --git a/frontend/__tests__/services/actions.test.tsx b/frontend/__tests__/services/actions.test.tsx index c6e2ac76e2..555fd18caa 100644 --- a/frontend/__tests__/services/actions.test.tsx +++ b/frontend/__tests__/services/actions.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import ActionType from "#/types/action-type"; import { ActionMessage } from "#/types/message"; -import { useCommandStore } from "#/state/command-store"; +import { useCommandStore } from "#/stores/command-store"; const mockDispatch = vi.fn(); const mockAppendInput = vi.fn(); diff --git a/frontend/__tests__/utils/has-advanced-settings-set.test.ts b/frontend/__tests__/utils/has-advanced-settings-set.test.ts index 36c7a7b609..be928262d1 100644 --- a/frontend/__tests__/utils/has-advanced-settings-set.test.ts +++ b/frontend/__tests__/utils/has-advanced-settings-set.test.ts @@ -29,5 +29,75 @@ describe("hasAdvancedSettingsSet", () => { }), ).toBe(true); }); + + test("enable_default_condenser is disabled", () => { + // Arrange + const settings = { + ...DEFAULT_SETTINGS, + enable_default_condenser: false, + }; + + // Act + const result = hasAdvancedSettingsSet(settings); + + // Assert + expect(result).toBe(true); + }); + + test("condenser_max_size is customized above default", () => { + // Arrange + const settings = { + ...DEFAULT_SETTINGS, + condenser_max_size: 200, + }; + + // Act + const result = hasAdvancedSettingsSet(settings); + + // Assert + expect(result).toBe(true); + }); + + test("condenser_max_size is customized below default", () => { + // Arrange + const settings = { + ...DEFAULT_SETTINGS, + condenser_max_size: 50, + }; + + // Act + const result = hasAdvancedSettingsSet(settings); + + // Assert + expect(result).toBe(true); + }); + + test("search_api_key is set to non-empty value", () => { + // Arrange + const settings = { + ...DEFAULT_SETTINGS, + search_api_key: "test-api-key-123", + }; + + // Act + const result = hasAdvancedSettingsSet(settings); + + // Assert + expect(result).toBe(true); + }); + + test("search_api_key with whitespace is treated as set", () => { + // Arrange + const settings = { + ...DEFAULT_SETTINGS, + search_api_key: " test-key ", + }; + + // Act + const result = hasAdvancedSettingsSet(settings); + + // Assert + expect(result).toBe(true); + }); }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e130cad40f..33717ced21 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,8 +11,8 @@ "@heroui/react": "2.8.6", "@microlink/react-json-view": "^1.26.2", "@monaco-editor/react": "^4.7.0-rc.0", - "@react-router/node": "^7.10.1", - "@react-router/serve": "^7.10.1", + "@react-router/node": "^7.11.0", + "@react-router/serve": "^7.11.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.12", "@uidotdev/usehooks": "^2.4.1", @@ -28,16 +28,16 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.32", - "lucide-react": "^0.561.0", + "lucide-react": "^0.562.0", "monaco-editor": "^0.55.1", - "posthog-js": "^1.309.0", + "posthog-js": "^1.309.1", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", "react-i18next": "^16.5.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-router": "^7.10.1", + "react-router": "^7.11.0", "react-syntax-highlighter": "^16.1.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -51,7 +51,7 @@ "devDependencies": { "@mswjs/socket.io-binding": "^0.2.0", "@playwright/test": "^1.57.0", - "@react-router/dev": "^7.10.1", + "@react-router/dev": "^7.11.0", "@tailwindcss/typography": "^0.5.19", "@tanstack/eslint-plugin-query": "^5.91.0", "@testing-library/dom": "^10.4.1", @@ -85,7 +85,7 @@ "tailwindcss": "^4.1.8", "typescript": "^5.9.3", "vite-plugin-svgr": "^4.5.0", - "vite-tsconfig-paths": "^6.0.2", + "vite-tsconfig-paths": "^6.0.3", "vitest": "^4.0.14" }, "engines": { @@ -192,6 +192,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -731,6 +732,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -777,6 +779,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2328,6 +2331,7 @@ "version": "2.4.24", "resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.24.tgz", "integrity": "sha512-9GKQgUc91otQfwmq6TLE72QKxtB341aK5NpBHS3gRoWYEuNN714Zl3OXwIZNvdXPJpsTaUo1ID1ibJU9tfgwdg==", + "peer": true, "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/system-rsc": "2.3.21", @@ -2407,6 +2411,7 @@ "version": "2.4.24", "resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.24.tgz", "integrity": "sha512-lL+anmY4GGWwKyTbJ2PEBZE4talIZ3hu4yGpku9TktCVG2nC2YTwiWQFJ+Jcbf8Cf9vuLzI1sla5bz2jUqiBRA==", + "peer": true, "dependencies": { "@heroui/shared-utils": "2.1.12", "color": "^4.2.3", @@ -3192,9 +3197,9 @@ "license": "MIT" }, "node_modules/@posthog/core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.8.0.tgz", - "integrity": "sha512-SfmG1EdbR+2zpQccgBUxM/snCROB9WGkY7VH1r9iaoTNqoaN9IkmIEA/07cZLY4DxVP8jt6Vdfe3s84xksac1g==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.8.1.tgz", + "integrity": "sha512-jfzBtQIk9auRi/biO+G/gumK5KxqsD5wOr7XpYMROE/I3pazjP4zIziinp21iQuIQJMXrDvwt9Af3njgOGwtew==", "dependencies": { "cross-spawn": "^7.0.6" } @@ -3872,11 +3877,10 @@ } }, "node_modules/@react-router/dev": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.10.1.tgz", - "integrity": "sha512-kap9O8rTN6b3vxjd+0SGjhm5vqiAZHMmOX1Hc7Y4KXRVVdusn+0+hxs44cDSfbW6Z6fCLw6GXXe0Kr+DJIRezw==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.11.0.tgz", + "integrity": "sha512-g1ou5Zw3r4mCU0L+EXH4vRtAiyt8qz1JOvL1k+PW4rZ4+71h5nBy/fLgD7cg5BnzQZmjRO1PzCgpF5BIrlKYxQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", @@ -3885,7 +3889,7 @@ "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", - "@react-router/node": "7.10.1", + "@react-router/node": "7.11.0", "@remix-run/node-fetch-server": "^0.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", @@ -3914,9 +3918,10 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@react-router/serve": "^7.10.1", - "@vitejs/plugin-rsc": "*", - "react-router": "^7.10.1", + "@react-router/serve": "^7.11.0", + "@vitejs/plugin-rsc": "~0.5.7", + "react-router": "^7.11.0", + "react-server-dom-webpack": "^19.2.3", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" @@ -3928,6 +3933,9 @@ "@vitejs/plugin-rsc": { "optional": true }, + "react-server-dom-webpack": { + "optional": true + }, "typescript": { "optional": true }, @@ -3936,33 +3944,10 @@ } } }, - "node_modules/@react-router/express": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.10.1.tgz", - "integrity": "sha512-O7xjg6wWHfrsnPyVWgQG+tCamIE09SqLqtHwa1tAFzKPjcDpCw4S4+/OkJvNXLtBL60H3VhZ1r2OQgXBgGOMpw==", - "license": "MIT", - "dependencies": { - "@react-router/node": "7.10.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "express": "^4.17.1 || ^5", - "react-router": "7.10.1", - "typescript": "^5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@react-router/node": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.10.1.tgz", - "integrity": "sha512-RLmjlR1zQu+ve8ibI0lu91pJrXGcmfkvsrQl7z/eTc5V5FZgl0OvQVWL5JDWBlBZyzdLMQQekUOX5WcPhCP1FQ==", - "license": "MIT", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.11.0.tgz", + "integrity": "sha512-11ha8EW+F7wTMmPz2pdi11LJxz2irtuksiCpunpZjtpPmYU37S+GGihG8vFeTa2xFPNunEaHNlfzKyzeYm570Q==", "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, @@ -3970,7 +3955,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.10.1", + "react-router": "7.11.0", "typescript": "^5.1.0" }, "peerDependenciesMeta": { @@ -3980,18 +3965,17 @@ } }, "node_modules/@react-router/serve": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.10.1.tgz", - "integrity": "sha512-qYco7sFpbRgoKJKsCgJmFBQwaLVsLv255K8vbPodnXe13YBEzV/ugIqRCYVz2hghvlPiEKgaHh2On0s/5npn6w==", - "license": "MIT", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.11.0.tgz", + "integrity": "sha512-U5Ht9PmUYF4Ti1ssaWlddLY4ZCbXBtHDGFU/u1h3VsHqleSdHsFuGAFrr/ZEuqTuEWp1CLqn2npEDAmlV9IUKQ==", "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", - "@react-router/express": "7.10.1", - "@react-router/node": "7.10.1", - "compression": "^1.7.4", + "@react-router/express": "7.11.0", + "@react-router/node": "7.11.0", + "compression": "^1.8.1", "express": "^4.19.2", "get-port": "5.1.1", - "morgan": "^1.10.0", + "morgan": "^1.10.1", "source-map-support": "^0.5.21" }, "bin": { @@ -4001,7 +3985,28 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.10.1" + "react-router": "7.11.0" + } + }, + "node_modules/@react-router/serve/node_modules/@react-router/express": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.11.0.tgz", + "integrity": "sha512-o5DeO9tqUrZcUWAgmPGgK4I/S6iFpqnj/e20xMGA04trk+90b9KAx9eqmRMgHERubVKANTM9gTDPduobQjeH1A==", + "dependencies": { + "@react-router/node": "7.11.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "express": "^4.17.1 || ^5", + "react-router": "7.11.0", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@react-stately/calendar": { @@ -5122,6 +5127,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5582,6 +5588,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5759,6 +5766,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5774,6 +5782,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5784,6 +5793,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5824,6 +5834,7 @@ "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", @@ -5881,6 +5892,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -6394,13 +6406,13 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -6413,7 +6425,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6424,6 +6435,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6554,8 +6566,7 @@ "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, "node_modules/array-includes": { "version": "3.1.9", @@ -6886,7 +6897,6 @@ "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -6910,7 +6920,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -6918,8 +6927,7 @@ "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/brace-expansion": { "version": "2.0.2", @@ -6964,6 +6972,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7485,7 +7494,6 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" }, @@ -7497,7 +7505,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -7513,7 +7520,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -7521,8 +7527,7 @@ "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, "node_modules/core-js": { "version": "3.47.0", @@ -7656,7 +7661,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -7866,7 +7872,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -8001,7 +8006,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -8354,8 +8358,7 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -8377,6 +8380,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8500,6 +8504,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8580,6 +8585,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8671,6 +8677,7 @@ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -8766,6 +8773,7 @@ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -8799,6 +8807,7 @@ "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9029,7 +9038,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -9068,7 +9076,7 @@ "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9114,7 +9122,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -9122,8 +9129,7 @@ "node_modules/express/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/exsolve": { "version": "1.0.8", @@ -9255,7 +9261,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9273,7 +9278,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -9281,8 +9285,7 @@ "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/find-up": { "version": "5.0.0", @@ -9395,7 +9398,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -9405,6 +9407,7 @@ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", "license": "MIT", + "peer": true, "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", @@ -9431,7 +9434,6 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -9996,7 +9998,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -10074,6 +10075,7 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -10108,7 +10110,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -10226,7 +10227,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", "engines": { "node": ">= 0.10" } @@ -10853,6 +10853,7 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -11460,9 +11461,9 @@ } }, "node_modules/lucide-react": { - "version": "0.561.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", - "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -11852,7 +11853,6 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -11861,7 +11861,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -11880,7 +11879,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -12466,7 +12464,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", "bin": { "mime": "cli.js" }, @@ -12558,6 +12555,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -12652,6 +12650,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", @@ -12998,7 +12997,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -13203,7 +13201,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -13247,8 +13244,7 @@ "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "node_modules/path-type": { "version": "4.0.0", @@ -13379,6 +13375,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13403,11 +13400,11 @@ } }, "node_modules/posthog-js": { - "version": "1.309.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.309.0.tgz", - "integrity": "sha512-SmFF0uKX3tNTgQOW4mR4shGLQ0YYG0FXyKTz13SbIH83/FtAJedppOIL7s0y9e7rjogBh6LsPekphhchs9Kh1Q==", + "version": "1.309.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.309.1.tgz", + "integrity": "sha512-JUJcQhYzNNKO0cgnSbowCsVi2RTu75XGZ2EmnTQti4tMGRCTOv/HCnZasdFniBGZ0rLugQkaScYca/84Ta2u5Q==", "dependencies": { - "@posthog/core": "1.8.0", + "@posthog/core": "1.8.1", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", @@ -13440,6 +13437,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13551,7 +13549,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -13580,7 +13577,6 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" }, @@ -13616,7 +13612,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -13625,7 +13620,6 @@ "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -13640,6 +13634,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13688,6 +13683,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13797,10 +13793,10 @@ } }, "node_modules/react-router": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", - "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", - "license": "MIT", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -14162,6 +14158,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14365,10 +14362,9 @@ } }, "node_modules/send": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", - "integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", - "license": "MIT", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -14376,13 +14372,13 @@ "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -14392,7 +14388,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -14400,122 +14395,22 @@ "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "license": "MIT", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/serve-static/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-static/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-static/node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -14574,8 +14469,7 @@ "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -15260,6 +15154,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -15386,6 +15281,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15440,7 +15336,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", "engines": { "node": ">=0.6" } @@ -15596,7 +15491,6 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -15689,6 +15583,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15814,7 +15709,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -15935,7 +15829,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", "engines": { "node": ">= 0.4.0" } @@ -15996,6 +15889,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16111,9 +16005,9 @@ } }, "node_modules/vite-tsconfig-paths": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.0.2.tgz", - "integrity": "sha512-c06LOO8fWB5RuEPpEIHXU9t7Dt4DoiPIljnKws9UygIaQo6PoFKawTftz5/QVcO+6pOs/HHWycnq4UrZkWVYnQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.0.3.tgz", + "integrity": "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg==", "dev": true, "dependencies": { "debug": "^4.1.1", @@ -16165,6 +16059,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16177,6 +16072,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/frontend/package.json b/frontend/package.json index 90636fed77..f08f6ea3b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,8 @@ "@heroui/react": "2.8.6", "@microlink/react-json-view": "^1.26.2", "@monaco-editor/react": "^4.7.0-rc.0", - "@react-router/node": "^7.10.1", - "@react-router/serve": "^7.10.1", + "@react-router/node": "^7.11.0", + "@react-router/serve": "^7.11.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.12", "@uidotdev/usehooks": "^2.4.1", @@ -27,16 +27,16 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.32", - "lucide-react": "^0.561.0", + "lucide-react": "^0.562.0", "monaco-editor": "^0.55.1", - "posthog-js": "^1.309.0", + "posthog-js": "^1.309.1", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", "react-i18next": "^16.5.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-router": "^7.10.1", + "react-router": "^7.11.0", "react-syntax-highlighter": "^16.1.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -82,7 +82,7 @@ "devDependencies": { "@mswjs/socket.io-binding": "^0.2.0", "@playwright/test": "^1.57.0", - "@react-router/dev": "^7.10.1", + "@react-router/dev": "^7.11.0", "@tailwindcss/typography": "^0.5.19", "@tanstack/eslint-plugin-query": "^5.91.0", "@testing-library/dom": "^10.4.1", @@ -116,7 +116,7 @@ "tailwindcss": "^4.1.8", "typescript": "^5.9.3", "vite-plugin-svgr": "^4.5.0", - "vite-tsconfig-paths": "^6.0.2", + "vite-tsconfig-paths": "^6.0.3", "vitest": "^4.0.14" }, "packageManager": "npm@10.5.0", diff --git a/frontend/src/ui/microagent-management-service/microagent-management-service.api.ts b/frontend/src/api/microagent-management-service/microagent-management-service.api.ts similarity index 100% rename from frontend/src/ui/microagent-management-service/microagent-management-service.api.ts rename to frontend/src/api/microagent-management-service/microagent-management-service.api.ts diff --git a/frontend/src/components/features/browser/browser.tsx b/frontend/src/components/features/browser/browser.tsx index 8c3842edd4..95c8f1fa1a 100644 --- a/frontend/src/components/features/browser/browser.tsx +++ b/frontend/src/components/features/browser/browser.tsx @@ -12,10 +12,9 @@ export function BrowserPanel() { reset(); }, [conversationId, reset]); - const imgSrc = - screenshotSrc && screenshotSrc.startsWith("data:image/png;base64,") - ? screenshotSrc - : `data:image/png;base64,${screenshotSrc || ""}`; + const imgSrc = screenshotSrc?.startsWith("data:image/png;base64,") + ? screenshotSrc + : `data:image/png;base64,${screenshotSrc ?? ""}`; return (
diff --git a/frontend/src/components/features/chat/change-agent-button.tsx b/frontend/src/components/features/chat/change-agent-button.tsx index 68a0bd2699..28de891db9 100644 --- a/frontend/src/components/features/chat/change-agent-button.tsx +++ b/frontend/src/components/features/chat/change-agent-button.tsx @@ -5,7 +5,7 @@ import { I18nKey } from "#/i18n/declaration"; import CodeTagIcon from "#/icons/code-tag.svg?react"; import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react"; import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { ChangeAgentContextMenu } from "./change-agent-context-menu"; import { cn } from "#/utils/utils"; import { USE_PLANNING_AGENT } from "#/utils/feature-flags"; diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index f37bd59c26..84c269dac3 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -38,7 +38,7 @@ import { import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; import { useConfig } from "#/hooks/query/use-config"; import { validateFiles } from "#/utils/file-validation"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import ConfirmationModeEnabled from "./confirmation-mode-enabled"; import { isV0Event, diff --git a/frontend/src/components/features/chat/chat-suggestions.tsx b/frontend/src/components/features/chat/chat-suggestions.tsx index 8abc5aa41c..1f9a9345ad 100644 --- a/frontend/src/components/features/chat/chat-suggestions.tsx +++ b/frontend/src/components/features/chat/chat-suggestions.tsx @@ -4,7 +4,7 @@ import { Suggestions } from "#/components/features/suggestions/suggestions"; import { I18nKey } from "#/i18n/declaration"; import BuildIt from "#/icons/build-it.svg?react"; import { SUGGESTIONS } from "#/utils/suggestions"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; interface ChatSuggestionsProps { onSuggestionsClick: (value: string) => void; diff --git a/frontend/src/components/features/chat/components/chat-input-container.tsx b/frontend/src/components/features/chat/components/chat-input-container.tsx index acba3074f5..e950ce785b 100644 --- a/frontend/src/components/features/chat/components/chat-input-container.tsx +++ b/frontend/src/components/features/chat/components/chat-input-container.tsx @@ -3,7 +3,7 @@ import { DragOver } from "../drag-over"; import { UploadedFiles } from "../uploaded-files"; import { ChatInputRow } from "./chat-input-row"; import { ChatInputActions } from "./chat-input-actions"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { cn } from "#/utils/utils"; interface ChatInputContainerProps { diff --git a/frontend/src/components/features/chat/components/chat-input-field.tsx b/frontend/src/components/features/chat/components/chat-input-field.tsx index 4c52b7980b..7a6fe4f52b 100644 --- a/frontend/src/components/features/chat/components/chat-input-field.tsx +++ b/frontend/src/components/features/chat/components/chat-input-field.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; interface ChatInputFieldProps { chatInputRef: React.RefObject; diff --git a/frontend/src/components/features/chat/custom-chat-input.tsx b/frontend/src/components/features/chat/custom-chat-input.tsx index 92ec264a34..624457b35b 100644 --- a/frontend/src/components/features/chat/custom-chat-input.tsx +++ b/frontend/src/components/features/chat/custom-chat-input.tsx @@ -8,7 +8,7 @@ import { useChatSubmission } from "#/hooks/chat/use-chat-submission"; import { ChatInputGrip } from "./components/chat-input-grip"; import { ChatInputContainer } from "./components/chat-input-container"; import { HiddenFileInput } from "./components/hidden-file-input"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; export interface CustomChatInputProps { disabled?: boolean; diff --git a/frontend/src/components/features/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/features/chat/event-content-helpers/get-observation-content.ts index 435a686918..11276a4e39 100644 --- a/frontend/src/components/features/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/features/chat/event-content-helpers/get-observation-content.ts @@ -140,7 +140,7 @@ const getTaskTrackingObservationContent = ( content += "\n\n**Task List:** Empty"; } - if (event.content && event.content.trim()) { + if (event.content?.trim()) { content += `\n\n**Result:** ${event.content.trim()}`; } diff --git a/frontend/src/components/features/chat/interactive-chat-box.tsx b/frontend/src/components/features/chat/interactive-chat-box.tsx index 56a4def14d..a2f1df8348 100644 --- a/frontend/src/components/features/chat/interactive-chat-box.tsx +++ b/frontend/src/components/features/chat/interactive-chat-box.tsx @@ -5,7 +5,7 @@ import { CustomChatInput } from "./custom-chat-input"; import { AgentState } from "#/types/agent-state"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { GitControlBar } from "./git-control-bar"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { useAgentState } from "#/hooks/use-agent-state"; import { processFiles, processImages } from "#/utils/file-processing"; import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; diff --git a/frontend/src/components/features/chat/messages.tsx b/frontend/src/components/features/chat/messages.tsx index 0d9032164d..6e68089b13 100644 --- a/frontend/src/components/features/chat/messages.tsx +++ b/frontend/src/components/features/chat/messages.tsx @@ -192,8 +192,7 @@ export const Messages: React.FC = React.memo( ) => { const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`; if ( - !conversation || - !conversation.selected_repository || + !conversation?.selected_repository || !conversation.selected_branch || !conversation.git_provider || !selectedEventId diff --git a/frontend/src/components/features/chat/uploaded-files.tsx b/frontend/src/components/features/chat/uploaded-files.tsx index 78b0c3dc49..ced7849ef0 100644 --- a/frontend/src/components/features/chat/uploaded-files.tsx +++ b/frontend/src/components/features/chat/uploaded-files.tsx @@ -1,6 +1,6 @@ import { UploadedFile } from "./uploaded-file"; import { UploadedImage } from "./uploaded-image"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; export function UploadedFiles() { const { diff --git a/frontend/src/components/features/controls/agent-status.tsx b/frontend/src/components/features/controls/agent-status.tsx index 675b5881a3..f62472bf95 100644 --- a/frontend/src/components/features/controls/agent-status.tsx +++ b/frontend/src/components/features/controls/agent-status.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { useEffect } from "react"; -import { useStatusStore } from "#/state/status-store"; +import { useStatusStore } from "#/stores/status-store"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { getStatusCode } from "#/utils/status"; import { ChatStopButton } from "../chat/chat-stop-button"; @@ -9,7 +9,7 @@ import ClockIcon from "#/icons/u-clock-three.svg?react"; import { ChatResumeAgentButton } from "../chat/chat-play-button"; import { cn, isTaskPolling } from "#/utils/utils"; import { AgentLoading } from "./agent-loading"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import CircleErrorIcon from "#/icons/circle-error.svg?react"; import { useAgentState } from "#/hooks/use-agent-state"; import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; diff --git a/frontend/src/components/features/controls/git-tools-submenu.tsx b/frontend/src/components/features/controls/git-tools-submenu.tsx index e55dd93952..97db554aba 100644 --- a/frontend/src/components/features/controls/git-tools-submenu.tsx +++ b/frontend/src/components/features/controls/git-tools-submenu.tsx @@ -10,7 +10,7 @@ import { getCreatePRPrompt, getCreateNewBranchPrompt, } from "#/utils/utils"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import ArrowUpIcon from "#/icons/u-arrow-up.svg?react"; import ArrowDownIcon from "#/icons/u-arrow-down.svg?react"; diff --git a/frontend/src/components/features/controls/macros-submenu.tsx b/frontend/src/components/features/controls/macros-submenu.tsx index 8705e11959..b167501d0b 100644 --- a/frontend/src/components/features/controls/macros-submenu.tsx +++ b/frontend/src/components/features/controls/macros-submenu.tsx @@ -8,7 +8,7 @@ import PrStatusIcon from "#/icons/pr-status.svg?react"; import DocumentIcon from "#/icons/document.svg?react"; import WaterIcon from "#/icons/u-water.svg?react"; import { I18nKey } from "#/i18n/declaration"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { REPO_SUGGESTIONS } from "#/utils/suggestions/repo-suggestions"; import { CONTEXT_MENU_ICON_TEXT_CLASSNAME } from "#/utils/constants"; diff --git a/frontend/src/components/features/conversation-panel/conversation-panel-wrapper.tsx b/frontend/src/components/features/conversation-panel/conversation-panel-wrapper.tsx index 3ecb6d01a6..57bde06727 100644 --- a/frontend/src/components/features/conversation-panel/conversation-panel-wrapper.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-panel-wrapper.tsx @@ -20,7 +20,7 @@ export function ConversationPanelWrapper({ return ReactDOM.createPortal(
diff --git a/frontend/src/components/features/conversation/conversation-main/conversation-main.tsx b/frontend/src/components/features/conversation/conversation-main/conversation-main.tsx index d5c7ad3a0e..0c6d639b3b 100644 --- a/frontend/src/components/features/conversation/conversation-main/conversation-main.tsx +++ b/frontend/src/components/features/conversation/conversation-main/conversation-main.tsx @@ -1,7 +1,7 @@ import { useWindowSize } from "@uidotdev/usehooks"; import { MobileLayout } from "./mobile-layout"; import { DesktopLayout } from "./desktop-layout"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; export function ConversationMain() { const { width } = useWindowSize(); diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx index 39b68c9033..f9f2bef251 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx @@ -7,7 +7,7 @@ import { TabContainer } from "./tab-container"; import { TabContentArea } from "./tab-content-area"; import { ConversationTabTitle } from "../conversation-tab-title"; import Terminal from "#/components/features/terminal/terminal"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { useConversationId } from "#/hooks/use-conversation-id"; // Lazy load all tab components diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx index eedb9010e8..ed649f373c 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx @@ -16,7 +16,7 @@ import { VSCodeTooltipContent } from "./vscode-tooltip-content"; import { useConversationStore, type ConversationTab, -} from "#/state/conversation-store"; +} from "#/stores/conversation-store"; import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu"; import { USE_PLANNING_AGENT } from "#/utils/feature-flags"; import { useConversationId } from "#/hooks/use-conversation-id"; diff --git a/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx b/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx index fdc9b21b00..c5ab171ca8 100644 --- a/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx +++ b/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx @@ -75,7 +75,7 @@ export function GitProviderDropdown({ } // If no input value, show all providers - if (!inputValue || !inputValue.trim()) { + if (!inputValue?.trim()) { return providers; } diff --git a/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx b/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx index 485f574f79..45b75bbd9f 100644 --- a/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx +++ b/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx @@ -99,7 +99,7 @@ export function GitRepoDropdown({ ); // If no input value, return all recent repos for this provider - if (!inputValue || !inputValue.trim()) { + if (!inputValue?.trim()) { return providerFilteredRepos; } @@ -139,7 +139,7 @@ export function GitRepoDropdown({ baseRepositories = repositories; } // If no input value, show all repositories - else if (!inputValue || !inputValue.trim()) { + else if (!inputValue?.trim()) { baseRepositories = repositories; } // For URL inputs, use the processed search input for filtering @@ -246,8 +246,7 @@ export function GitRepoDropdown({ // Create sticky footer item for GitHub provider const stickyFooterItem = useMemo(() => { if ( - !config || - !config.APP_SLUG || + !config?.APP_SLUG || provider !== ProviderOptions.github || config.APP_MODE !== "saas" ) diff --git a/frontend/src/components/features/home/shared/dropdown-item.tsx b/frontend/src/components/features/home/shared/dropdown-item.tsx index 08e22dc12b..36a0e25967 100644 --- a/frontend/src/components/features/home/shared/dropdown-item.tsx +++ b/frontend/src/components/features/home/shared/dropdown-item.tsx @@ -45,7 +45,7 @@ export function DropdownItem({ // eslint-disable-next-line react/jsx-props-no-spreading
  • - {renderIcon && renderIcon(item)} + {renderIcon?.(item)} {getDisplayText(item)}
  • diff --git a/frontend/src/components/features/microagent-management/microagent-management-add-microagent-button.tsx b/frontend/src/components/features/microagent-management/microagent-management-add-microagent-button.tsx index 0fca5981c1..9bcb282ce8 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-add-microagent-button.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-add-microagent-button.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { GitRepository } from "#/types/git"; interface MicroagentManagementAddMicroagentButtonProps { diff --git a/frontend/src/components/features/microagent-management/microagent-management-content.tsx b/frontend/src/components/features/microagent-management/microagent-management-content.tsx index dc16e1da98..fe50ea7849 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-content.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-content.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { MicroagentManagementSidebar } from "./microagent-management-sidebar"; import { MicroagentManagementMain } from "./microagent-management-main"; import { MicroagentManagementUpsertMicroagentModal } from "./microagent-management-upsert-microagent-modal"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple"; import { LearnThisRepoFormData, diff --git a/frontend/src/components/features/microagent-management/microagent-management-conversation-stopped.tsx b/frontend/src/components/features/microagent-management/microagent-management-conversation-stopped.tsx index 817c31f15f..6c1eb627fd 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-conversation-stopped.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-conversation-stopped.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { BrandButton } from "../settings/brand-button"; import { Loader } from "#/components/shared/loader"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; export function MicroagentManagementConversationStopped() { const { t } = useTranslation(); diff --git a/frontend/src/components/features/microagent-management/microagent-management-error.tsx b/frontend/src/components/features/microagent-management/microagent-management-error.tsx index 5918d6f081..022a718736 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-error.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-error.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { BrandButton } from "../settings/brand-button"; import { Loader } from "#/components/shared/loader"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; export function MicroagentManagementError() { const { t } = useTranslation(); diff --git a/frontend/src/components/features/microagent-management/microagent-management-learn-this-repo-modal.tsx b/frontend/src/components/features/microagent-management/microagent-management-learn-this-repo-modal.tsx index bbc46c8425..9bb1b40c6d 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-learn-this-repo-modal.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-learn-this-repo-modal.tsx @@ -5,7 +5,7 @@ import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; import { BrandButton } from "../settings/brand-button"; import { I18nKey } from "#/i18n/declaration"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import XIcon from "#/icons/x.svg?react"; import { cn, getRepoMdCreatePrompt } from "#/utils/utils"; import { LearnThisRepoFormData } from "#/types/microagent-management"; diff --git a/frontend/src/components/features/microagent-management/microagent-management-learn-this-repo.tsx b/frontend/src/components/features/microagent-management/microagent-management-learn-this-repo.tsx index dbb76c162a..82d09f2936 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-learn-this-repo.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-learn-this-repo.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { GitRepository } from "#/types/git"; interface MicroagentManagementLearnThisRepoProps { diff --git a/frontend/src/components/features/microagent-management/microagent-management-main.tsx b/frontend/src/components/features/microagent-management/microagent-management-main.tsx index 6647d23121..c8405044f9 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-main.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-main.tsx @@ -1,4 +1,4 @@ -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { MicroagentManagementDefault } from "./microagent-management-default"; import { MicroagentManagementOpeningPr } from "./microagent-management-opening-pr"; import { MicroagentManagementReviewPr } from "./microagent-management-review-pr"; diff --git a/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx b/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx index 9da7f8383d..f8ac499f55 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { RepositoryMicroagent } from "#/types/microagent-management"; import { Conversation } from "#/api/open-hands.types"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { cn } from "#/utils/utils"; import { GitRepository } from "#/types/git"; diff --git a/frontend/src/components/features/microagent-management/microagent-management-opening-pr.tsx b/frontend/src/components/features/microagent-management/microagent-management-opening-pr.tsx index f4a3beedc0..c9f3ad925d 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-opening-pr.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-opening-pr.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { BrandButton } from "../settings/brand-button"; import { Loader } from "#/components/shared/loader"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; export function MicroagentManagementOpeningPr() { const { t } = useTranslation(); diff --git a/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx b/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx index 868580550a..b2fc4464c0 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx @@ -6,7 +6,7 @@ import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents"; import { useMicroagentManagementConversations } from "#/hooks/query/use-microagent-management-conversations"; import { GitRepository } from "#/types/git"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { cn } from "#/utils/utils"; import { I18nKey } from "#/i18n/declaration"; diff --git a/frontend/src/components/features/microagent-management/microagent-management-review-pr.tsx b/frontend/src/components/features/microagent-management/microagent-management-review-pr.tsx index 1031b7202e..57f4ff129d 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-review-pr.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-review-pr.tsx @@ -3,7 +3,7 @@ import { I18nKey } from "#/i18n/declaration"; import { BrandButton } from "../settings/brand-button"; import { getProviderName, constructPullRequestUrl } from "#/utils/utils"; import { Provider } from "#/types/settings"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; export function MicroagentManagementReviewPr() { const { t } = useTranslation(); diff --git a/frontend/src/components/features/microagent-management/microagent-management-sidebar-tabs.tsx b/frontend/src/components/features/microagent-management/microagent-management-sidebar-tabs.tsx index 378f0ad7a1..d7a088c608 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-sidebar-tabs.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-sidebar-tabs.tsx @@ -2,7 +2,7 @@ import { Tab, Tabs } from "@heroui/react"; import { useTranslation } from "react-i18next"; import { MicroagentManagementRepositories } from "./microagent-management-repositories"; import { I18nKey } from "#/i18n/declaration"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; interface MicroagentManagementSidebarTabsProps { isSearchLoading?: boolean; diff --git a/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx b/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx index 3ef952bb79..99719a3f86 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx @@ -6,7 +6,7 @@ import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar import { useGitRepositories } from "#/hooks/query/use-git-repositories"; import { useSearchRepositories } from "#/hooks/query/use-search-repositories"; import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { GitRepository } from "#/types/git"; import { Provider } from "#/types/settings"; import { diff --git a/frontend/src/components/features/microagent-management/microagent-management-upsert-microagent-modal.tsx b/frontend/src/components/features/microagent-management/microagent-management-upsert-microagent-modal.tsx index 6e41583f02..1c5bf37c80 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-upsert-microagent-modal.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-upsert-microagent-modal.tsx @@ -5,7 +5,7 @@ import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; import { BrandButton } from "../settings/brand-button"; import { I18nKey } from "#/i18n/declaration"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import XIcon from "#/icons/x.svg?react"; import { cn, extractRepositoryInfo } from "#/utils/utils"; import { BadgeInput } from "#/components/shared/inputs/badge-input"; diff --git a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx index 2994946731..d89ce2d199 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { Spinner } from "@heroui/react"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content"; import { I18nKey } from "#/i18n/declaration"; import { extractRepositoryInfo } from "#/utils/utils"; diff --git a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-header.tsx b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-header.tsx index bf28adbabe..a60f055adb 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-header.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-header.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { BrandButton } from "../settings/brand-button"; import { getProviderName, constructMicroagentUrl } from "#/utils/utils"; import { I18nKey } from "#/i18n/declaration"; -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; export function MicroagentManagementViewMicroagentHeader() { const { t } = useTranslation(); diff --git a/frontend/src/components/features/microagent-management/microagent-management-view-microagent.tsx b/frontend/src/components/features/microagent-management/microagent-management-view-microagent.tsx index 2deaf1ef22..a7c6b6bdb8 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-view-microagent.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-view-microagent.tsx @@ -1,4 +1,4 @@ -import { useMicroagentManagementStore } from "#/state/microagent-management-store"; +import { useMicroagentManagementStore } from "#/stores/microagent-management-store"; import { MicroagentManagementViewMicroagentHeader } from "./microagent-management-view-microagent-header"; import { MicroagentManagementViewMicroagentContent } from "./microagent-management-view-microagent-content"; diff --git a/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts b/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts index 17824a51c8..8e2a0cb253 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts @@ -5,7 +5,7 @@ export const parseMessageFromEvent = (event: MessageEvent): string => { const message = event.llm_message; // Safety check: ensure llm_message exists and has content - if (!message || !message.content) { + if (!message?.content) { return ""; } diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 0cf43b49ce..73bd2b365a 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -14,7 +14,7 @@ import { useEventStore } from "#/stores/use-event-store"; 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 { useCommandStore } from "#/stores/command-store"; import { useBrowserStore } from "#/stores/browser-store"; import { isV1Event, @@ -40,7 +40,7 @@ import type { V1SendMessageRequest, } from "#/api/conversation-service/v1-conversation-service.types"; import EventService from "#/api/event-service/event-service.api"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { isBudgetOrCreditError } from "#/utils/error-handler"; import { useTracking } from "#/hooks/use-tracking"; import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation-file"; diff --git a/frontend/src/hooks/chat/use-chat-input-logic.ts b/frontend/src/hooks/chat/use-chat-input-logic.ts index d908882a40..21dc682fc9 100644 --- a/frontend/src/hooks/chat/use-chat-input-logic.ts +++ b/frontend/src/hooks/chat/use-chat-input-logic.ts @@ -4,7 +4,7 @@ import { clearEmptyContent, getTextContent, } from "#/components/features/chat/utils/chat-input.utils"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; /** * Hook for managing chat input content logic diff --git a/frontend/src/hooks/chat/use-grip-resize.ts b/frontend/src/hooks/chat/use-grip-resize.ts index b29f820399..a46d3fbe4f 100644 --- a/frontend/src/hooks/chat/use-grip-resize.ts +++ b/frontend/src/hooks/chat/use-grip-resize.ts @@ -4,7 +4,7 @@ import { CHAT_INPUT } from "#/utils/constants"; import { IMessageToSend, useConversationStore, -} from "#/state/conversation-store"; +} from "#/stores/conversation-store"; /** * Hook for managing grip resize functionality diff --git a/frontend/src/hooks/query/use-microagent-management-conversations.ts b/frontend/src/hooks/query/use-microagent-management-conversations.ts index 4c83ca2f75..947cbcf509 100644 --- a/frontend/src/hooks/query/use-microagent-management-conversations.ts +++ b/frontend/src/hooks/query/use-microagent-management-conversations.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import MicroagentManagementService from "#/ui/microagent-management-service/microagent-management-service.api"; +import MicroagentManagementService from "#/api/microagent-management-service/microagent-management-service.api"; export const useMicroagentManagementConversations = ( selectedRepository: string, diff --git a/frontend/src/hooks/use-auto-resize.ts b/frontend/src/hooks/use-auto-resize.ts index 6a86784e0f..52546d78ec 100644 --- a/frontend/src/hooks/use-auto-resize.ts +++ b/frontend/src/hooks/use-auto-resize.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, RefObject, useRef } from "react"; -import { IMessageToSend } from "#/state/conversation-store"; +import { IMessageToSend } from "#/stores/conversation-store"; import { EPS } from "#/utils/constants"; import { getStyleHeightPx, setStyleHeightPx } from "#/utils/utils"; import { useDragResize } from "./use-drag-resize"; diff --git a/frontend/src/hooks/use-handle-plan-click.ts b/frontend/src/hooks/use-handle-plan-click.ts index 9734bab8da..1507665812 100644 --- a/frontend/src/hooks/use-handle-plan-click.ts +++ b/frontend/src/hooks/use-handle-plan-click.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { displaySuccessToast } from "#/utils/custom-toast-handlers"; diff --git a/frontend/src/hooks/use-terminal.ts b/frontend/src/hooks/use-terminal.ts index d01132a85f..caa2e42a15 100644 --- a/frontend/src/hooks/use-terminal.ts +++ b/frontend/src/hooks/use-terminal.ts @@ -1,7 +1,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import React from "react"; -import { Command, useCommandStore } from "#/state/command-store"; +import { Command, useCommandStore } from "#/stores/command-store"; import { parseTerminalOutput } from "#/utils/parse-terminal-output"; /* @@ -29,6 +29,47 @@ const renderCommand = ( } }; +/** + * Check if the terminal is ready for fit operations. + * This prevents the "Cannot read properties of undefined (reading 'dimensions')" error + * that occurs when fit() is called on a terminal that is hidden, disposed, or not fully initialized. + */ +const canFitTerminal = ( + terminalInstance: Terminal | null, + fitAddonInstance: FitAddon | null, + containerElement: HTMLDivElement | null, +): boolean => { + // Check terminal and fitAddon exist + if (!terminalInstance || !fitAddonInstance) { + return false; + } + + // Check container element exists + if (!containerElement) { + return false; + } + + // Check element is visible (not display: none) + // When display is none, offsetParent is null (except for fixed/body elements) + const computedStyle = window.getComputedStyle(containerElement); + if (computedStyle.display === "none") { + return false; + } + + // Check element has dimensions + const { clientWidth, clientHeight } = containerElement; + if (clientWidth === 0 || clientHeight === 0) { + return false; + } + + // Check terminal has been opened (element property is set after open()) + if (!terminalInstance.element) { + return false; + } + + return true; +}; + // Create a persistent reference that survives component unmounts // This ensures terminal history is preserved when navigating away and back const persistentLastCommandIndex = { current: 0 }; @@ -39,6 +80,7 @@ export const useTerminal = () => { const fitAddon = React.useRef(null); const ref = React.useRef(null); const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference + const isDisposed = React.useRef(false); const createTerminal = () => new Terminal({ @@ -55,6 +97,15 @@ export const useTerminal = () => { }, }); + const fitTerminalSafely = React.useCallback(() => { + if (isDisposed.current) { + return; + } + if (canFitTerminal(terminal.current, fitAddon.current, ref.current)) { + fitAddon.current!.fit(); + } + }, []); + const initializeTerminal = () => { if (terminal.current) { if (fitAddon.current) terminal.current.loadAddon(fitAddon.current); @@ -62,13 +113,14 @@ export const useTerminal = () => { terminal.current.open(ref.current); // Hide cursor for read-only terminal using ANSI escape sequence terminal.current.write("\x1b[?25l"); - fitAddon.current?.fit(); + fitTerminalSafely(); } } }; // Initialize terminal and handle cleanup React.useEffect(() => { + isDisposed.current = false; terminal.current = createTerminal(); fitAddon.current = new FitAddon(); @@ -91,6 +143,7 @@ export const useTerminal = () => { } return () => { + isDisposed.current = true; terminal.current?.dispose(); lastCommandIndex.current = 0; }; @@ -118,7 +171,10 @@ export const useTerminal = () => { let resizeObserver: ResizeObserver | null = null; resizeObserver = new ResizeObserver(() => { - fitAddon.current?.fit(); + // Use requestAnimationFrame to debounce resize events and ensure DOM is ready + requestAnimationFrame(() => { + fitTerminalSafely(); + }); }); if (ref.current) { @@ -128,7 +184,7 @@ export const useTerminal = () => { return () => { resizeObserver?.disconnect(); }; - }, []); + }, [fitTerminalSafely]); return ref; }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 2f99b1aef6..1b330730d9 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -436,6 +436,8 @@ export enum I18nKey { BUTTON$CREATE = "BUTTON$CREATE", BUTTON$DELETE = "BUTTON$DELETE", BUTTON$COPY_TO_CLIPBOARD = "BUTTON$COPY_TO_CLIPBOARD", + BUTTON$HOME = "BUTTON$HOME", + BUTTON$OPEN_IN_NEW_TAB = "BUTTON$OPEN_IN_NEW_TAB", BUTTON$REFRESH = "BUTTON$REFRESH", ERROR$REQUIRED_FIELD = "ERROR$REQUIRED_FIELD", PLANNER$EMPTY_MESSAGE = "PLANNER$EMPTY_MESSAGE", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index fc4ca89dbc..a421de5ddf 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -6975,6 +6975,38 @@ "es": "Copiar al portapapeles", "tr": "Panoya Kopyala" }, + "BUTTON$HOME": { + "en": "Home", + "ja": "ホーム", + "zh-CN": "主页", + "zh-TW": "首頁", + "ko-KR": "홈", + "no": "Hjem", + "it": "Home", + "pt": "Início", + "es": "Inicio", + "ar": "الرئيسية", + "fr": "Accueil", + "tr": "Ana Sayfa", + "de": "Startseite", + "uk": "Головна" + }, + "BUTTON$OPEN_IN_NEW_TAB": { + "en": "Open in New Tab", + "ja": "新しいタブで開く", + "zh-CN": "在新标签页中打开", + "zh-TW": "在新分頁中開啟", + "ko-KR": "새 탭에서 열기", + "no": "Åpne i ny fane", + "it": "Apri in una nuova scheda", + "pt": "Abrir em nova aba", + "es": "Abrir en nueva pestaña", + "ar": "فتح في علامة تبويب جديدة", + "fr": "Ouvrir dans un nouvel onglet", + "tr": "Yeni Sekmede Aç", + "de": "In neuem Tab öffnen", + "uk": "Відкрити в новій вкладці" + }, "BUTTON$REFRESH": { "en": "Refresh", "ja": "更新", diff --git a/frontend/src/mocks/secrets-handlers.ts b/frontend/src/mocks/secrets-handlers.ts index 3d5570943a..18c4dc98fd 100644 --- a/frontend/src/mocks/secrets-handlers.ts +++ b/frontend/src/mocks/secrets-handlers.ts @@ -34,7 +34,7 @@ export const SECRETS_HANDLERS = [ http.post("/api/secrets", async ({ request }) => { const body = (await request.json()) as CustomSecret; - if (typeof body === "object" && body && body.name) { + if (typeof body === "object" && body?.name) { secrets.set(body.name, body); return HttpResponse.json(true); } @@ -48,7 +48,7 @@ export const SECRETS_HANDLERS = [ if (typeof id === "string" && typeof body === "object") { const secret = secrets.get(id); - if (secret && body && body.name) { + if (secret && body?.name) { const newSecret: CustomSecret = { ...secret, ...body }; secrets.delete(id); secrets.set(body.name, newSecret); diff --git a/frontend/src/mocks/settings-handlers.ts b/frontend/src/mocks/settings-handlers.ts index c08cd8dc36..00de1e9c5d 100644 --- a/frontend/src/mocks/settings-handlers.ts +++ b/frontend/src/mocks/settings-handlers.ts @@ -49,9 +49,11 @@ export const SETTINGS_HANDLERS = [ "anthropic/claude-sonnet-4-20250514", "anthropic/claude-sonnet-4-5-20250929", "anthropic/claude-haiku-4-5-20251001", + "anthropic/claude-opus-4-5-20251101", "openhands/claude-sonnet-4-20250514", "openhands/claude-sonnet-4-5-20250929", "openhands/claude-haiku-4-5-20251001", + "openhands/claude-opus-4-5-20251101", "sambanova/Meta-Llama-3.1-8B-Instruct", ]), ), @@ -134,7 +136,7 @@ export const SETTINGS_HANDLERS = [ const providerTokensSet: Partial> = Object.fromEntries( Object.entries(rawTokens) - .filter(([, val]) => val && val.token) + .filter(([, val]) => val?.token) .map(([provider]) => [provider as Provider, ""]), ); diff --git a/frontend/src/routes/conversation.tsx b/frontend/src/routes/conversation.tsx index ec19051530..0237878e1e 100644 --- a/frontend/src/routes/conversation.tsx +++ b/frontend/src/routes/conversation.tsx @@ -3,8 +3,8 @@ import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { useConversationId } from "#/hooks/use-conversation-id"; -import { useCommandStore } from "#/state/command-store"; -import { useConversationStore } from "#/state/conversation-store"; +import { useCommandStore } from "#/stores/command-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { useAgentStore } from "#/stores/agent-store"; import { AgentState } from "#/types/agent-state"; diff --git a/frontend/src/routes/planner-tab.tsx b/frontend/src/routes/planner-tab.tsx index 2e5af229ef..f17a0acbc5 100644 --- a/frontend/src/routes/planner-tab.tsx +++ b/frontend/src/routes/planner-tab.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; -import { useConversationStore } from "#/state/conversation-store"; +import { useConversationStore } from "#/stores/conversation-store"; import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; import { useHandlePlanClick } from "#/hooks/use-handle-plan-click"; diff --git a/frontend/src/routes/served-tab.tsx b/frontend/src/routes/served-tab.tsx index f2f6b26883..b6abb5b3d3 100644 --- a/frontend/src/routes/served-tab.tsx +++ b/frontend/src/routes/served-tab.tsx @@ -65,6 +65,7 @@ function ServedApp() { type="button" onClick={() => window.open(fullUrl, "_blank")} className="text-sm" + aria-label={t(I18nKey.BUTTON$OPEN_IN_NEW_TAB)} > @@ -72,11 +73,17 @@ function ServedApp() { type="button" onClick={() => setRefreshKey((prev) => prev + 1)} className="text-sm" + aria-label={t(I18nKey.BUTTON$REFRESH)} > -
    diff --git a/frontend/src/routes/vscode-tab.tsx b/frontend/src/routes/vscode-tab.tsx index 0d64180c1d..e1bb2e8fe4 100644 --- a/frontend/src/routes/vscode-tab.tsx +++ b/frontend/src/routes/vscode-tab.tsx @@ -51,7 +51,7 @@ function VSCodeTab() { ); } - if (error || (data && data.error) || !data?.url || iframeError) { + if (error || data?.error || !data?.url || iframeError) { return (
    {iframeError || diff --git a/frontend/src/services/actions.ts b/frontend/src/services/actions.ts index 86b89106ff..6f03c526e1 100644 --- a/frontend/src/services/actions.ts +++ b/frontend/src/services/actions.ts @@ -1,6 +1,6 @@ import { trackError } from "#/utils/error-handler"; import useMetricsStore from "#/stores/metrics-store"; -import { useStatusStore } from "#/state/status-store"; +import { useStatusStore } from "#/stores/status-store"; import ActionType from "#/types/action-type"; import { ActionMessage, @@ -8,7 +8,7 @@ import { StatusMessage, } from "#/types/message"; import { handleObservationMessage } from "./observations"; -import { useCommandStore } from "#/state/command-store"; +import { useCommandStore } from "#/stores/command-store"; import { queryClient } from "#/query-client-config"; import { ActionSecurityRisk, diff --git a/frontend/src/services/observations.ts b/frontend/src/services/observations.ts index 40cc1daa8a..8f1d8d3b41 100644 --- a/frontend/src/services/observations.ts +++ b/frontend/src/services/observations.ts @@ -1,5 +1,5 @@ import { ObservationMessage } from "#/types/message"; -import { useCommandStore } from "#/state/command-store"; +import { useCommandStore } from "#/stores/command-store"; import ObservationType from "#/types/observation-type"; import { useBrowserStore } from "#/stores/browser-store"; import { useAgentStore } from "#/stores/agent-store"; diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts index 1191e0ea68..e4a04b1e87 100644 --- a/frontend/src/services/settings.ts +++ b/frontend/src/services/settings.ts @@ -3,7 +3,7 @@ import { Settings } from "#/types/settings"; export const LATEST_SETTINGS_VERSION = 5; export const DEFAULT_SETTINGS: Settings = { - llm_model: "openhands/claude-sonnet-4-20250514", + llm_model: "openhands/claude-opus-4-5-20251101", llm_base_url: "", agent: "CodeActAgent", language: "en", diff --git a/frontend/src/state/command-store.ts b/frontend/src/stores/command-store.ts similarity index 100% rename from frontend/src/state/command-store.ts rename to frontend/src/stores/command-store.ts diff --git a/frontend/src/state/conversation-store.ts b/frontend/src/stores/conversation-store.ts similarity index 100% rename from frontend/src/state/conversation-store.ts rename to frontend/src/stores/conversation-store.ts diff --git a/frontend/src/state/microagent-management-store.ts b/frontend/src/stores/microagent-management-store.ts similarity index 100% rename from frontend/src/state/microagent-management-store.ts rename to frontend/src/stores/microagent-management-store.ts diff --git a/frontend/src/state/status-store.ts b/frontend/src/stores/status-store.ts similarity index 100% rename from frontend/src/state/status-store.ts rename to frontend/src/stores/status-store.ts diff --git a/frontend/src/utils/extract-model-and-provider.ts b/frontend/src/utils/extract-model-and-provider.ts index 93ef12d8bf..ab0836079f 100644 --- a/frontend/src/utils/extract-model-and-provider.ts +++ b/frontend/src/utils/extract-model-and-provider.ts @@ -16,7 +16,7 @@ import { * splitIsActuallyVersion(split) // returns true */ const splitIsActuallyVersion = (split: string[]) => - split[1] && split[1][0] && isNumber(split[1][0]); + split[1]?.[0] && isNumber(split[1][0]); /** * Given a model string, extract the provider and model name. Currently the supported separators are "/" and "." diff --git a/frontend/src/utils/has-advanced-settings-set.ts b/frontend/src/utils/has-advanced-settings-set.ts index b873425239..8e3de2be9c 100644 --- a/frontend/src/utils/has-advanced-settings-set.ts +++ b/frontend/src/utils/has-advanced-settings-set.ts @@ -1,6 +1,48 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; import { Settings } from "#/types/settings"; -export const hasAdvancedSettingsSet = (settings: Partial): boolean => - Object.keys(settings).length > 0 && - (!!settings.llm_base_url || settings.agent !== DEFAULT_SETTINGS.agent); +/** + * Determines if any advanced-only settings are configured. + * Advanced-only settings are those that appear only in the Advanced Settings view + * and not in the Basic Settings view. + * + * Advanced-only fields: + * - llm_base_url: Custom base URL for LLM API + * - agent: Custom agent selection (when not using default) + * - enable_default_condenser: Memory condenser toggle (when disabled, as default is enabled) + * - condenser_max_size: Custom condenser size (when different from default) + * - search_api_key: Search API key (when set) + */ +export const hasAdvancedSettingsSet = ( + settings: Partial, +): boolean => { + if (Object.keys(settings).length === 0) { + return false; + } + + // Check for advanced-only settings that differ from defaults + const hasBaseUrl = + !!settings.llm_base_url && settings.llm_base_url.trim() !== ""; + const hasCustomAgent = + settings.agent !== undefined && settings.agent !== DEFAULT_SETTINGS.agent; + // Default is true, so only check if explicitly disabled + const hasDisabledCondenser = settings.enable_default_condenser === false; + // Check if condenser size differs from default (default is 120) + const hasCustomCondenserSize = + settings.condenser_max_size !== undefined && + settings.condenser_max_size !== null && + settings.condenser_max_size !== DEFAULT_SETTINGS.condenser_max_size; + // Check if search API key is set (non-empty string) + const hasSearchApiKey = + settings.search_api_key !== undefined && + settings.search_api_key !== null && + settings.search_api_key.trim() !== ""; + + return ( + hasBaseUrl || + hasCustomAgent || + hasDisabledCondenser || + hasCustomCondenserSize || + hasSearchApiKey + ); +}; diff --git a/frontend/src/utils/verified-models.ts b/frontend/src/utils/verified-models.ts index 5b2e19e9ef..dcf5f72517 100644 --- a/frontend/src/utils/verified-models.ts +++ b/frontend/src/utils/verified-models.ts @@ -19,6 +19,7 @@ export const VERIFIED_MODELS = [ "claude-haiku-4-5-20251001", "claude-opus-4-20250514", "claude-opus-4-1-20250805", + "claude-opus-4-5-20251101", "gemini-2.5-pro", "o4-mini", "deepseek-chat", @@ -80,6 +81,7 @@ export const VERIFIED_OPENHANDS_MODELS = [ "gpt-5-mini-2025-08-07", "claude-opus-4-20250514", "claude-opus-4-1-20250805", + "claude-opus-4-5-20251101", "gemini-2.5-pro", "o3", "o4-mini", @@ -91,4 +93,4 @@ export const VERIFIED_OPENHANDS_MODELS = [ ]; // Default model for OpenHands provider -export const DEFAULT_OPENHANDS_MODEL = "openhands/claude-sonnet-4-20250514"; +export const DEFAULT_OPENHANDS_MODEL = "openhands/claude-opus-4-5-20251101"; diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 646d80cb9a..de0b89e82d 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -478,7 +478,11 @@ class LiveStatusAppConversationService(AppConversationServiceBase): if sandbox.status in (None, SandboxStatus.ERROR): raise SandboxError(f'Sandbox status: {sandbox.status}') if sandbox.status == SandboxStatus.RUNNING: - return + # There are still bugs in the remote runtime - they report running while still just + # starting resulting in a race condition. Manually check that it is actually + # running. + if await self._check_agent_server_alive(sandbox): + return if sandbox.status != SandboxStatus.STARTING: raise SandboxError(f'Sandbox not startable: {sandbox.id}') @@ -491,9 +495,19 @@ class LiveStatusAppConversationService(AppConversationServiceBase): if sandbox.status not in (SandboxStatus.STARTING, SandboxStatus.RUNNING): raise SandboxError(f'Sandbox not startable: {sandbox.id}') if sandbox_info.status == SandboxStatus.RUNNING: - return + # There are still bugs in the remote runtime - they report running while still just + # starting resulting in a race condition. Manually check that it is actually + # running. + if await self._check_agent_server_alive(sandbox_info): + return raise SandboxError(f'Sandbox failed to start: {sandbox.id}') + async def _check_agent_server_alive(self, sandbox_info: SandboxInfo) -> bool: + agent_server_url = self._get_agent_server_url(sandbox_info) + url = f'{agent_server_url.rstrip("/")}/alive' + response = await self.httpx_client.get(url) + return response.is_success + def _get_agent_server_url(self, sandbox: SandboxInfo) -> str: """Get agent server url for running sandbox.""" exposed_urls = sandbox.exposed_urls diff --git a/openhands/app_server/sandbox/remote_sandbox_service.py b/openhands/app_server/sandbox/remote_sandbox_service.py index 5ee42218dc..076c478478 100644 --- a/openhands/app_server/sandbox/remote_sandbox_service.py +++ b/openhands/app_server/sandbox/remote_sandbox_service.py @@ -44,6 +44,7 @@ from openhands.app_server.services.injector import InjectorState from openhands.app_server.user.specifiy_user_context import ADMIN, USER_CONTEXT_ATTR from openhands.app_server.user.user_context import UserContext from openhands.app_server.utils.sql_utils import Base, UtcDateTime +from openhands.sdk.utils.paging import page_iterator _logger = logging.getLogger(__name__) WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL' @@ -121,18 +122,9 @@ class RemoteSandboxService(SandboxService): _logger.error(f'HTTP error for URL {url}: {e}') raise - async def _to_sandbox_info( + def _to_sandbox_info( self, stored: StoredRemoteSandbox, runtime: dict[str, Any] | None = None - ) -> SandboxInfo: - # If we did not get passsed runtime data, load some - if runtime is None: - try: - runtime = await self._get_runtime(stored.id) - except Exception: - _logger.exception( - f'Error getting runtime: {stored.id}', stack_info=True - ) - + ): status = self._get_sandbox_status_from_runtime(runtime) # Get session_api_key and exposed urls @@ -232,6 +224,40 @@ class RemoteSandboxService(SandboxService): runtime_data = response.json() return runtime_data + async def _get_runtimes_batch( + self, sandbox_ids: list[str] + ) -> dict[str, dict[str, Any]]: + """Get multiple runtimes in a single batch request. + + Args: + sandbox_ids: List of sandbox IDs to fetch + + Returns: + Dictionary mapping sandbox_id to runtime data + """ + if not sandbox_ids: + return {} + + # Build query parameters for the batch endpoint + params = [('ids', sandbox_id) for sandbox_id in sandbox_ids] + + response = await self._send_runtime_api_request( + 'GET', + '/sessions/batch', + params=params, + ) + response.raise_for_status() + batch_data = response.json() + + # The batch endpoint should return a list of runtimes + # Convert to a dictionary keyed by session_id for easy lookup + runtimes_by_id = {} + for runtime in batch_data: + if runtime and 'session_id' in runtime: + runtimes_by_id[runtime['session_id']] = runtime + + return runtimes_by_id + async def _init_environment( self, sandbox_spec: SandboxSpecInfo, sandbox_id: str ) -> dict[str, str]: @@ -282,13 +308,15 @@ class RemoteSandboxService(SandboxService): if has_more: next_page_id = str(offset + limit) - # Convert stored callbacks to domain models - items = await asyncio.gather( - *[ - self._to_sandbox_info(stored_sandbox) - for stored_sandbox in stored_sandboxes - ] - ) + # Batch fetch runtime data for all sandboxes + sandbox_ids = [stored_sandbox.id for stored_sandbox in stored_sandboxes] + runtimes_by_id = await self._get_runtimes_batch(sandbox_ids) + + # Convert stored sandboxes to domain models with runtime data + items = [ + self._to_sandbox_info(stored_sandbox, runtimes_by_id.get(stored_sandbox.id)) + for stored_sandbox in stored_sandboxes + ] return SandboxPage(items=items, next_page_id=next_page_id) @@ -297,12 +325,46 @@ class RemoteSandboxService(SandboxService): stored_sandbox = await self._get_stored_sandbox(sandbox_id) if stored_sandbox is None: return None - return await self._to_sandbox_info(stored_sandbox) + + runtime = None + try: + runtime = await self._get_runtime(stored_sandbox.id) + except Exception: + _logger.exception( + f'Error getting runtime: {stored_sandbox.id}', stack_info=True + ) + + return self._to_sandbox_info(stored_sandbox, runtime) async def get_sandbox_by_session_api_key( self, session_api_key: str ) -> Union[SandboxInfo, None]: """Get a single sandbox by session API key.""" + # TODO: We should definitely refactor this and store the session_api_key in + # the v1_remote_sandbox table + try: + response = await self._send_runtime_api_request( + 'GET', + '/list', + ) + response.raise_for_status() + content = response.json() + for runtime in content['runtimes']: + if session_api_key == runtime['session_api_key']: + query = await self._secure_select() + query = query.filter( + StoredRemoteSandbox.id == runtime.get('session_id') + ) + result = await self.db_session.execute(query) + sandbox = result.first() + if sandbox is None: + raise ValueError('sandbox_not_found') + return self._to_sandbox_info(sandbox, runtime) + except Exception: + _logger.exception( + 'Error getting sandbox from session_api_key', stack_info=True + ) + # Get all stored sandboxes for the current user stmt = await self._secure_select() result = await self.db_session.execute(stmt) @@ -313,7 +375,7 @@ class RemoteSandboxService(SandboxService): 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) + return self._to_sandbox_info(stored_sandbox, runtime) except Exception: # Continue checking other sandboxes if one fails continue @@ -386,7 +448,7 @@ class RemoteSandboxService(SandboxService): # Hack - result doesn't contain this runtime_data['pod_status'] = 'pending' - return await self._to_sandbox_info(stored_sandbox, runtime_data) + return self._to_sandbox_info(stored_sandbox, runtime_data) except httpx.HTTPError as e: _logger.error(f'Failed to start sandbox: {e}') @@ -454,6 +516,81 @@ class RemoteSandboxService(SandboxService): _logger.error(f'Error deleting sandbox {sandbox_id}: {e}') return False + async def pause_old_sandboxes(self, max_num_sandboxes: int) -> list[str]: + """Pause the oldest sandboxes if there are more than max_num_sandboxes running. + In a multi user environment, this will pause sandboxes only for the current user. + + Args: + max_num_sandboxes: Maximum number of sandboxes to keep running + + Returns: + List of sandbox IDs that were paused + """ + if max_num_sandboxes <= 0: + raise ValueError('max_num_sandboxes must be greater than 0') + + response = await self._send_runtime_api_request( + 'GET', + '/list', + ) + content = response.json() + running_session_ids = [ + runtime.get('session_id') for runtime in content['runtimes'] + ] + + query = await self._secure_select() + query = query.filter(StoredRemoteSandbox.id.in_(running_session_ids)).order_by( + StoredRemoteSandbox.created_at.desc() + ) + running_sandboxes = list(await self.db_session.execute(query)) + + # If we're within the limit, no cleanup needed + if len(running_sandboxes) <= max_num_sandboxes: + return [] + + # Determine how many to pause + num_to_pause = len(running_sandboxes) - max_num_sandboxes + sandboxes_to_pause = running_sandboxes[:num_to_pause] + + # Stop the oldest sandboxes + paused_sandbox_ids = [] + for sandbox in sandboxes_to_pause: + try: + success = await self.pause_sandbox(sandbox.id) + if success: + paused_sandbox_ids.append(sandbox.id) + except Exception: + # Continue trying to pause other sandboxes even if one fails + pass + + return paused_sandbox_ids + + async def batch_get_sandboxes( + self, sandbox_ids: list[str] + ) -> list[SandboxInfo | None]: + """Get a batch of sandboxes, returning None for any which were not found.""" + if not sandbox_ids: + return [] + query = await self._secure_select() + query = query.filter(StoredRemoteSandbox.id.in_(sandbox_ids)) + stored_remote_sandboxes = await self.db_session.execute(query) + stored_remote_sandboxes_by_id = { + stored_remote_sandbox[0].id: stored_remote_sandbox[0] + for stored_remote_sandbox in stored_remote_sandboxes + } + runtimes_by_id = await self._get_runtimes_batch( + list(stored_remote_sandboxes_by_id) + ) + results = [] + for sandbox_id in sandbox_ids: + stored_remote_sandbox = stored_remote_sandboxes_by_id.get(sandbox_id) + result = None + if stored_remote_sandbox: + runtime = runtimes_by_id.get(sandbox_id) + result = self._to_sandbox_info(stored_remote_sandbox, runtime) + results.append(result) + return results + def _build_service_url(url: str, service_name: str): scheme, host_and_path = url.split('://') @@ -504,32 +641,26 @@ async def poll_agent_servers(api_url: str, api_key: str, sleep_interval: int): get_event_callback_service(state) as event_callback_service, get_httpx_client(state) as httpx_client, ): - page_id = None matches = 0 - while True: - page = await app_conversation_info_service.search_app_conversation_info( - page_id=page_id + async for app_conversation_info in page_iterator( + app_conversation_info_service.search_app_conversation_info + ): + runtime = runtimes_by_sandbox_id.get( + app_conversation_info.sandbox_id ) - for app_conversation_info in page.items: - runtime = runtimes_by_sandbox_id.get( - app_conversation_info.sandbox_id + if runtime: + matches += 1 + await refresh_conversation( + app_conversation_info_service=app_conversation_info_service, + event_service=event_service, + event_callback_service=event_callback_service, + app_conversation_info=app_conversation_info, + runtime=runtime, + httpx_client=httpx_client, ) - if runtime: - matches += 1 - await refresh_conversation( - app_conversation_info_service=app_conversation_info_service, - event_service=event_service, - event_callback_service=event_callback_service, - app_conversation_info=app_conversation_info, - runtime=runtime, - httpx_client=httpx_client, - ) - page_id = page.next_page_id - if page_id is None: - _logger.debug( - f'Matched {len(runtimes_by_sandbox_id)} Runtimes with {matches} Conversations.' - ) - break + _logger.debug( + f'Matched {len(runtimes_by_sandbox_id)} Runtimes with {matches} Conversations.' + ) except Exception as exc: _logger.exception( @@ -583,37 +714,29 @@ async def refresh_conversation( event_url = ( f'{url}/api/conversations/{app_conversation_info.id.hex}/events/search' ) - page_id = None - while True: + + async def fetch_events_page(page_id: str | None = None) -> EventPage: + """Helper function to fetch a page of events from the agent server.""" params: dict[str, str] = {} if page_id: - params['page_id'] = page_id # type: ignore[unreachable] + params['page_id'] = page_id response = await httpx_client.get( event_url, params=params, headers={'X-Session-API-Key': runtime['session_api_key']}, ) response.raise_for_status() - page = EventPage.model_validate(response.json()) + return EventPage.model_validate(response.json()) - to_process = [] - for event in page.items: - existing = await event_service.get_event(event.id) - if existing is None: - await event_service.save_event(app_conversation_info.id, event) - to_process.append(event) - - for event in to_process: + async for event in page_iterator(fetch_events_page): + existing = await event_service.get_event(event.id) + if existing is None: + await event_service.save_event(app_conversation_info.id, event) await event_callback_service.execute_callbacks( app_conversation_info.id, event ) - page_id = page.next_page_id - if page_id is None: - _logger.debug( - f'Finished Refreshing Conversation {app_conversation_info.id}' - ) - break + _logger.debug(f'Finished Refreshing Conversation {app_conversation_info.id}') except Exception as exc: _logger.exception(f'Error Refreshing Conversation: {exc}', stack_info=True) diff --git a/openhands/app_server/sandbox/sandbox_service.py b/openhands/app_server/sandbox/sandbox_service.py index b1144a47cc..45274975d7 100644 --- a/openhands/app_server/sandbox/sandbox_service.py +++ b/openhands/app_server/sandbox/sandbox_service.py @@ -8,6 +8,7 @@ from openhands.app_server.sandbox.sandbox_models import ( ) from openhands.app_server.services.injector import Injector from openhands.sdk.utils.models import DiscriminatedUnionMixin +from openhands.sdk.utils.paging import page_iterator class SandboxService(ABC): @@ -71,7 +72,7 @@ class SandboxService(ABC): """ async def pause_old_sandboxes(self, max_num_sandboxes: int) -> list[str]: - """Stop the oldest sandboxes if there are more than max_num_sandboxes running. + """Pause the oldest sandboxes if there are more than max_num_sandboxes running. In a multi user environment, this will pause sandboxes only for the current user. Args: @@ -83,24 +84,11 @@ class SandboxService(ABC): if max_num_sandboxes <= 0: raise ValueError('max_num_sandboxes must be greater than 0') - # Get all sandboxes (we'll search through all pages) - all_sandboxes = [] - page_id = None - - while True: - page = await self.search_sandboxes(page_id=page_id, limit=100) - all_sandboxes.extend(page.items) - - if page.next_page_id is None: - break - page_id = page.next_page_id - - # Filter to only running sandboxes - running_sandboxes = [ - sandbox - for sandbox in all_sandboxes - if sandbox.status == SandboxStatus.RUNNING - ] + # Get all running sandboxes (iterate through all pages) + running_sandboxes = [] + async for sandbox in page_iterator(self.search_sandboxes, limit=100): + if sandbox.status == SandboxStatus.RUNNING: + running_sandboxes.append(sandbox) # If we're within the limit, no cleanup needed if len(running_sandboxes) <= max_num_sandboxes: diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index 958e5cb348..0753f0a0a1 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -944,6 +944,23 @@ class AgentController: return else: raise LLMContextWindowExceedError() + # Check if this is a tool call validation error that should be recoverable + elif ( + isinstance(e, BadRequestError) + and 'tool call validation failed' in error_str + and ( + 'missing properties' in error_str + or 'missing required' in error_str + ) + ): + # Handle tool call validation errors from Groq as recoverable errors + self.event_stream.add_event( + ErrorObservation( + content=f'Tool call validation failed: {str(e)}. Please check the tool parameters and try again.', + ), + EventSource.AGENT, + ) + return else: raise e diff --git a/openhands/core/config/llm_config.py b/openhands/core/config/llm_config.py index 0089f9b279..8a5f704b36 100644 --- a/openhands/core/config/llm_config.py +++ b/openhands/core/config/llm_config.py @@ -50,7 +50,7 @@ class LLMConfig(BaseModel): completion_kwargs: Custom kwargs to pass to litellm.completion. """ - model: str = Field(default='claude-sonnet-4-20250514') + model: str = Field(default='claude-opus-4-5-20251101') api_key: SecretStr | None = Field(default=None) base_url: str | None = Field(default=None) api_version: str | None = Field(default=None) diff --git a/openhands/runtime/browser/browser_env.py b/openhands/runtime/browser/browser_env.py index 55e3ce1890..c8d09d9c2b 100644 --- a/openhands/runtime/browser/browser_env.py +++ b/openhands/runtime/browser/browser_env.py @@ -1,8 +1,10 @@ import atexit import json import multiprocessing +import os import time import uuid +from pathlib import Path import browsergym.core # noqa F401 (we register the openended task as a gym environment) import gymnasium as gym @@ -67,6 +69,16 @@ class BrowserEnv: raise BrowserInitException('Failed to start browser environment.') def browser_process(self) -> None: + def _is_local_runtime() -> bool: + runtime_flag = os.getenv('RUNTIME', '').lower() + return runtime_flag == 'local' + + # Default Playwright cache for local runs only; do not override in docker + if _is_local_runtime() and 'PLAYWRIGHT_BROWSERS_PATH' not in os.environ: + os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str( + Path.home() / '.cache' / 'playwright' + ) + if self.eval_mode: assert self.browsergym_eval_env is not None logger.info('Initializing browser env for web browsing evaluation.') @@ -87,6 +99,11 @@ class BrowserEnv: ) env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000) else: + downloads_path = os.getenv('BROWSERGYM_DOWNLOAD_DIR') + if not downloads_path and _is_local_runtime(): + downloads_path = str(Path.home() / '.cache' / 'browsergym-downloads') + if not downloads_path: + downloads_path = '/workspace/.downloads/' env = gym.make( 'browsergym/openended', task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'}, @@ -96,7 +113,7 @@ class BrowserEnv: tags_to_mark='all', timeout=100000, pw_context_kwargs={'accept_downloads': True}, - pw_chromium_kwargs={'downloads_path': '/workspace/.downloads/'}, + pw_chromium_kwargs={'downloads_path': downloads_path}, ) obs, info = env.reset() diff --git a/openhands/runtime/impl/local/local_runtime.py b/openhands/runtime/impl/local/local_runtime.py index f6ef26a1cf..cf81b222eb 100644 --- a/openhands/runtime/impl/local/local_runtime.py +++ b/openhands/runtime/impl/local/local_runtime.py @@ -249,7 +249,22 @@ class LocalRuntime(ActionExecutionClient): ) else: # Set up workspace directory + # For local runtime, prefer a stable host path over /workspace defaults. + if ( + self.config.workspace_base is None + and self.config.runtime + and self.config.runtime.lower() == 'local' + ): + env_base = os.getenv('LOCAL_WORKSPACE_BASE') + if env_base: + self.config.workspace_base = os.path.abspath(env_base) + else: + self.config.workspace_base = os.path.abspath( + os.path.join(os.getcwd(), 'workspace', 'local') + ) + if self.config.workspace_base is not None: + os.makedirs(self.config.workspace_base, exist_ok=True) logger.warning( f'Workspace base path is set to {self.config.workspace_base}. ' 'It will be used as the path for the agent to run in. ' diff --git a/openhands/server/app.py b/openhands/server/app.py index d5135f2399..5cee75b163 100644 --- a/openhands/server/app.py +++ b/openhands/server/app.py @@ -36,7 +36,7 @@ from openhands.server.shared import conversation_manager, server_config from openhands.server.types import AppMode from openhands.version import get_version -mcp_app = mcp_server.http_app(path='/mcp') +mcp_app = mcp_server.http_app(path='/mcp', stateless_http=True) def combine_lifespans(*lifespans): diff --git a/openhands/server/routes/mcp.py b/openhands/server/routes/mcp.py index 929c66af5b..2d541d637c 100644 --- a/openhands/server/routes/mcp.py +++ b/openhands/server/routes/mcp.py @@ -25,9 +25,7 @@ from openhands.server.user_auth import ( ) from openhands.storage.data_models.conversation_metadata import ConversationMetadata -mcp_server = FastMCP( - 'mcp', stateless_http=True, mask_error_details=True, dependencies=None -) +mcp_server = FastMCP('mcp', mask_error_details=True) HOST = f'https://{os.getenv("WEB_HOST", "app.all-hands.dev").strip()}' CONVERSATION_URL = HOST + '/conversations/{}' diff --git a/openhands/utils/llm.py b/openhands/utils/llm.py index 1686babd96..876a890001 100644 --- a/openhands/utils/llm.py +++ b/openhands/utils/llm.py @@ -60,6 +60,7 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]: 'openhands/gpt-5-2025-08-07', 'openhands/gpt-5-mini-2025-08-07', 'openhands/claude-opus-4-20250514', + 'openhands/claude-opus-4-5-20251101', 'openhands/gemini-2.5-pro', 'openhands/o3', 'openhands/o4-mini', diff --git a/tests/unit/app_server/test_remote_sandbox_service.py b/tests/unit/app_server/test_remote_sandbox_service.py index 5802e46ecb..c70ad7d324 100644 --- a/tests/unit/app_server/test_remote_sandbox_service.py +++ b/tests/unit/app_server/test_remote_sandbox_service.py @@ -331,7 +331,7 @@ class TestSandboxInfoConversion: runtime_data = create_runtime_data(status='running', pod_status='ready') # Execute - sandbox_info = await remote_sandbox_service._to_sandbox_info( + sandbox_info = remote_sandbox_service._to_sandbox_info( stored_sandbox, runtime_data ) @@ -358,7 +358,7 @@ class TestSandboxInfoConversion: runtime_data = create_runtime_data(status='running', pod_status='pending') # Execute - sandbox_info = await remote_sandbox_service._to_sandbox_info( + sandbox_info = remote_sandbox_service._to_sandbox_info( stored_sandbox, runtime_data ) @@ -367,23 +367,6 @@ class TestSandboxInfoConversion: assert sandbox_info.session_api_key == 'test-session-key' assert sandbox_info.exposed_urls is None - @pytest.mark.asyncio - async def test_to_sandbox_info_without_runtime(self, remote_sandbox_service): - """Test conversion to SandboxInfo without runtime data.""" - # Setup - stored_sandbox = create_stored_sandbox() - remote_sandbox_service._get_runtime = AsyncMock( - side_effect=Exception('Runtime not found') - ) - - # Execute - sandbox_info = await remote_sandbox_service._to_sandbox_info(stored_sandbox) - - # Verify - assert sandbox_info.status == SandboxStatus.MISSING - assert sandbox_info.session_api_key is None - assert sandbox_info.exposed_urls is None - @pytest.mark.asyncio async def test_to_sandbox_info_loads_runtime_when_none_provided( self, remote_sandbox_service @@ -391,15 +374,12 @@ class TestSandboxInfoConversion: """Test that runtime data is loaded when not provided.""" # Setup stored_sandbox = create_stored_sandbox() - runtime_data = create_runtime_data() - remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data) # Execute - sandbox_info = await remote_sandbox_service._to_sandbox_info(stored_sandbox) + sandbox_info = remote_sandbox_service._to_sandbox_info(stored_sandbox, None) # Verify - remote_sandbox_service._get_runtime.assert_called_once_with('test-sandbox-123') - assert sandbox_info.status == SandboxStatus.RUNNING + assert sandbox_info.status == SandboxStatus.MISSING class TestSandboxLifecycle: @@ -677,15 +657,18 @@ class TestSandboxSearch: mock_result = MagicMock() mock_result.scalars.return_value = mock_scalars remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result) - remote_sandbox_service._to_sandbox_info = AsyncMock( - side_effect=lambda stored: SandboxInfo( - id=stored.id, - created_by_user_id=stored.created_by_user_id, - sandbox_spec_id=stored.sandbox_spec_id, - status=SandboxStatus.RUNNING, - session_api_key='test-key', - created_at=stored.created_at, - ) + + # Mock the batch endpoint response + mock_batch_response = MagicMock() + mock_batch_response.raise_for_status.return_value = None + mock_batch_response.json.return_value = { + 'runtimes': [ + create_runtime_data('sb1'), + create_runtime_data('sb2'), + ] + } + remote_sandbox_service.httpx_client.request = AsyncMock( + return_value=mock_batch_response ) # Execute @@ -697,6 +680,14 @@ class TestSandboxSearch: assert result.items[0].id == 'sb1' assert result.items[1].id == 'sb2' + # Verify that the batch endpoint was called + remote_sandbox_service.httpx_client.request.assert_called_once_with( + 'GET', + 'https://api.example.com/sessions/batch', + headers={'X-API-Key': 'test-api-key'}, + params=[('ids', 'sb1'), ('ids', 'sb2')], + ) + @pytest.mark.asyncio async def test_search_sandboxes_with_pagination(self, remote_sandbox_service): """Test sandbox search with pagination.""" @@ -710,15 +701,15 @@ class TestSandboxSearch: mock_result = MagicMock() mock_result.scalars.return_value = mock_scalars remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result) - remote_sandbox_service._to_sandbox_info = AsyncMock( - side_effect=lambda stored: SandboxInfo( - id=stored.id, - created_by_user_id=stored.created_by_user_id, - sandbox_spec_id=stored.sandbox_spec_id, - status=SandboxStatus.RUNNING, - session_api_key='test-key', - created_at=stored.created_at, - ) + + # Mock the batch endpoint response + mock_batch_response = MagicMock() + mock_batch_response.raise_for_status.return_value = None + mock_batch_response.json.return_value = { + 'runtimes': [create_runtime_data(f'sb{i}') for i in range(6)] + } + remote_sandbox_service.httpx_client.request = AsyncMock( + return_value=mock_batch_response ) # Execute @@ -739,15 +730,15 @@ class TestSandboxSearch: mock_result = MagicMock() mock_result.scalars.return_value = mock_scalars remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result) - remote_sandbox_service._to_sandbox_info = AsyncMock( - side_effect=lambda stored: SandboxInfo( - id=stored.id, - created_by_user_id=stored.created_by_user_id, - sandbox_spec_id=stored.sandbox_spec_id, - status=SandboxStatus.RUNNING, - session_api_key='test-key', - created_at=stored.created_at, - ) + + # Mock the batch endpoint response + mock_batch_response = MagicMock() + mock_batch_response.raise_for_status.return_value = None + mock_batch_response.json.return_value = { + 'runtimes': [create_runtime_data('sb1')] + } + remote_sandbox_service.httpx_client.request = AsyncMock( + return_value=mock_batch_response ) # Execute @@ -757,6 +748,76 @@ class TestSandboxSearch: # Note: We can't easily verify the exact SQL query, but we can verify the method was called remote_sandbox_service.db_session.execute.assert_called_once() + @pytest.mark.asyncio + async def test_get_runtimes_batch_success(self, remote_sandbox_service): + """Test successful batch runtime retrieval.""" + # Setup + sandbox_ids = ['sb1', 'sb2', 'sb3'] + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = [ + create_runtime_data('sb1'), + create_runtime_data('sb2'), + create_runtime_data('sb3'), + ] + remote_sandbox_service.httpx_client.request = AsyncMock( + return_value=mock_response + ) + + # Execute + result = await remote_sandbox_service._get_runtimes_batch(sandbox_ids) + + # Verify + assert len(result) == 3 + assert 'sb1' in result + assert 'sb2' in result + assert 'sb3' in result + assert result['sb1']['session_id'] == 'sb1' + + # Verify the correct API call was made + remote_sandbox_service.httpx_client.request.assert_called_once_with( + 'GET', + 'https://api.example.com/sessions/batch', + headers={'X-API-Key': 'test-api-key'}, + params=[('ids', 'sb1'), ('ids', 'sb2'), ('ids', 'sb3')], + ) + + @pytest.mark.asyncio + async def test_get_runtimes_batch_empty_list(self, remote_sandbox_service): + """Test batch runtime retrieval with empty sandbox list.""" + # Execute + result = await remote_sandbox_service._get_runtimes_batch([]) + + # Verify + assert result == {} + # Verify no API call was made + remote_sandbox_service.httpx_client.request.assert_not_called() + + @pytest.mark.asyncio + async def test_get_runtimes_batch_partial_results(self, remote_sandbox_service): + """Test batch runtime retrieval with partial results (some sandboxes not found).""" + # Setup + sandbox_ids = ['sb1', 'sb2', 'sb3'] + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = [ + create_runtime_data('sb1'), + create_runtime_data('sb3'), + # sb2 is missing from the response + ] + remote_sandbox_service.httpx_client.request = AsyncMock( + return_value=mock_response + ) + + # Execute + result = await remote_sandbox_service._get_runtimes_batch(sandbox_ids) + + # Verify + assert len(result) == 2 + assert 'sb1' in result + assert 'sb2' not in result # Missing from response + assert 'sb3' in result + @pytest.mark.asyncio async def test_get_sandbox_exists(self, remote_sandbox_service): """Test getting an existing sandbox.""" @@ -765,7 +826,7 @@ class TestSandboxSearch: remote_sandbox_service._get_stored_sandbox = AsyncMock( return_value=stored_sandbox ) - remote_sandbox_service._to_sandbox_info = AsyncMock( + remote_sandbox_service._to_sandbox_info = MagicMock( return_value=SandboxInfo( id='test-sandbox-123', created_by_user_id='test-user-123', diff --git a/tests/unit/controller/test_agent_controller.py b/tests/unit/controller/test_agent_controller.py index da12ee8f9e..3da87ecfdf 100644 --- a/tests/unit/controller/test_agent_controller.py +++ b/tests/unit/controller/test_agent_controller.py @@ -24,6 +24,7 @@ from openhands.core.schema import AgentState from openhands.events import Event, EventSource, EventStream, EventStreamSubscriber from openhands.events.action import ChangeAgentStateAction, CmdRunAction, MessageAction from openhands.events.action.agent import CondensationAction, RecallAction +from openhands.events.action.empty import NullAction from openhands.events.action.message import SystemMessageAction from openhands.events.event import RecallType from openhands.events.observation import ( @@ -299,6 +300,64 @@ async def test_react_to_content_policy_violation( await controller.close() +@pytest.mark.asyncio +async def test_tool_call_validation_error_handling( + mock_agent_with_stats, + test_event_stream, +): + """Test that tool call validation errors from Groq are handled as recoverable errors.""" + mock_agent, conversation_stats, llm_registry = mock_agent_with_stats + + controller = AgentController( + agent=mock_agent, + event_stream=test_event_stream, + conversation_stats=conversation_stats, + iteration_delta=10, + sid='test', + confirmation_mode=False, + headless_mode=True, + ) + + controller.state.agent_state = AgentState.RUNNING + + # Track call count to only raise error on first call + # This prevents a feedback loop where ErrorObservation triggers another step + # which raises the same error again (since the mock always raises) + call_count = 0 + + def mock_step(state): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise BadRequestError( + message='litellm.BadRequestError: GroqException - {"error":{"message":"tool call validation failed: parameters for tool str_replace_editor did not match schema: errors: [missing properties: \'path\']","type":"invalid_request_error","code":"tool_use_failed"}}', + model='groq/llama3-8b-8192', + llm_provider='groq', + ) + # Return NullAction on subsequent calls to break the feedback loop + return NullAction() + + mock_agent.step = mock_step + + # Call _step which should handle the tool validation error + await controller._step() + + # Verify that the agent state is still RUNNING (not ERROR) + assert controller.state.agent_state == AgentState.RUNNING + + # Verify that an ErrorObservation was added to the event stream + events = list(test_event_stream.get_events()) + error_observations = [e for e in events if isinstance(e, ErrorObservation)] + assert len(error_observations) == 1 + + error_obs = error_observations[0] + assert 'tool call validation failed' in error_obs.content + assert 'missing properties' in error_obs.content + assert 'path' in error_obs.content + + await controller.close() + + @pytest.mark.asyncio async def test_run_controller_with_fatal_error( test_event_stream, mock_memory, mock_agent_with_stats diff --git a/tests/unit/server/routes/test_mcp_routes.py b/tests/unit/server/routes/test_mcp_routes.py index 1a55cc0a39..8677b8c85c 100644 --- a/tests/unit/server/routes/test_mcp_routes.py +++ b/tests/unit/server/routes/test_mcp_routes.py @@ -1,3 +1,4 @@ +import warnings from unittest.mock import AsyncMock, patch import pytest @@ -7,6 +8,38 @@ from openhands.server.routes.mcp import get_conversation_link from openhands.server.types import AppMode +def test_mcp_server_no_stateless_http_deprecation_warning(): + """Test that mcp_server is created without stateless_http deprecation warning. + + This test verifies the fix for the fastmcp deprecation warning: + 'Providing `stateless_http` when creating a server is deprecated. + Provide it when calling `run` or as a global setting instead.' + + The fix moves the stateless_http parameter from FastMCP() constructor + to the http_app() method call. + """ + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + + # Import the mcp_server which triggers FastMCP creation + from openhands.server.routes.mcp import mcp_server + + # Check that no deprecation warning about stateless_http was raised + stateless_http_warnings = [ + warning + for warning in w + if issubclass(warning.category, DeprecationWarning) + and 'stateless_http' in str(warning.message) + ] + + assert len(stateless_http_warnings) == 0, ( + f'Unexpected stateless_http deprecation warning: {stateless_http_warnings}' + ) + + # Verify mcp_server was created successfully + assert mcp_server is not None + + @pytest.mark.asyncio async def test_get_conversation_link_non_saas_mode(): """Test get_conversation_link in non-SAAS mode.""" diff --git a/trigger_commit.txt b/trigger_commit.txt deleted file mode 100644 index 402f8bb0e5..0000000000 --- a/trigger_commit.txt +++ /dev/null @@ -1 +0,0 @@ -# Trigger E2E test run